PM_Console/backend/app/Console/Commands/TransferSqliteToMysql.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);
}
}