Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,14 @@ TYPESENSE_API_KEY=xyz

NIGHTWATCH_TOKEN=
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1

# SSH Tunnel Configuration for Database Migration
SSH_TUNNEL_USER=
SSH_TUNNEL_HOSTNAME=
SSH_TUNNEL_PORT=22
SSH_TUNNEL_LOCAL_PORT=3307
SSH_TUNNEL_IDENTITY_FILE=
SSH_TUNNEL_PRIVATE_KEY="-----BEGIN OPENSSH PRIVATE KEY-----
oi!jkdiososbXCbnNzaC1rZXktdjEABG5vbmUAAAAEbm9uZQAAAAAAAAFwAAAAdzc2gtcn
... (votre clé SSH privée complète ici) ...
-----END OPENSSH PRIVATE KEY-----"
10 changes: 9 additions & 1 deletion app-modules/database-migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,18 @@ Configure your environment variables in `.env`:
# SSH Tunnel Configuration
SSH_TUNNEL_USER=your-ssh-user
SSH_TUNNEL_HOSTNAME=your-server.com
SSH_TUNNEL_IDENTITY_FILE=/path/to/your/private/key
SSH_TUNNEL_LOCAL_PORT=3307
SSH_TUNNEL_BIND_PORT=3306
SSH_TUNNEL_BIND_ADDRESS=127.0.0.1

# Option 1: Utiliser un fichier de clé SSH (par défaut)
SSH_TUNNEL_IDENTITY_FILE=/path/to/your/private/key

