option('window')); $dryRun = (bool) $this->option('dry-run'); // Fetch (user_id, video_id, parent_id, body) groups that appear >1 time. // We ignore the window at the SQL layer and apply it per-group in PHP: // that avoids a correlated subquery and is fast enough for a one-shot // cleanup. For very large tables, chunk by user_id. $groups = DB::table('comments') ->selectRaw('user_id, video_id, parent_id, body, COUNT(*) as cnt') ->groupBy('user_id', 'video_id', 'parent_id', 'body') ->havingRaw('COUNT(*) > 1') ->get(); if ($groups->isEmpty()) { $this->info('No duplicate (user, video, parent, body) groups found.'); return self::SUCCESS; } $totalRemoved = 0; $groupsTouched = 0; foreach ($groups as $g) { $rows = Comment::where('user_id', $g->user_id) ->where('video_id', $g->video_id) ->where(function ($q) use ($g) { $g->parent_id === null ? $q->whereNull('parent_id') : $q->where('parent_id', $g->parent_id); }) ->where('body', $g->body) ->orderBy('created_at') ->orderBy('id') ->get(); if ($rows->count() < 2) { continue; } $keeper = $rows->first(); $cluster = collect([$keeper]); $previous = $keeper; foreach ($rows->slice(1) as $row) { // Same cluster if row is within window of the previous row — // which is the behavior the server guard enforces today. if ($row->created_at->diffInSeconds($previous->created_at) <= $window) { $cluster->push($row); $previous = $row; } } if ($cluster->count() < 2) { continue; } $toDelete = $cluster->skip(1); $groupsTouched++; $totalRemoved += $toDelete->count(); $this->line(sprintf( ' user=%d video=%d parent=%s keep=%d remove=%s body="%s"', $g->user_id, $g->video_id, $g->parent_id ?? 'null', $keeper->id, $toDelete->pluck('id')->join(','), mb_strimwidth(preg_replace('/\s+/', ' ', (string) $g->body), 0, 40, '…'), )); if ($dryRun) { continue; } DB::transaction(function () use ($keeper, $toDelete) { $ids = $toDelete->pluck('id')->all(); // Reparent any replies that were nested under a doomed row. Comment::whereIn('parent_id', $ids)->update(['parent_id' => $keeper->id]); // Move likes to the keeper, dropping dupes. DB::table('comment_likes') ->whereIn('comment_id', $ids) ->update(['comment_id' => $keeper->id]); Comment::whereIn('id', $ids)->delete(); }); } $this->info(sprintf( '%s %d duplicate rows across %d groups.', $dryRun ? 'Would remove' : 'Removed', $totalRemoved, $groupsTouched, )); return self::SUCCESS; } }