#!/usr/bin/env php getMessage() . "\n"; exit(1); } // Create migrations tracking table if it doesn't exist $createTable = "CREATE TABLE IF NOT EXISTS migrations ( id INT AUTO_INCREMENT PRIMARY KEY, filename VARCHAR(255) NOT NULL UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_filename (filename) )"; if (!$conn->query($createTable)) { echo "Error: Could not create migrations table: " . $conn->error . "\n"; exit(1); } // Tracks per-statement progress within a migration file. MySQL DDL statements // (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/ // rollback() around a whole file can't actually undo DDL already executed // earlier in that same file. This table lets a re-run after a partial failure // resume from the statement after the last one that succeeded, instead of // re-executing already-applied DDL and wedging on "already exists" errors. $createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress ( filename VARCHAR(255) NOT NULL PRIMARY KEY, last_statement_index INT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP )"; if (!$conn->query($createProgressTable)) { echo "Error: Could not create migration_progress table: " . $conn->error . "\n"; exit(1); } // Get list of completed migrations $completed = []; $result = $conn->query("SELECT filename FROM migrations ORDER BY id"); while ($row = $result->fetch_assoc()) { $completed[] = $row['filename']; } // Get list of migration files $migrationsDir = __DIR__; $files = glob($migrationsDir . '/*.sql'); sort($files); if (empty($files)) { echo "No migration files found.\n"; exit(0); } if ($statusOnly) { echo "Migration Status:\n"; echo str_repeat('-', 60) . "\n"; foreach ($files as $file) { $filename = basename($file); $status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]'; echo sprintf(" %s %s\n", $status, $filename); } exit(0); } // Find pending migrations $pending = []; foreach ($files as $file) { $filename = basename($file); if (!in_array($filename, $completed)) { $pending[] = $file; } } if (empty($pending)) { echo "All migrations are up to date.\n"; exit(0); } echo sprintf("Found %d pending migration(s):\n", count($pending)); foreach ($pending as $file) { echo " - " . basename($file) . "\n"; } echo "\n"; if ($dryRun) { echo "[DRY RUN] No changes made.\n"; exit(0); } // Run pending migrations $success = 0; $failed = 0; foreach ($pending as $file) { $filename = basename($file); echo "Running: $filename... "; $sql = file_get_contents($file); if ($sql === false) { echo "FAILED (could not read file)\n"; $failed++; continue; } // Execute migration statement-by-statement, tracking progress as we go. // No begin_transaction()/rollback() here: DDL statements auto-commit in // MySQL/MariaDB regardless, so a transaction wrapper around the whole // file would only create the illusion of atomicity while giving no real // protection. Instead, each statement commits immediately (autocommit), // and its index is durably recorded so a later re-run can resume exactly // where a previous run left off rather than re-executing already-applied // DDL. try { // Split by semicolon but respect statements properly // Note: This doesn't handle semicolons in strings, but our migrations are simple $statements = array_values(array_filter( array_map('trim', explode(';', $sql)), function($stmt) { // Remove comments and check if there's actual SQL $cleaned = preg_replace('/--.*$/m', '', $stmt); return !empty(trim($cleaned)); } )); $resumeFrom = 0; $progressStmt = $conn->prepare( "SELECT last_statement_index FROM migration_progress WHERE filename = ?" ); $progressStmt->bind_param('s', $filename); $progressStmt->execute(); $progressRow = $progressStmt->get_result()->fetch_assoc(); $progressStmt->close(); if ($progressRow) { $resumeFrom = (int)$progressRow['last_statement_index'] + 1; echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements) . " after a previous partial failure... "; } foreach ($statements as $index => $statement) { if ($index < $resumeFrom) { continue; } if (!$conn->query($statement)) { // Some "errors" are acceptable (like "index already exists") $error = $conn->error; if (strpos($error, 'Duplicate key name') !== false || strpos($error, 'already exists') !== false) { // Index already exists, that's fine } else { throw new Exception($error); } } // Record progress after every statement so a later run can // resume from here even if a subsequent statement fails. $upsert = $conn->prepare( "INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?) ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)" ); $upsert->bind_param('si', $filename, $index); $upsert->execute(); $upsert->close(); } // Record the migration as fully complete and clear its progress marker $stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)"); $stmt->bind_param('s', $filename); if (!$stmt->execute()) { throw new Exception("Could not record migration: " . $conn->error); } $clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?"); $clearProgress->bind_param('s', $filename); $clearProgress->execute(); $clearProgress->close(); echo "OK\n"; $success++; } catch (Exception $e) { // Nothing to roll back: every statement up to the failure already // committed (DDL implicitly, everything else via autocommit). The // progress marker recorded above reflects exactly how far this file // got, so the next run will resume right after the last success. echo "FAILED (" . $e->getMessage() . ")\n"; $failed++; } } echo "\n"; echo "=== Migration Complete ===\n"; echo sprintf(" Success: %d\n", $success); echo sprintf(" Failed: %d\n", $failed); exit($failed > 0 ? 1 : 0);