['role_id', 'user_id'], 'permission_role' => ['permission_id', 'role_id'], 'project_user' => ['project_id', 'user_id'], 'sprint_task' => ['sprint_id', 'task_id'], 'sprint_members' => ['sprint_id', 'user_id'], 'meeting_user' => ['meeting_id', 'user_id'], 'department_user' => ['department_id', 'user_id'], ]; public function handle(): int { $sourcePath = config('database.connections.migration_sqlite.database'); if (! is_string($sourcePath) || ! is_file($sourcePath)) { $this->error('SQLite source file does not exist.'); return self::FAILURE; } if (config('database.connections.migration_mysql.driver') !== 'mysql') { $this->error('The migration target must use the mysql driver.'); return self::FAILURE; } $source = DB::connection('migration_sqlite'); $target = DB::connection('migration_mysql'); try { $target->select('SELECT 1'); } catch (Throwable $exception) { $this->error('MySQL target connection failed: '.$exception->getCode()); return self::FAILURE; } $tables = $this->transferableTables($source); if ($tables === []) { $this->warn('No transferable tables were found.'); return self::SUCCESS; } $this->table(['Mode', 'Source', 'Target', 'Tables'], [[ $this->option('execute') ? 'EXECUTE' : 'DRY RUN', basename($sourcePath), (string) config('database.connections.migration_mysql.database'), count($tables), ]]); if (! $this->option('execute')) { foreach ($tables as $table) { $this->line(sprintf('%s: %d rows', $table, $source->table($table)->count())); } return self::SUCCESS; } $chunkSize = max(10, min((int) $this->option('chunk'), 2000)); Schema::connection('migration_mysql')->disableForeignKeyConstraints(); try { if ($this->option('truncate')) { foreach (array_reverse($tables) as $table) { $target->table($table)->truncate(); } } foreach ($tables as $table) { $this->transferTable($source, $target, $table, $chunkSize); } } finally { Schema::connection('migration_mysql')->enableForeignKeyConstraints(); } $failed = false; foreach ($tables as $table) { $sourceCount = $source->table($table)->count(); $targetCount = $target->table($table)->count(); $sourceDigest = $this->tableDigest($source, $table); $targetDigest = $this->tableDigest($target, $table); $match = $sourceCount === $targetCount && hash_equals($sourceDigest, $targetDigest); $failed = $failed || ! $match; $this->line(sprintf( '%s source=%d target=%d content=%s %s', $table, $sourceCount, $targetCount, hash_equals($sourceDigest, $targetDigest) ? 'MATCH' : 'MISMATCH', $match ? 'OK' : 'MISMATCH' )); } if ($failed) { $this->error('Transfer completed with count mismatches.'); return self::FAILURE; } $this->info('Transfer and row-count validation completed successfully.'); return self::SUCCESS; } private function transferableTables(ConnectionInterface $source): array { $sourceTables = collect($source->select( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ))->pluck('name'); $targetTables = collect(Schema::connection('migration_mysql')->getTableListing()) ->map(fn (string $table) => str_contains($table, '.') ? str($table)->afterLast('.')->toString() : $table); $missing = $sourceTables->diff($targetTables)->reject( fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true) ); if ($missing->isNotEmpty()) { throw new RuntimeException('Target schema is missing tables: '.$missing->implode(', ')); } return $sourceTables ->intersect($targetTables) ->reject(fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true)) ->values() ->all(); } private function transferTable( ConnectionInterface $source, ConnectionInterface $target, string $table, int $chunkSize ): void { $columns = collect(Schema::connection('migration_mysql')->getColumnListing($table)); $keys = $this->keyColumns($source, $table); $orderColumn = $keys[0] ?? $columns->first(); $processed = 0; $source->table($table) ->orderBy($orderColumn) ->chunk($chunkSize, function ($rows) use ($target, $table, $columns, $keys, &$processed) { $payload = collect($rows)->map( fn ($row) => collect((array) $row)->only($columns)->all() )->all(); if ($payload === []) { return; } if ($keys !== []) { $target->table($table)->upsert($payload, $keys, $columns->diff($keys)->all()); } else { foreach ($payload as $row) { $target->table($table)->updateOrInsert($row, $row); } } $processed += count($payload); }); $this->info(sprintf('%s: %d rows transferred', $table, $processed)); } private function keyColumns(ConnectionInterface $source, string $table): array { if (isset(self::NATURAL_KEYS[$table])) { return self::NATURAL_KEYS[$table]; } return collect($source->select("PRAGMA table_info('".str_replace("'", "''", $table)."')")) ->filter(fn ($column) => (int) $column->pk > 0) ->sortBy(fn ($column) => (int) $column->pk) ->pluck('name') ->all(); } private function tableDigest(ConnectionInterface $connection, string $table): string { $columns = Schema::connection($connection->getName())->getColumnListing($table); sort($columns); $order = $this->keyColumns(DB::connection('migration_sqlite'), $table); if ($order === []) { $order = $columns; } $query = $connection->table($table)->select($columns); foreach ($order as $column) { $query->orderBy($column); } $context = hash_init('sha256'); foreach ($query->cursor() as $row) { $normalized = []; foreach ($columns as $column) { $normalized[$column] = $this->normalizeValue($row->{$column}); } hash_update($context, json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n"); } return hash_final($context); } private function normalizeValue(mixed $value): mixed { if ($value === null) { return null; } $string = (string) $value; if (preg_match('/^\d{4}-\d{2}-\d{2} 00:00:00$/', $string)) { return substr($string, 0, 10); } $decoded = json_decode($string, true); if (json_last_error() === JSON_ERROR_NONE && (str_starts_with($string, '{') || str_starts_with($string, '['))) { return $this->normalizeJson($decoded); } if (preg_match('/^-?\d+\.\d+$/', $string)) { return rtrim(rtrim($string, '0'), '.'); } return $string; } private function normalizeJson(mixed $value): mixed { if (! is_array($value)) { return $value; } if (! array_is_list($value)) { ksort($value); } return array_map(fn ($item) => $this->normalizeJson($item), $value); } }