|
- <?php
-
- declare(strict_types=1);
-
- namespace Core;
-
- class MigrationManager
- {
- protected Database $database;
- protected string $path;
-
- public function __construct(Database $database, string $path)
- {
- $this->database = $database;
- $this->path = rtrim($path, '/');
- }
-
- public function ensureTable(): void
- {
- $tableExists = $this->database->first(
- "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'migrations'"
- );
-
- if (!$tableExists) {
- $this->database->execute(
- 'CREATE TABLE migrations (
- id INT IDENTITY(1,1) NOT NULL,
- migration NVARCHAR(255) NOT NULL,
- ran_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT PK_migrations PRIMARY KEY (id),
- CONSTRAINT UQ_migrations_migration UNIQUE (migration)
- )'
- );
- }
-
- $this->database->execute(
- 'DELETE FROM migrations
- WHERE id NOT IN (
- SELECT MIN(id)
- FROM migrations
- GROUP BY 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 INTO migrations (migration)
- SELECT :m1 WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration = :m2)',
- ['m1' => $name, 'm2' => $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 TOP ({$steps}) MAX(id) AS id, migration
- FROM migrations
- GROUP BY migration
- ORDER BY MAX(id) DESC"
- );
- $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 = <<<PHP
- <?php
-
- declare(strict_types=1);
-
- use Core\Database;
- use Core\Migration;
-
- return new class extends Migration
- {
- public function up(Database \$database): void
- {
- // Write the forward migration here.
- }
-
- public function down(Database \$database): void
- {
- // Write the rollback migration here.
- }
- };
- PHP;
-
- file_put_contents($path, $template . PHP_EOL);
-
- return $path;
- }
-
- private function migrationFiles(): array
- {
- $files = glob($this->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.');
- }
- }
|