# Option 2: Utiliser le contenu de la clé SSH via variable d'environnement (recommandé pour Docker)
SSH_TUNNEL_PRIVATE_KEY="-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAFwAAAAdzc2gtcn
... (votre clé SSH privée complète ici) ...
-----END OPENSSH PRIVATE KEY-----"
SSH_TUNNEL_AUTO_ACTIVATE=false
SSH_TUNNEL_LOGGING_ENABLED=true
SSH_TUNNEL_LOGGING_CHANNEL=default
Expand Down
1 change: 1 addition & 0 deletions app-modules/database-migration/config/ssh-tunnel.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
'hostname' => env('SSH_TUNNEL_HOSTNAME'),
'port' => env('SSH_TUNNEL_PORT', 22),
'identity_file' => env('SSH_TUNNEL_IDENTITY_FILE', '~/.ssh/id_rsa'),
'private_key_content' => env('SSH_TUNNEL_PRIVATE_KEY'),
'options' => env('SSH_TUNNEL_SSH_OPTIONS', '-o StrictHostKeyChecking=no'),
'verbosity' => env('SSH_TUNNEL_VERBOSITY', ''),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
final class MigrateDatabaseCommand extends Command
{
protected $signature = 'db:migrate-mysql-to-pgsql
{--tables=* : Specific tables to migrate (optional)}
{--tables= : Specific tables to migrate (optional)}
{--chunk=1000 : Number of records to process per chunk}
{--dry-run : Show what would be migrated without actually doing it}';

Expand All @@ -23,9 +23,9 @@ public function handle(
): int {
$this->info('🚀 Starting MySQL to PostgreSQL migration...');

// Ensure SSH tunnel is active
if (! $tunnelService->isActive()) {
$this->warn('SSH tunnel is not active. Attempting to activate...');

$tunnelService->activate();
}

Expand All @@ -46,8 +46,7 @@ public function handle(
}

try {
// Get tables to migrate
$tables = $specificTables ?: $migrationService->getSourceTables();
$tables = $specificTables ? explode(',', $specificTables) : $migrationService->getSourceTables();

if (blank($tables)) {
$this->error('❌ No tables found to migrate');
Expand All @@ -60,24 +59,34 @@ public function handle(
$progressBar = $this->output->createProgressBar(count($tables));
$progressBar->start();

foreach ($tables as $table) {
if ($table === null) {
continue;
}

$this->newLine();
$this->info("🔄 Migrating table: {$table}");
if (! $isDryRun) {
$migrationService->disableForeignKeyConstraints();
}

try {
foreach ($tables as $table) {
if (blank($table)) {
continue;
}

$this->newLine();
$this->info("🔄 Migrating table: {$table}");

if (! $isDryRun) {
$migrationService->migrateTable($table, $chunkSize, function ($processed, $total): void {
$this->line(" 📊 Processed {$processed}/{$total} records");
});
} else {
$count = $migrationService->getTableRecordCount($table);
$this->line(" 📊 Would migrate {$count} records");
}

$progressBar->advance();
}
} finally {
if (! $isDryRun) {
$migrationService->migrateTable($table, $chunkSize, function ($processed, $total): void {
$this->line(" 📊 Processed {$processed}/{$total} records");
});
} else {
$count = $migrationService->getTableRecordCount($table);
$this->line(" 📊 Would migrate {$count} records");
$migrationService->enableForeignKeyConstraints();
}

$progressBar->advance();
}

$progressBar->finish();
Expand All @@ -95,6 +104,14 @@ public function handle(
$this->error("❌ Migration failed: {$e->getMessage()}");

return Command::FAILURE;
} finally {
$this->info('🧹 Cleaning up SSH tunnel and temporary files...');

$tunnelService->destroy();
$this->info('✅ SSH tunnel destroyed');

$tunnelService->forceCleanupTempKeyFile();
$this->info('✅ Temporary SSH key file cleaned up');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ private function getExcludedTables(): array
return [
'migrations',
'password_resets',
'password_reset_tokens',
'personal_access_tokens',
'failed_jobs',
'jobs',
'job_batches',
'temporary_uploads',
];
}

Expand All @@ -61,45 +61,65 @@ public function getTableRecordCount(string $table): int
->count();
}

/**
* Migrate a single table from source to target
*/
public function migrateTable(string $table, int $chunkSize = 1000, ?callable $progressCallback = null): void
{
// First, ensure the table exists in target database
if (! Schema::connection($this->targetConnection)->hasTable($table)) {
throw new \Exception("Table '{$table}' does not exist in target database. Run migrations first.");
return;
}

// Clear existing data in target table
DB::connection($this->targetConnection)->table($table)->truncate();

$totalRecords = $this->getTableRecordCount($table);
$processedRecords = 0;

// Process data in chunks
DB::connection($this->sourceConnection)
->table($table)
->orderBy('id')
->chunk($chunkSize, function (Collection $records) use (
$table,
&$processedRecords,
$totalRecords,
$progressCallback
): void {
$data = $records->map(fn ($record): array => $this->transformRecord((array) $record))->toArray();

// Insert into target database
DB::connection($this->targetConnection)
->table($table)
->insert($data);

$processedRecords += count($records);

if ($progressCallback) {
$progressCallback($processedRecords, $totalRecords);
}
});
$query = DB::connection($this->sourceConnection)->table($table);

if ($this->hasIdColumn($table)) {
$query->orderBy('id');
} else {
$columns = Schema::connection($this->sourceConnection)->getColumnListing($table);

if (! empty($columns)) {
$query->orderBy($columns[0]);
}
}

$query->chunk($chunkSize, function (Collection $records) use (
$table,
&$processedRecords,
$totalRecords,
$progressCallback
): void {
$data = $records->map(fn ($record): array => $this->transformRecord((array) $record))->toArray();

DB::connection($this->targetConnection)
->table($table)
->insert($data);

$processedRecords += count($records);

if ($progressCallback) {
$progressCallback($processedRecords, $totalRecords);
}
});
}

public function disableForeignKeyConstraints(): void
{
DB::connection($this->targetConnection)
->statement('SET session_replication_role = replica;');
}

public function enableForeignKeyConstraints(): void
{
DB::connection($this->targetConnection)
->statement('SET session_replication_role = DEFAULT;');
}

private function hasIdColumn(string $table): bool
{
return Schema::connection($this->sourceConnection)
->hasColumn($table, 'id');
}

/**
Expand All @@ -112,15 +132,10 @@ private function transformRecord(array $record): array
{
foreach ($record as $key => $value) {
// Handle MySQL boolean fields (tinyint) to PostgreSQL boolean
if (is_int($value) && in_array($value, [0, 1]) && preg_match('/^(is_|has_|can_|should_|enabled|active|published|verified)/', $key)) {
if (is_int($value) && in_array($value, [0, 1]) && preg_match('/^(is_|has_|can_|should_|enabled|active|certified|public|featured|published|pinned|opt_in|sponsored|verified|locked)/', $key)) {
$record[$key] = (bool) $value;
}

// Handle empty strings that should be null in PostgreSQL
if ($value === '') {
$record[$key] = null;
}

// Handle MySQL timestamp '0000-00-00 00:00:00' to null
if ($value === '0000-00-00 00:00:00') {
$record[$key] = null;
Expand Down
Loading
Loading