diff --git a/database/migrations/2025_07_31_164635_change_hashtags_collation.php b/database/migrations/2025_07_31_164635_change_hashtags_collation.php new file mode 100644 index 000000000..6a1f965e1 --- /dev/null +++ b/database/migrations/2025_07_31_164635_change_hashtags_collation.php @@ -0,0 +1,76 @@ += 0x10000). The historic default + * of utf8mb4_unicode_ci treats all such characters as equal, which causes + * distinct hashtags (e.g. Shavian vs cuneiform of the same length) to + * collide on the unique name/slug indexes. + */ + private const TARGET_COLLATION = 'utf8mb4_unicode_520_ci'; + + /** + * The collation to restore on rollback (the previous project default). + */ + private const PREVIOUS_COLLATION = 'utf8mb4_unicode_ci'; + + /** + * Column definitions to keep intact while altering the collation. Both are + * VARCHAR(255) NOT NULL with unique indexes; MODIFY preserves the index. + */ + private const COLUMNS = ['name', 'slug']; + + /** + * Run the migrations. + */ + public function up(): void + { + $this->setCollation(self::TARGET_COLLATION); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $this->setCollation(self::PREVIOUS_COLLATION); + } + + /** + * Apply the given collation to the hashtags name and slug columns. + * + * Laravel's fluent ->change() does not reliably emit a collation-only + * change on MySQL/MariaDB, so issue an explicit MODIFY per column. + */ + private function setCollation(string $collation): void + { + if (! $this->isMysql()) { + return; + } + + foreach (self::COLUMNS as $column) { + DB::statement( + 'ALTER TABLE `hashtags` MODIFY `'.$column.'` '. + 'VARCHAR(255) CHARACTER SET utf8mb4 COLLATE '.$collation.' NOT NULL' + ); + } + } + + /** + * This migration only applies to MySQL/MariaDB. Postgres compares + * hashtags with ILIKE (no collation quirk) and other drivers (e.g. the + * sqlite test database) do not support these collations. + * + * Note: Laravel 11+ reports MariaDB as the distinct "mariadb" driver, so + * both must be matched here. + */ + private function isMysql(): bool + { + return in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true); + } +}; diff --git a/tests/Feature/HashtagCollationTest.php b/tests/Feature/HashtagCollationTest.php new file mode 100644 index 000000000..bde6b4af4 --- /dev/null +++ b/tests/Feature/HashtagCollationTest.php @@ -0,0 +1,108 @@ += 0x10000). +| +*/ + +it('migration runs without error regardless of database driver', function () { + // The test suite uses sqlite by default. The migration detects the driver + // and no-ops gracefully rather than attempting unsupported ALTER syntax. + Artisan::call('migrate', [ + '--path' => 'database/migrations/2025_07_31_164635_change_hashtags_collation.php', + '--force' => true, + ]); + + // If we reach here without exception the no-op path worked. + expect(true)->toBeTrue(); +}); + +it('migration rollback runs without error regardless of database driver', function () { + Artisan::call('migrate', [ + '--path' => 'database/migrations/2025_07_31_164635_change_hashtags_collation.php', + '--force' => true, + ]); + + Artisan::call('migrate:rollback', [ + '--path' => 'database/migrations/2025_07_31_164635_change_hashtags_collation.php', + '--force' => true, + ]); + + expect(true)->toBeTrue(); +}); + +it('distinct BMP-outside hashtags do not collide on the unique index', function () { + // This test is only meaningful on MySQL/MariaDB where the collation fix matters. + if (! in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true)) { + $this->markTestSkipped('Collation behavior is MySQL/MariaDB-specific.'); + } + + // Ensure the migration has been applied. + Artisan::call('migrate', [ + '--path' => 'database/migrations/2025_07_31_164635_change_hashtags_collation.php', + '--force' => true, + ]); + + // Two distinct 5-character hashtags using characters outside the BMP. + // Under the old utf8mb4_unicode_ci collation these were considered equal. + $shavian = '𐑖𐑱𐑝𐑾𐑯'; // Shavian script + $cuneiform = 'π’†³π’†π’€­π’Šπ’† '; // Cuneiform script + + $tag1 = Hashtag::create(['name' => $shavian, 'slug' => $shavian]); + $tag2 = Hashtag::create(['name' => $cuneiform, 'slug' => $cuneiform]); + + // Both must coexist as separate rows with distinct IDs. + expect($tag1->id)->not->toBe($tag2->id); + expect(Hashtag::where('slug', $shavian)->first()->id)->toBe($tag1->id); + expect(Hashtag::where('slug', $cuneiform)->first()->id)->toBe($tag2->id); +}); + +it('same-script hashtags with the same slug still correctly deduplicate', function () { + // Sanity check: two identical hashtags should NOT create duplicates. + if (! in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true)) { + $this->markTestSkipped('Collation behavior is MySQL/MariaDB-specific.'); + } + + Artisan::call('migrate', [ + '--path' => 'database/migrations/2025_07_31_164635_change_hashtags_collation.php', + '--force' => true, + ]); + + $tag = Hashtag::firstOrCreate(['slug' => 'hello'], ['name' => 'hello']); + $same = Hashtag::firstOrCreate(['slug' => 'hello'], ['name' => 'hello']); + + expect($tag->id)->toBe($same->id); + expect(Hashtag::where('slug', 'hello')->count())->toBe(1); +}); + +it('case-insensitivity is preserved after collation change', function () { + // utf8mb4_unicode_520_ci is still case-insensitive, so #Hello == #hello. + if (! in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true)) { + $this->markTestSkipped('Collation behavior is MySQL/MariaDB-specific.'); + } + + Artisan::call('migrate', [ + '--path' => 'database/migrations/2025_07_31_164635_change_hashtags_collation.php', + '--force' => true, + ]); + + Hashtag::create(['name' => 'Pixelfed', 'slug' => 'pixelfed']); + + // Case-insensitive lookup should find it with different casing. + $found = Hashtag::where('slug', 'PIXELFED')->first(); + expect($found)->not->toBeNull(); + expect($found->slug)->toBe('pixelfed'); +});