From 40567476662d432522b684fc7f2d8c3980c94465 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 29 Aug 2026 23:10:55 +0930 Subject: [PATCH] Revert hashtags collation migration from #6098 Migration failed with a duplicate entry error: recollating to utf8mb4_unicode_520_ci causes previously-distinct hashtag names/slugs to collide on the unique indexes. Reverting until the data is de-duplicated first. --- ...07_31_164635_change_hashtags_collation.php | 76 ------------ tests/Feature/HashtagCollationTest.php | 108 ------------------ 2 files changed, 184 deletions(-) delete mode 100644 database/migrations/2025_07_31_164635_change_hashtags_collation.php delete mode 100644 tests/Feature/HashtagCollationTest.php diff --git a/database/migrations/2025_07_31_164635_change_hashtags_collation.php b/database/migrations/2025_07_31_164635_change_hashtags_collation.php deleted file mode 100644 index 6a1f965e1..000000000 --- a/database/migrations/2025_07_31_164635_change_hashtags_collation.php +++ /dev/null @@ -1,76 +0,0 @@ -= 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 deleted file mode 100644 index bde6b4af4..000000000 --- a/tests/Feature/HashtagCollationTest.php +++ /dev/null @@ -1,108 +0,0 @@ -= 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'); -});