exec(<<query('SELECT migration FROM migrations'); $rows = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []; return array_fill_keys($rows, true); } function getMigrationFiles(): array { $sqlFiles = glob(__DIR__ . '/db/migrations/*.sql') ?: []; $phpFiles = glob(__DIR__ . '/db/migrations/*.php') ?: []; $files = array_merge($sqlFiles, $phpFiles); usort($files, static function (string $left, string $right): int { return strnatcasecmp(basename($left), basename($right)); }); return $files; } function splitSqlStatements(string $sql): array { $sql = preg_replace('/^\xEF\xBB\xBF/', '', $sql) ?? $sql; $sql = preg_replace('/\/\*.*?\*\//s', '', $sql) ?? $sql; $lines = preg_split('/\R/', $sql) ?: []; $filteredLines = []; foreach ($lines as $line) { $trimmed = ltrim($line); if ($trimmed === '' || str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) { continue; } $filteredLines[] = $line; } $cleanSql = implode("\n", $filteredLines); $statements = []; $buffer = ''; $inSingleQuote = false; $inDoubleQuote = false; $length = strlen($cleanSql); for ($index = 0; $index < $length; $index++) { $char = $cleanSql[$index]; $previous = $index > 0 ? $cleanSql[$index - 1] : ''; if ($char === "'" && !$inDoubleQuote && $previous !== '\\') { $inSingleQuote = !$inSingleQuote; } elseif ($char === '"' && !$inSingleQuote && $previous !== '\\') { $inDoubleQuote = !$inDoubleQuote; } if ($char === ';' && !$inSingleQuote && !$inDoubleQuote) { $statement = trim($buffer); if ($statement !== '') { $statements[] = $statement; } $buffer = ''; continue; } $buffer .= $char; } $tail = trim($buffer); if ($tail !== '') { $statements[] = $tail; } return $statements; } function isIgnorableMigrationError(PDOException $exception): bool { $driverCode = isset($exception->errorInfo[1]) ? (int) $exception->errorInfo[1] : null; $message = strtolower($exception->getMessage()); $ignorableCodes = [1050, 1060, 1061, 1062, 1091, 1826]; $ignorableSnippets = [ 'already exists', 'duplicate column name', 'duplicate key name', 'duplicate entry', 'duplicate foreign key constraint name', 'check that column/key exists', ]; if ($driverCode !== null && in_array($driverCode, $ignorableCodes, true)) { return true; } foreach ($ignorableSnippets as $snippet) { if (str_contains($message, $snippet)) { return true; } } return false; } function executeSqlMigration(PDO $pdo, string $filePath): void { $sql = file_get_contents($filePath); if ($sql === false) { throw new RuntimeException('Unable to read SQL migration: ' . basename($filePath)); } $statements = splitSqlStatements($sql); if ($statements === []) { migrationOutput(' - no executable SQL statements found'); return; } foreach ($statements as $number => $statement) { try { $pdo->exec($statement); } catch (PDOException $exception) { if (isIgnorableMigrationError($exception)) { migrationOutput(' - skipped statement ' . ($number + 1) . ': ' . $exception->getMessage()); continue; } throw new RuntimeException( 'SQL migration failed in ' . basename($filePath) . ' at statement ' . ($number + 1) . ': ' . $exception->getMessage(), 0, $exception ); } } } function executePhpMigration(string $filePath): void { ob_start(); try { $result = include $filePath; } catch (Throwable $throwable) { ob_end_clean(); throw new RuntimeException('PHP migration failed in ' . basename($filePath) . ': ' . $throwable->getMessage(), 0, $throwable); } $output = trim((string) ob_get_clean()); if ($result === false) { throw new RuntimeException('PHP migration returned false: ' . basename($filePath)); } if ($output !== '') { foreach (preg_split('/\R/', $output) ?: [] as $line) { migrationOutput(' ' . $line); } } } function recordMigration(PDO $pdo, string $migrationName): void { $stmt = $pdo->prepare('INSERT INTO migrations (migration) VALUES (:migration)'); $stmt->execute(['migration' => $migrationName]); } function runAllMigrations(): int { if (!canRunMigrations()) { if (PHP_SAPI !== 'cli') { http_response_code(403); } migrationOutput('Forbidden: run migrate.php from CLI, localhost, or while logged in as an Administrator.'); return 1; } try { $pdo = db(); ensureMigrationsTable($pdo); $executed = getExecutedMigrations($pdo); $files = getMigrationFiles(); if ($files === []) { migrationOutput('No migration files found in db/migrations.'); return 0; } $applied = 0; $skipped = 0; migrationOutput('Starting migration run...'); foreach ($files as $filePath) { $migrationName = basename($filePath); if (isset($executed[$migrationName])) { migrationOutput('SKIP ' . $migrationName); $skipped++; continue; } migrationOutput('RUN ' . $migrationName); $extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION)); if ($extension === 'sql') { executeSqlMigration($pdo, $filePath); } elseif ($extension === 'php') { executePhpMigration($filePath); } else { throw new RuntimeException('Unsupported migration type: ' . $migrationName); } recordMigration($pdo, $migrationName); $executed[$migrationName] = true; $applied++; migrationOutput('OK ' . $migrationName); } migrationOutput('Done. Applied ' . $applied . ' migration(s), skipped ' . $skipped . ' already executed.'); return 0; } catch (Throwable $throwable) { if (PHP_SAPI !== 'cli') { http_response_code(500); } migrationOutput('ERROR: ' . $throwable->getMessage()); return 1; } } exit(runAllMigrations());