fix: use utf8mb4_unicode_520_ci for hashtags to fix BMP-outside collation (PR #6098)

MySQL/MariaDB's utf8mb4_unicode_ci collation treats all characters outside
the Basic Multilingual Plane as equal, conflating distinct same-length
hashtags (e.g. Shavian vs cuneiform) on the unique name/slug indexes.

Migrate the hashtags name/slug columns to utf8mb4_unicode_520_ci, which
differentiates supplementary-plane characters. Improvements over the
original PR:
- Use an explicit ALTER ... MODIFY, since Laravel's fluent ->change() emits
  no collation change on MySQL/MariaDB and silently no-ops.
- Match both 'mysql' and 'mariadb' drivers (Laravel 11+ reports MariaDB as a
  distinct driver, so a mysql-only check would skip the fix on MariaDB).
- Provide an accurate, reversible down() and preserve NOT NULL + unique keys.

Add feature tests covering the no-op path on non-MySQL drivers and, on
MySQL/MariaDB, that distinct BMP-outside hashtags coexist while same-slug
and case-insensitive dedup still work.
fix/hashtags-outside-bmp
Your Name 4 weeks ago
parent cca9eb426d
commit 88adf721c3

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* The collation that correctly differentiates characters outside the
* Basic Multilingual Plane (codepoints >= 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);
}
};

@ -0,0 +1,108 @@
<?php
use App\Models\Hashtag;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Hashtag collation fix (PR #6098)
|--------------------------------------------------------------------------
|
| Verifies that the hashtags collation migration runs safely on all drivers
| and, on MySQL/MariaDB, prevents conflation of distinct hashtags that use
| characters outside the Basic Multilingual Plane (codepoints >= 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');
});
Loading…
Cancel
Save