267 lines
9.7 KiB
PHP
267 lines
9.7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Migrates legacy `artworks_comments` (projekti_old_skinbase) into `artwork_comments`.
|
||
*
|
||
* Column mapping:
|
||
* legacy.comment_id → artwork_comments.legacy_id (idempotency key)
|
||
* legacy.artwork_id → artwork_comments.artwork_id
|
||
* legacy.user_id → artwork_comments.user_id
|
||
* legacy.description → artwork_comments.content
|
||
* legacy.date + .time → artwork_comments.created_at / updated_at
|
||
*
|
||
* Ignored legacy columns: owner, author (username strings), owner_user_id
|
||
*
|
||
* Usage:
|
||
* php artisan comments:import-legacy
|
||
* php artisan comments:import-legacy --dry-run
|
||
* php artisan comments:import-legacy --chunk=1000
|
||
* php artisan comments:import-legacy --allow-guest-user=0 (import rows where user_id maps to 0 / not found, assigning a fallback user_id)
|
||
*/
|
||
class ImportLegacyComments extends Command
|
||
{
|
||
protected $signature = 'comments:import-legacy
|
||
{--dry-run : Preview only — no writes to DB}
|
||
{--chunk=500 : Rows to process per batch}
|
||
{--skip-empty : Skip comments with empty/whitespace-only content}';
|
||
|
||
protected $description = 'Import legacy artworks_comments into artwork_comments';
|
||
|
||
public function handle(): int
|
||
{
|
||
$dryRun = (bool) $this->option('dry-run');
|
||
$chunk = max(1, (int) $this->option('chunk'));
|
||
$skipEmpty = (bool) $this->option('skip-empty');
|
||
|
||
if ($dryRun) {
|
||
$this->warn('[DRY-RUN] No data will be written.');
|
||
}
|
||
|
||
// Verify legacy connection
|
||
try {
|
||
DB::connection('legacy')->getPdo();
|
||
} catch (\Throwable $e) {
|
||
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
||
return self::FAILURE;
|
||
}
|
||
|
||
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('artworks_comments')) {
|
||
$this->error('Legacy table `artworks_comments` not found.');
|
||
return self::FAILURE;
|
||
}
|
||
|
||
if (! DB::getSchemaBuilder()->hasColumn('artwork_comments', 'legacy_id')) {
|
||
$this->error('Column `legacy_id` missing from `artwork_comments`. Run: php artisan migrate');
|
||
return self::FAILURE;
|
||
}
|
||
|
||
// Pre-load valid artwork IDs and user IDs from new DB for O(1) lookup
|
||
$this->info('Loading new-DB artwork and user ID sets…');
|
||
|
||
$validArtworkIds = DB::table('artworks')
|
||
->whereNull('deleted_at')
|
||
->pluck('id')
|
||
->flip()
|
||
->all();
|
||
|
||
$validUserIds = DB::table('users')
|
||
->whereNull('deleted_at')
|
||
->pluck('id')
|
||
->flip()
|
||
->all();
|
||
|
||
$this->info(sprintf(
|
||
'Found %d artworks and %d users in new DB.',
|
||
count($validArtworkIds),
|
||
count($validUserIds)
|
||
));
|
||
|
||
// Already-imported legacy IDs (to resume safely)
|
||
$this->info('Loading already-imported legacy_ids…');
|
||
$alreadyImported = DB::table('artwork_comments')
|
||
->whereNotNull('legacy_id')
|
||
->pluck('legacy_id')
|
||
->flip()
|
||
->all();
|
||
|
||
$this->info(sprintf('%d comments already imported (will be skipped).', count($alreadyImported)));
|
||
|
||
$total = DB::connection('legacy')->table('artworks_comments')->count();
|
||
$this->info("Legacy rows to process: {$total}");
|
||
|
||
if ($total === 0) {
|
||
$this->warn('No legacy rows found. Nothing to do.');
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
$stats = [
|
||
'imported' => 0,
|
||
'skipped_duplicate' => 0,
|
||
'skipped_artwork' => 0,
|
||
'skipped_user' => 0,
|
||
'skipped_empty' => 0,
|
||
'errors' => 0,
|
||
];
|
||
|
||
$bar = $this->output->createProgressBar($total);
|
||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
|
||
$bar->setMessage('0', 'imported');
|
||
$bar->setMessage('0', 'skipped');
|
||
$bar->start();
|
||
|
||
DB::connection('legacy')
|
||
->table('artworks_comments')
|
||
->orderBy('comment_id')
|
||
->chunk($chunk, function ($rows) use (
|
||
&$stats,
|
||
&$alreadyImported,
|
||
$validArtworkIds,
|
||
$validUserIds,
|
||
$dryRun,
|
||
$skipEmpty,
|
||
$bar
|
||
) {
|
||
$inserts = [];
|
||
$now = now();
|
||
|
||
foreach ($rows as $row) {
|
||
$legacyId = (int) $row->comment_id;
|
||
$artworkId = (int) $row->artwork_id;
|
||
$userId = (int) $row->user_id;
|
||
$content = trim((string) ($row->description ?? ''));
|
||
|
||
// --- Already imported ---
|
||
if (isset($alreadyImported[$legacyId])) {
|
||
$stats['skipped_duplicate']++;
|
||
$bar->advance();
|
||
continue;
|
||
}
|
||
|
||
// --- Content ---
|
||
if ($skipEmpty && $content === '') {
|
||
$stats['skipped_empty']++;
|
||
$bar->advance();
|
||
continue;
|
||
}
|
||
|
||
// Replace empty content with a placeholder so NOT NULL is satisfied
|
||
if ($content === '') {
|
||
$content = '[no content]';
|
||
}
|
||
|
||
// --- Artwork must exist ---
|
||
if (! isset($validArtworkIds[$artworkId])) {
|
||
$stats['skipped_artwork']++;
|
||
$bar->advance();
|
||
continue;
|
||
}
|
||
|
||
// --- User must exist ---
|
||
if (! isset($validUserIds[$userId])) {
|
||
$stats['skipped_user']++;
|
||
$bar->advance();
|
||
continue;
|
||
}
|
||
|
||
// --- Build timestamp from separate date + time columns ---
|
||
$createdAt = $this->buildTimestamp($row->date, $row->time, $now);
|
||
|
||
if (! $dryRun) {
|
||
$inserts[] = [
|
||
'legacy_id' => $legacyId,
|
||
'artwork_id' => $artworkId,
|
||
'user_id' => $userId,
|
||
'content' => $content,
|
||
'is_approved' => 1,
|
||
'created_at' => $createdAt,
|
||
'updated_at' => $createdAt,
|
||
'deleted_at' => null,
|
||
];
|
||
|
||
$alreadyImported[$legacyId] = true;
|
||
}
|
||
|
||
$stats['imported']++;
|
||
$bar->advance();
|
||
}
|
||
|
||
if (! $dryRun && ! empty($inserts)) {
|
||
try {
|
||
DB::table('artwork_comments')->insert($inserts);
|
||
} catch (\Throwable $e) {
|
||
// Fallback: row-by-row with ignore on unique violations
|
||
foreach ($inserts as $row) {
|
||
try {
|
||
DB::table('artwork_comments')->insertOrIgnore([$row]);
|
||
} catch (\Throwable) {
|
||
$stats['errors']++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$skippedTotal = $stats['skipped_duplicate']
|
||
+ $stats['skipped_artwork']
|
||
+ $stats['skipped_user']
|
||
+ $stats['skipped_empty'];
|
||
|
||
$bar->setMessage((string) $stats['imported'], 'imported');
|
||
$bar->setMessage((string) $skippedTotal, 'skipped');
|
||
});
|
||
|
||
$bar->finish();
|
||
$this->newLine(2);
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Summary
|
||
// -------------------------------------------------------------------------
|
||
$this->table(
|
||
['Result', 'Count'],
|
||
[
|
||
['Imported', $stats['imported']],
|
||
['Skipped – already imported', $stats['skipped_duplicate']],
|
||
['Skipped – artwork gone', $stats['skipped_artwork']],
|
||
['Skipped – user gone', $stats['skipped_user']],
|
||
['Skipped – empty content', $stats['skipped_empty']],
|
||
['Errors', $stats['errors']],
|
||
]
|
||
);
|
||
|
||
if ($dryRun) {
|
||
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
|
||
} else {
|
||
$this->info('Migration complete.');
|
||
}
|
||
|
||
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* Combine a legacy `date` (DATE) and `time` (TIME) column into a single datetime string.
|
||
* Falls back to $fallback when both are null.
|
||
*/
|
||
private function buildTimestamp(mixed $date, mixed $time, \Illuminate\Support\Carbon $fallback): string
|
||
{
|
||
if (! $date) {
|
||
return $fallback->toDateTimeString();
|
||
}
|
||
|
||
$datePart = substr((string) $date, 0, 10); // '2000-09-13'
|
||
$timePart = $time ? substr((string) $time, 0, 8) : '00:00:00'; // '09:34:27'
|
||
|
||
// Sanity-check: MySQL TIME can be negative or > 24h for intervals — clamp to midnight
|
||
if (! preg_match('/^\d{2}:\d{2}:\d{2}$/', $timePart) || $timePart < '00:00:00') {
|
||
$timePart = '00:00:00';
|
||
}
|
||
|
||
return $datePart . ' ' . $timePart;
|
||
}
|
||
}
|