#!/usr/bin/env php setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // Create migrations tracking table $pdo->exec(" CREATE TABLE IF NOT EXISTS _migrations ( name TEXT PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP ) "); $applied = $pdo->query("SELECT name FROM _migrations")->fetchAll(PDO::FETCH_COLUMN); $pendingDir = $root . '/migrations/pending'; $appliedDir = $root . '/migrations/applied'; if (!is_dir($appliedDir)) { mkdir($appliedDir, 0755, true); } // Collect .sql files from both pending and applied dirs $files = []; foreach ([$pendingDir, $appliedDir] as $dir) { if (!is_dir($dir)) continue; foreach (glob($dir . '/*.sql') as $f) { $files[basename($f)] = $f; } } ksort($files); if (empty($files)) { echo "No pending migration files.\n"; exit(0); } $count = 0; foreach ($files as $name => $file) { if (in_array($name, $applied, true)) { echo "Skip (already applied): $name\n"; continue; } echo "Applying: $name\n"; $sql = file_get_contents($file); // Split into individual statements to handle partial failures gracefully // (e.g. ALTER TABLE may fail with "duplicate column" but DROP VIEW must still run) $statements = array_filter( array_map('trim', explode(';', $sql)), fn($s) => $s !== '' ); $errors = []; foreach ($statements as $stmt) { try { $pdo->exec($stmt . ';'); } catch (PDOException $e) { $msg = $e->getMessage(); if (stripos($msg, 'duplicate column name') !== false) { echo " Skipping (column exists): " . substr($stmt, 0, 60) . "...\n"; continue; } $errors[] = $msg; } } if (empty($errors)) { $pdo->prepare("INSERT OR REPLACE INTO _migrations (name) VALUES (?)")->execute([$name]); if (str_starts_with($file, $pendingDir)) { rename($file, $appliedDir . '/' . $name); } $count++; } else { echo " FAILED: " . implode(' | ', $errors) . "\n"; throw new PDOException(implode(' | ', $errors)); } } echo "$count migration(s) applied.\n";