@ -25,22 +25,124 @@ return new class extends Migration
*/
private const COLUMNS = ['name', 'slug'];
/**
* Tables that reference hashtags.id via a hashtag_id column. Rows in these
* tables must be repointed from a duplicate hashtag to the surviving one
* before the duplicate is deleted, so no references are orphaned.
*
* group_post_hashtags is intentionally excluded: it references the
* separate group_hashtags table, not hashtags.
*/
private const REFERENCING_TABLES = [
'status_hashtags',
'hashtag_follows',
'hashtag_related',
'discover_category_hashtags',
];
/**
* Run the migrations.
*/
public function up(): void
{
if (! $this->isMysql()) {
return;
}
// The unique indexes on name/slug are enforced during the ALTER. Under
// the stricter target collation, rows that were previously distinct may
// now be considered equal, so merge those duplicates first to avoid a
// "Duplicate entry" (1062) failure on the ALTER TABLE.
$this->mergeDuplicates('name');
$this->mergeDuplicates('slug');
$this->setCollation(self::TARGET_COLLATION);
}
/**
* Reverse the migrations.
*
* Note: merged duplicate rows are not restored on rollback (the data is
* gone). Only the collation is reverted.
*/
public function down(): void
{
if (! $this->isMysql()) {
return;
}
$this->setCollation(self::PREVIOUS_COLLATION);
}
/**
* Merge hashtags that collide on the given column under the target
* collation. For each group of colliding rows, the lowest id is kept and
* all references are repointed to it before the losing rows are deleted.
*/
private function mergeDuplicates(string $column): void
{
$collatedColumn = 'CONVERT(`'.$column.'` USING utf8mb4) COLLATE '.self::TARGET_COLLATION;
// Group by the value compared under the target collation. Any group
// with more than one row would violate the unique index after the
// collation change.
$groups = DB::table('hashtags')
->select(DB::raw('MIN(id) as keep_id'))
->groupBy(DB::raw($collatedColumn))
->havingRaw('COUNT(*) > 1')
->get();
foreach ($groups as $group) {
$keepId = (int) $group->keep_id;
// Find the losing rows: everything in the same collated group
// except the surviving (lowest) id.
$keepValue = DB::table('hashtags')->where('id', $keepId)->value($column);
$losers = DB::table('hashtags')
->whereRaw($collatedColumn.' = CONVERT(? USING utf8mb4) COLLATE '.self::TARGET_COLLATION, [$keepValue])
->where('id', '!=', $keepId)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
if (empty($losers)) {
continue;
}
$this->repointReferences($losers, $keepId);
DB::table('hashtags')->whereIn('id', $losers)->delete();
}
}
/**
* Repoint references from the losing hashtag ids to the surviving id.
* UPDATE IGNORE avoids aborting on unique constraints in the referencing
* tables; any rows that could not be repointed (because an equivalent
* reference to keep_id already exists) are then deleted.
*/
private function repointReferences(array $loserIds, int $keepId): void
{
$placeholders = implode(',', array_fill(0, count($loserIds), '?'));
foreach (self::REFERENCING_TABLES as $table) {
if (! DB::getSchemaBuilder()->hasColumn($table, 'hashtag_id')) {
continue;
}
DB::statement(
'UPDATE IGNORE `'.$table.'` SET `hashtag_id` = ? WHERE `hashtag_id` IN ('.$placeholders.')',
array_merge([$keepId], $loserIds)
);
DB::statement(
'DELETE FROM `'.$table.'` WHERE `hashtag_id` IN ('.$placeholders.')',
$loserIds
);
}
}
/**
* Apply the given collation to the hashtags name and slug columns.
*
@ -49,10 +151,6 @@ return new class extends Migration
*/
private function setCollation(string $collation): void
{
if (! $this->isMysql()) {
return;
}
foreach (self::COLUMNS as $column) {
DB::statement(
'ALTER TABLE `hashtags` MODIFY `'.$column.'` '.