Files
takeone-youtube-clone/app/Console/Commands/CommentsDedupeCommand.php
T
ghassanandClaude Opus 4.7 48c947d171 feat(cli): comments:dedupe cleanup command
Collapse historical duplicate comments produced before the 60-second
dedupe guard landed. Keeps the earliest row in each cluster, reparents
nested replies onto it, moves comment_likes rows, deletes the rest.

Default --window=60 matches the server guard; --dry-run shows the plan
without touching the DB. Transactional per group so a partial failure
does not leave orphaned likes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:38:34 +03:00

123 lines
4.2 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Comment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Clean up duplicate comments that pre-date the server-side 60-second dedupe
* guard. Duplicate = identical (user_id, video_id, parent_id, body) within a
* configurable gap. Keeps the earliest row, moves any likes onto it, deletes
* the rest. --dry-run shows what would happen without touching the DB.
*/
class CommentsDedupeCommand extends Command
{
protected $signature = 'comments:dedupe
{--window=60 : Treat duplicates as same submission if within N seconds (default 60)}
{--dry-run : Print what would be removed; do not delete}';
protected $description = 'Collapse duplicate comments (same user, video, parent, body) into the earliest one';
public function handle(): int
{
$window = max(1, (int) $this->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;
}
}