*/ private array $alreadyPresent = []; /** @var array> */ private array $replaceSeeds = []; public function handle(): int { try { $source = DB::connection('sqlite_legacy'); $target = DB::connection('mysql'); $this->assertConnections($source, $target); $tables = $this->orderedTables($source); $this->assertTargetSchema($target, $tables); $this->reportPlan($source, $target, $tables); if (! $this->option('execute')) { $this->info('Dry-run completed. No rows were written. Use --execute to transfer.'); return self::SUCCESS; } $this->assertEmptyTarget($target, $tables); $this->copy($source, $target, $tables); $this->verify($source, $target, $tables); $this->info('Transfer and integrity verification completed successfully.'); return self::SUCCESS; } catch (Throwable $exception) { $this->error($exception->getMessage()); return self::FAILURE; } } private function assertConnections(Connection $source, Connection $target): void { if ($source->getDriverName() !== 'sqlite') { throw new RuntimeException('sqlite_legacy must use SQLite.'); } if (! in_array($target->getDriverName(), ['mysql', 'mariadb'], true)) { throw new RuntimeException('The mysql connection must point to MariaDB/MySQL.'); } $path = (string) config('database.connections.sqlite_legacy.database'); if (! is_file($path) || ! is_readable($path)) { throw new RuntimeException('The legacy SQLite database is missing or unreadable.'); } $server = $target->selectOne('select version() version, @@character_set_database charset, @@collation_database collation'); if ($server->charset !== 'utf8mb4' || $server->collation !== 'utf8mb4_unicode_ci') { throw new RuntimeException('Target database must use utf8mb4 and utf8mb4_unicode_ci.'); } $this->info("Target server: {$server->version}; {$server->charset}/{$server->collation}"); } /** @return list */ private function orderedTables(Connection $source): array { $excluded = ['migrations', ...($this->option('include-operational') ? [] : self::OPERATIONAL)]; $tables = collect(Schema::connection($source->getName())->getTables()) ->map(fn (array $table): string => (string) ($table['name'] ?? '')) ->filter(fn (string $table): bool => $table !== '' && ! in_array($table, $excluded, true))->values()->all(); $pending = array_fill_keys($tables, true); $ordered = []; while ($pending !== []) { $progress = false; foreach (array_keys($pending) as $table) { $dependencies = collect(Schema::connection($source->getName())->getForeignKeys($table)) ->map(fn (array $key): string => (string) ($key['foreign_table'] ?? '')) ->filter(fn (string $dependency): bool => isset($pending[$dependency]) && $dependency !== $table); if ($dependencies->isNotEmpty()) { continue; } $ordered[] = $table; unset($pending[$table]); $progress = true; } if (! $progress) { throw new RuntimeException('Unresolved foreign-key dependencies: '.implode(', ', array_keys($pending))); } } return $ordered; } /** @param list $tables */ private function assertTargetSchema(Connection $target, array $tables): void { $missing = collect($tables)->reject(fn (string $table): bool => Schema::connection($target->getName())->hasTable($table)); if ($missing->isNotEmpty()) { throw new RuntimeException('Run target migrations first. Missing tables: '.$missing->implode(', ')); } } /** @param list $tables */ private function reportPlan(Connection $source, Connection $target, array $tables): void { $this->table(['Table', 'SQLite', 'MariaDB'], collect($tables)->map(fn (string $table): array => [$table, $source->table($table)->count(), $target->table($table)->count()])->all()); if (! $this->option('include-operational')) { $this->warn('Operational tables are excluded; database sessions will require users to sign in again.'); } } /** @param list $tables */ private function assertEmptyTarget(Connection $target, array $tables): void { $source = DB::connection('sqlite_legacy'); foreach ($tables as $table) { if (! $target->table($table)->exists()) { continue; } if ($source->table($table)->count() === $target->table($table)->count() && hash_equals($this->tableChecksum($source, $table), $this->tableChecksum($target, $table))) { $this->alreadyPresent[$table] = true; continue; } $primary = collect(Schema::connection($source->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false)); $primaryColumns = (array) ($primary['columns'] ?? []); if ($this->option('replace-migrated-seeds') && $primaryColumns !== [] && $source->table($table)->count() === $target->table($table)->count() && hash_equals($this->keyChecksum($source, $table, $primaryColumns), $this->keyChecksum($target, $table, $primaryColumns))) { $this->replaceSeeds[$table] = $primaryColumns; continue; } throw new RuntimeException("Target table {$table} is non-empty and does not exactly match the source."); } } /** @param list $tables */ private function copy(Connection $source, Connection $target, array $tables): void { $chunk = max(1, min(5000, (int) $this->option('chunk'))); foreach ($tables as $table) { if (isset($this->alreadyPresent[$table])) { $this->line("Kept identical migrated seed data in {$table}"); continue; } $columns = collect(Schema::connection($source->getName())->getColumns($table))->pluck('name')->all(); $primary = collect(Schema::connection($source->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false)); $order = ((array) ($primary['columns'] ?? []))[0] ?? $columns[0] ?? null; if ($order === null) { continue; } $target->transaction(function () use ($source, $target, $table, $order, $chunk): void { $source->table($table)->orderBy($order)->chunk($chunk, function ($rows) use ($target, $table): void { $data = $rows->map(fn (object $row): array => (array) $row)->all(); if ($data !== []) { if (isset($this->replaceSeeds[$table])) { $target->table($table)->upsert($data, $this->replaceSeeds[$table]); } else { $target->table($table)->insert($data); } } }); }); $this->line("Copied {$table}"); } } /** @param list $tables */ private function verify(Connection $source, Connection $target, array $tables): void { foreach ($tables as $table) { if ($source->table($table)->count() !== $target->table($table)->count()) { throw new RuntimeException("Row-count mismatch for {$table}."); } $columns = collect(Schema::connection($source->getName())->getColumns($table))->pluck('name'); foreach ($columns->filter(fn (string $column): bool => preg_match('/(^|_)(password|token|ulid|uuid)(_hash)?$/', $column) === 1) as $column) { if (! hash_equals($this->checksum($source, $table, $column), $this->checksum($target, $table, $column))) { throw new RuntimeException("Sensitive checksum mismatch for {$table}.{$column}."); } } } foreach ($tables as $table) { foreach (Schema::connection($target->getName())->getForeignKeys($table) as $key) { $local = (array) ($key['columns'] ?? []); $foreign = (array) ($key['foreign_columns'] ?? []); $parent = (string) ($key['foreign_table'] ?? ''); if (count($local) !== 1 || count($foreign) !== 1 || $parent === '') { continue; } $orphans = $target->table("{$table} as child") ->leftJoin("{$parent} as parent", "child.{$local[0]}", '=', "parent.{$foreign[0]}") ->whereNotNull("child.{$local[0]}")->whereNull("parent.{$foreign[0]}")->count(); if ($orphans > 0) { throw new RuntimeException("Foreign-key orphans found in {$table}.{$local[0]}."); } } } } private function checksum(Connection $connection, string $table, string $column): string { $hash = hash_init('sha256'); $connection->table($table)->orderBy($column)->select($column)->chunk(1000, function ($rows) use ($hash, $column): void { foreach ($rows as $row) { $value = (string) $row->{$column}; hash_update($hash, strlen($value).':'.$value."\n"); } }); return hash_final($hash); } private function tableChecksum(Connection $connection, string $table): string { $columns = collect(Schema::connection($connection->getName())->getColumns($table))->pluck('name')->all(); $primary = collect(Schema::connection($connection->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false)); $order = (array) ($primary['columns'] ?? $columns); $hash = hash_init('sha256'); $query = $connection->table($table)->select($columns); foreach ($order as $column) { $query->orderBy($column); } $query->chunk(1000, function ($rows) use ($hash, $columns): void { foreach ($rows as $row) { foreach ($columns as $column) { $value = $row->{$column}; hash_update($hash, $value === null ? '-1:' : strlen((string) $value).':'.(string) $value); } hash_update($hash, "\n"); } }); return hash_final($hash); } /** @param list $columns */ private function keyChecksum(Connection $connection, string $table, array $columns): string { $hash = hash_init('sha256'); $query = $connection->table($table)->select($columns); foreach ($columns as $column) { $query->orderBy($column); } foreach ($query->get() as $row) { foreach ($columns as $column) { hash_update($hash, strlen((string) $row->{$column}).':'.(string) $row->{$column}); } hash_update($hash, "\n"); } return hash_final($hash); } }