269 خطوط
8.9 KiB
PHP
269 خطوط
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Database\ConnectionInterface;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class TransferSqliteToMysql extends Command
|
|
{
|
|
protected $signature = 'db:transfer-sqlite-to-mysql
|
|
{--execute : Perform writes; without this flag the command is a dry run}
|
|
{--truncate : Empty transferable target tables before importing}
|
|
{--chunk=250 : Rows processed per batch}';
|
|
|
|
protected $description = 'Idempotently transfer application data from SQLite to a separately configured MySQL database';
|
|
|
|
private const EXCLUDED_TABLES = [
|
|
'migrations',
|
|
'cache',
|
|
'cache_locks',
|
|
'jobs',
|
|
'job_batches',
|
|
'failed_jobs',
|
|
'password_reset_tokens',
|
|
'sessions',
|
|
'personal_access_tokens',
|
|
];
|
|
|
|
private const NATURAL_KEYS = [
|
|
'role_user' => ['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);
|
|
}
|
|
}
|