database = $database; $this->path = rtrim($path, '/'); } public function ensureTable(): void { $this->database->execute( 'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)' ); $this->database->execute( 'DELETE FROM migrations WHERE id NOT IN ( SELECT MIN(id) FROM migrations GROUP BY migration )' ); $this->database->execute( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)' ); $files = array_map('basename', $this->migrationFiles()); if ($files === []) { $this->database->execute('DELETE FROM migrations'); return; } $placeholders = []; $parameters = []; foreach ($files as $index => $file) { $placeholder = 'migration_' . $index; $placeholders[] = ':' . $placeholder; $parameters[$placeholder] = $file; } $this->database->execute( 'DELETE FROM migrations WHERE migration NOT IN (' . implode(', ', $placeholders) . ')', $parameters ); } public function status(): array { $this->ensureTable(); $ran = $this->database->query('SELECT migration, ran_at FROM migrations ORDER BY id ASC'); $ranByName = []; foreach ($ran as $row) { $ranByName[$row['migration']] = $row['ran_at']; } $files = $this->migrationFiles(); $status = []; foreach ($files as $file) { $name = basename($file); $status[] = [ 'migration' => $name, 'ran' => array_key_exists($name, $ranByName), 'ran_at' => $ranByName[$name] ?? null, ]; } return $status; } public function runPending(): array { $this->ensureTable(); $ran = $this->database->query('SELECT migration FROM migrations'); $ranNames = array_column($ran, 'migration'); $files = $this->migrationFiles(); $ranMigrations = []; foreach ($files as $file) { $name = basename($file); if (in_array($name, $ranNames, true)) { continue; } $migration = $this->loadMigration($file); $this->database->pdo()->beginTransaction(); try { $migration->up($this->database); $this->database->execute( 'INSERT OR IGNORE INTO migrations (migration) VALUES (:migration)', ['migration' => $name] ); $this->database->pdo()->commit(); $ranMigrations[] = $name; } catch (\Throwable $exception) { $this->database->pdo()->rollBack(); throw $exception; } } return $ranMigrations; } public function rollback(int $steps = 1): array { $this->ensureTable(); $steps = max(1, $steps); $applied = $this->database->query( "SELECT MAX(id) AS id, migration FROM migrations GROUP BY migration ORDER BY id DESC LIMIT {$steps}" ); $rolledBack = []; foreach ($applied as $row) { $file = $this->path . '/' . $row['migration']; if (!file_exists($file)) { throw new \RuntimeException("Migration file not found for rollback: {$row['migration']}"); } $migration = $this->loadMigration($file); $this->database->pdo()->beginTransaction(); try { $migration->down($this->database); $this->database->execute( 'DELETE FROM migrations WHERE id = :id', ['id' => $row['id']] ); $this->database->pdo()->commit(); $rolledBack[] = $row['migration']; } catch (\Throwable $exception) { $this->database->pdo()->rollBack(); throw $exception; } } return $rolledBack; } public function fresh(): array { $this->ensureTable(); $files = array_reverse($this->migrationFiles()); $rolledBack = []; foreach ($files as $file) { $migration = $this->loadMigration($file); $name = basename($file); $this->database->pdo()->beginTransaction(); try { $migration->down($this->database); $this->database->pdo()->commit(); $rolledBack[] = $name; } catch (\Throwable $exception) { $this->database->pdo()->rollBack(); throw $exception; } } $this->database->execute('DELETE FROM migrations'); $ran = $this->runPending(); return [ 'rolled_back' => $rolledBack, 'migrated' => $ran, ]; } public function make(string $name): string { $slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_'); if ($slug === '') { throw new \InvalidArgumentException('Migration name must contain letters or numbers.'); } if (!is_dir($this->path)) { mkdir($this->path, 0777, true); } $timestamp = date('Ymd_His'); $filename = $timestamp . '_' . $slug . '.php'; $path = $this->path . '/' . $filename; if (file_exists($path)) { throw new \RuntimeException("Migration already exists: {$filename}"); } $template = <<path . '/*.php') ?: []; sort($files); return $files; } private function loadMigration(string $file): Migration { $migration = require $file; if ($migration instanceof Migration) { return $migration; } if (is_callable($migration)) { return new class ($migration, basename($file)) extends Migration { private $callback; private string $name; public function __construct(callable $callback, string $name) { $this->callback = $callback; $this->name = $name; } public function up(Database $database): void { ($this->callback)($database); } public function down(Database $database): void { throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method."); } }; } throw new \RuntimeException('Migration files must return a Migration instance.'); } }