From d75f802214f243c35492156e0e15f5740508cf42 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 2 Oct 2026 22:39:54 +0600 Subject: [PATCH] Migrations: rollback, reset, refresh, status, db:wipe, pretend, locking and transactional runs --- .../Commands/Migrations/DbWipeCommand.php | 62 +++ .../Commands/Migrations/MigrateCommand.php | 43 +- .../Migrations/MigrateFreshCommand.php | 101 +++++ .../Migrations/MigrateRefreshCommand.php | 60 +-- .../Migrations/MigrateResetCommand.php | 83 ++++ .../Migrations/MigrateRollbackCommand.php | 105 +++++ .../Migrations/MigrateStatusCommand.php | 131 ++++++ .../Support/InteractsWithMigrations.php | 166 +++++++ src/Phaseolies/Database/Database.php | 43 ++ .../Database/Migration/Migration.php | 5 + .../Migration/MigrationRepository.php | 207 ++++++++- .../Database/Migration/Migrator.php | 411 ++++++++++++++++-- tests/Builder/MigrateCommandsTest.php | 189 ++++++++ tests/Builder/MigratorPgsqlTest.php | 49 +++ tests/Builder/MigratorTest.php | 248 +++++++++++ tests/Builder/MigratorTestCase.php | 149 +++++++ 16 files changed, 1960 insertions(+), 92 deletions(-) create mode 100644 src/Phaseolies/Console/Commands/Migrations/DbWipeCommand.php create mode 100644 src/Phaseolies/Console/Commands/Migrations/MigrateFreshCommand.php create mode 100644 src/Phaseolies/Console/Commands/Migrations/MigrateResetCommand.php create mode 100644 src/Phaseolies/Console/Commands/Migrations/MigrateRollbackCommand.php create mode 100644 src/Phaseolies/Console/Commands/Migrations/MigrateStatusCommand.php create mode 100644 src/Phaseolies/Console/Support/InteractsWithMigrations.php create mode 100644 tests/Builder/MigrateCommandsTest.php create mode 100644 tests/Builder/MigratorPgsqlTest.php create mode 100644 tests/Builder/MigratorTest.php create mode 100644 tests/Builder/MigratorTestCase.php diff --git a/src/Phaseolies/Console/Commands/Migrations/DbWipeCommand.php b/src/Phaseolies/Console/Commands/Migrations/DbWipeCommand.php new file mode 100644 index 00000000..4e203d41 --- /dev/null +++ b/src/Phaseolies/Console/Commands/Migrations/DbWipeCommand.php @@ -0,0 +1,62 @@ +executeWithTiming(function () { + $connection = $this->resolveConnection(); + + if (!$this->confirmToProceed("This will drop all tables from database connection: {$connection}", true)) { + return Command::FAILURE; + } + + try { + $database = DB::connection($connection); + + $database->disableForeignKeyConstraints(); + + try { + $dropped = $database->dropAllTables(); + } finally { + $database->enableForeignKeyConstraints(); + } + } catch (\Throwable $e) { + $this->displayError("Failed to wipe database [{$connection}]: {$e->getMessage()}"); + + return Command::FAILURE; + } + + $this->displaySuccess("Dropped {$dropped} " . ($dropped === 1 ? 'table' : 'tables') . " from {$connection}"); + + return Command::SUCCESS; + }); + } +} diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateCommand.php index 7c9e243b..ee67f47e 100644 --- a/src/Phaseolies/Console/Commands/Migrations/MigrateCommand.php +++ b/src/Phaseolies/Console/Commands/Migrations/MigrateCommand.php @@ -3,16 +3,19 @@ namespace Phaseolies\Console\Commands\Migrations; use Phaseolies\Console\Schedule\Command; +use Phaseolies\Console\Support\InteractsWithMigrations; use Phaseolies\Database\Migration\Migrator; class MigrateCommand extends Command { + use InteractsWithMigrations; + /** * The name and signature of the console command. * * @var string */ - protected $name = 'migrate {--path=} {--connection=}'; + protected $name = 'migrate {--path=} {--connection=} {--step} {--pretend} {--force} {--seed}'; /** * The description of the console command. @@ -45,28 +48,38 @@ public function __construct() public function handle(): int { return $this->executeWithTiming(function () { - $connection = $this->option('connection') ?: config('database.default'); + $connection = $this->resolveConnection(); + $pretend = (bool) $this->option('pretend'); - $path = $this->option('path'); + if (!$this->confirmToProceed('Running migrations in production')) { + return Command::FAILURE; + } - $this->line("🔁 Running migrations on connection: {$connection}"); + $this->line("🔁 " . ($pretend ? 'Previewing' : 'Running') . " migrations on connection: {$connection}"); $this->newLine(); - if ($path) { - $migrations = $this->migrator->run($connection, $path); - } else { - $migrations = $this->migrator->run($connection); + try { + $migrations = $this->migrator->run($connection, $this->option('path') ?: null, [ + 'step' => (bool) $this->option('step'), + 'pretend' => $pretend, + 'progress' => $this->progressReporter(), + ]); + } catch (\Throwable $e) { + $this->newLine(); + $this->displayError($e->getMessage()); + + return Command::FAILURE; } if (empty($migrations)) { - $this->newLine(); $this->displayInfo('Nothing to migrate'); - } else { - $this->displaySuccess('Database migrated successfully'); - $this->line('📊 Migrations Executed:'); - foreach ($migrations as $migration) { - $this->line('- ' . $migration . ''); - } + } elseif (!$pretend) { + $this->newLine(); + $this->displaySuccess(count($migrations) . ' ' . (count($migrations) === 1 ? 'migration' : 'migrations') . ' executed'); + } + + if ($this->option('seed') && !$pretend) { + return $this->runSeeders(); } return Command::SUCCESS; diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateFreshCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateFreshCommand.php new file mode 100644 index 00000000..539b4ea9 --- /dev/null +++ b/src/Phaseolies/Console/Commands/Migrations/MigrateFreshCommand.php @@ -0,0 +1,101 @@ +migrator = app('migrator'); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle(): int + { + return $this->executeWithTiming(function () { + $connection = $this->resolveConnection(); + + if (!$this->confirmToProceed("This will drop all tables from database connection: {$connection}", true)) { + return Command::FAILURE; + } + + $this->line("♻️ Refreshing database on connection: {$connection}"); + + try { + $database = DB::connection($connection); + + $database->disableForeignKeyConstraints(); + + try { + $tablesDropped = $database->dropAllTables(); + } finally { + $database->enableForeignKeyConstraints(); + } + + $this->newLine(); + $this->line("✔ Dropped {$tablesDropped} tables from {$connection}"); + } catch (\Throwable $e) { + $this->displayError("Failed to refresh database [{$connection}]: {$e->getMessage()}"); + return Command::FAILURE; + } + + $this->newLine(); + $this->line('🔁 Running migrations'); + $this->newLine(); + + try { + $migrations = $this->migrator->run($connection, null, ['progress' => $this->progressReporter()]); + } catch (\Throwable $e) { + $this->newLine(); + $this->displayError($e->getMessage()); + return Command::FAILURE; + } + + $this->newLine(); + $this->displaySuccess('Database refresh completed (' . count($migrations) . ' executed)'); + + if ($this->option('seed')) { + return $this->runSeeders(); + } + + return Command::SUCCESS; + }); + } +} diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php index c8080b76..a8c8f402 100644 --- a/src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php +++ b/src/Phaseolies/Console/Commands/Migrations/MigrateRefreshCommand.php @@ -3,25 +3,26 @@ namespace Phaseolies\Console\Commands\Migrations; use Phaseolies\Console\Schedule\Command; -use Phaseolies\Support\Facades\Schema; -use Phaseolies\Support\Facades\DB; +use Phaseolies\Console\Support\InteractsWithMigrations; use Phaseolies\Database\Migration\Migrator; class MigrateRefreshCommand extends Command { + use InteractsWithMigrations; + /** * The name and signature of the console command. * * @var string */ - protected $name = 'migrate:fresh {--connection=}'; + protected $name = 'migrate:refresh {--connection=} {--step=} {--seed} {--force}'; /** * The description of the console command. * * @var string */ - protected $description = 'Drop all tables and re-run all migrations for the specified or default connection'; + protected $description = 'Roll back all migrations (or the last --step=N) and run them again'; /** * The migrator instance. @@ -36,7 +37,6 @@ class MigrateRefreshCommand extends Command public function __construct() { parent::__construct(); - $this->migrator = app('migrator'); } @@ -48,43 +48,49 @@ public function __construct() public function handle(): int { return $this->executeWithTiming(function () { - $connection = $this->option('connection') ?: config('database.default'); + $connection = $this->resolveConnection(); - $this->displayWarning("This will drop all tables from database connection: {$connection}"); - $this->line('Are you sure you want to proceed? (yes/no) [no]'); - $response = trim(fgets(STDIN)); + try { + $step = $this->integerOption('step'); + } catch (\RuntimeException $e) { + $this->displayError($e->getMessage()); - if (strtolower($response) !== 'yes') { - $this->displayInfo('Command cancelled'); - return Command::SUCCESS; + return Command::FAILURE; } - $this->newLine(); - $this->line("♻️ Refreshing database on connection: {$connection}"); + $scope = $step === null ? 'every migration' : "the last {$step} migration(s)"; - try { - Schema::connection($connection)->disableForeignKeyConstraints(); + if (!$this->confirmToProceed("This will roll back {$scope} on connection: {$connection} and run them again", true)) { + return Command::FAILURE; + } - $tablesDropped = DB::connection($connection)->dropAllTables(); + $progress = $this->progressReporter(); - Schema::connection($connection)->enableForeignKeyConstraints(); + try { + $this->line("⏪ Rolling back on connection: {$connection}"); + $this->newLine(); + $rolledBack = $step === null + ? $this->migrator->reset($connection, ['progress' => $progress]) + : $this->migrator->rollback($connection, ['step' => $step, 'progress' => $progress]); + + $this->newLine(); + $this->line('🔁 Running migrations'); $this->newLine(); - $this->line("✔ Dropped {$tablesDropped} tables from {$connection}"); + + $migrated = $this->migrator->run($connection, null, ['progress' => $progress]); } catch (\Throwable $e) { - $this->displayError("Failed to refresh database [{$connection}]: {$e->getMessage()}"); + $this->newLine(); + $this->displayError($e->getMessage()); + return Command::FAILURE; } $this->newLine(); - $this->line('🔁 Running migrations'); - $this->newLine(); - $migrations = $this->migrator->run($connection); + $this->displaySuccess(sprintf('Refreshed (%d rolled back, %d executed)', count($rolledBack), count($migrated))); - $this->displaySuccess('Database refresh completed'); - $this->line('📊 Migrations Executed:'); - foreach ($migrations as $migration) { - $this->line('- ' . $migration . ''); + if ($this->option('seed')) { + return $this->runSeeders(); } return Command::SUCCESS; diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateResetCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateResetCommand.php new file mode 100644 index 00000000..01f7f47a --- /dev/null +++ b/src/Phaseolies/Console/Commands/Migrations/MigrateResetCommand.php @@ -0,0 +1,83 @@ +migrator = app('migrator'); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle(): int + { + return $this->executeWithTiming(function () { + $connection = $this->resolveConnection(); + $pretend = (bool) $this->option('pretend'); + + if (!$this->confirmToProceed("This will roll back every migration on connection: {$connection}", true)) { + return Command::FAILURE; + } + + $this->line("⏪ " . ($pretend ? 'Previewing reset' : 'Resetting migrations') . " on connection: {$connection}"); + $this->newLine(); + + try { + $rolledBack = $this->migrator->reset($connection, [ + 'pretend' => $pretend, + 'progress' => $this->progressReporter(), + ]); + } catch (\Throwable $e) { + $this->newLine(); + $this->displayError($e->getMessage()); + + return Command::FAILURE; + } + + if (empty($rolledBack)) { + $this->displayInfo('Nothing to roll back'); + } elseif (!$pretend) { + $this->newLine(); + $this->displaySuccess(count($rolledBack) . ' ' . (count($rolledBack) === 1 ? 'migration' : 'migrations') . ' rolled back'); + } + + return Command::SUCCESS; + }); + } +} diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateRollbackCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateRollbackCommand.php new file mode 100644 index 00000000..155f8c6e --- /dev/null +++ b/src/Phaseolies/Console/Commands/Migrations/MigrateRollbackCommand.php @@ -0,0 +1,105 @@ +migrator = app('migrator'); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle(): int + { + return $this->executeWithTiming(function () { + $connection = $this->resolveConnection(); + $pretend = (bool) $this->option('pretend'); + + try { + $step = $this->integerOption('step'); + $batch = $this->integerOption('batch'); + } catch (\RuntimeException $e) { + $this->displayError($e->getMessage()); + + return Command::FAILURE; + } + + if ($step !== null && $batch !== null) { + $this->displayError('Use either --step or --batch, not both.'); + + return Command::FAILURE; + } + + if (!$this->confirmToProceed('Rolling back migrations in production')) { + return Command::FAILURE; + } + + $this->line("⏪ " . ($pretend ? 'Previewing rollback' : 'Rolling back migrations') . " on connection: {$connection}"); + $this->newLine(); + + try { + $options = ['pretend' => $pretend, 'progress' => $this->progressReporter()]; + + if ($step !== null) { + $options['step'] = $step; + } + + if ($batch !== null) { + $options['batch'] = $batch; + } + + $rolledBack = $this->migrator->rollback($connection, $options); + } catch (\Throwable $e) { + $this->newLine(); + $this->displayError($e->getMessage()); + + return Command::FAILURE; + } + + if (empty($rolledBack)) { + $this->displayInfo('Nothing to roll back'); + } elseif (!$pretend) { + $this->newLine(); + $this->displaySuccess(count($rolledBack) . ' ' . (count($rolledBack) === 1 ? 'migration' : 'migrations') . ' rolled back'); + } + + return Command::SUCCESS; + }); + } +} diff --git a/src/Phaseolies/Console/Commands/Migrations/MigrateStatusCommand.php b/src/Phaseolies/Console/Commands/Migrations/MigrateStatusCommand.php new file mode 100644 index 00000000..d90c4dd9 --- /dev/null +++ b/src/Phaseolies/Console/Commands/Migrations/MigrateStatusCommand.php @@ -0,0 +1,131 @@ +migrator = app('migrator'); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle(): int + { + $connection = $this->resolveConnection(); + + try { + $rows = $this->migrator->status($connection); + } catch (\Throwable $e) { + $this->displayError($e->getMessage()); + + return Command::FAILURE; + } + + $pending = array_values(array_filter($rows, fn($row) => !$row['ran'])); + $problems = array_values(array_filter($rows, fn($row) => $row['modified'] || $row['missing'])); + + // Both flags are meant for CI and deploy scripts, so the exit code carries the answer. + $exit = $this->option('pending') && $pending !== [] ? Command::FAILURE : Command::SUCCESS; + + if ($this->option('json')) { + $this->output->writeln(json_encode([ + 'connection' => $connection, + 'migrations' => $rows, + 'pending' => count($pending), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $exit; + } + + $this->newLine(); + $this->line("📊 Migration status on connection: {$connection}"); + $this->newLine(); + + if ($rows === []) { + $this->displayInfo('No migrations found'); + + return $exit; + } + + $table = $this->createTable(); + $table->setHeaders(['Migration', 'Status', 'Batch', 'Ran at', 'Time']); + + foreach ($this->option('pending') ? $pending : $rows as $row) { + $table->addRow([ + preg_replace('/\.php$/', '', $row['migration']), + $this->statusLabel($row), + $row['batch'] ?? '-', + $row['ran_at'] ?? '-', + $row['execution_time'] === null ? '-' : $row['execution_time'] . 'ms', + ]); + } + + $table->render(); + $this->newLine(); + + $this->line(sprintf( + ' %d ran, %d pending%s', + count($rows) - count($pending), + count($pending), + $problems === [] ? '' : sprintf(', %d need attention', count($problems)) + )); + + if ($problems !== []) { + $this->line(' Modified = the file changed after it ran. Missing = it ran, but the file is gone.'); + } + + $this->newLine(); + + return $exit; + } + + /** + * @param array $row + * @return string + */ + private function statusLabel(array $row): string + { + return match (true) { + $row['missing'] => 'Ran (file missing)', + $row['modified'] => 'Ran (modified)', + $row['ran'] => 'Ran', + default => 'Pending', + }; + } +} diff --git a/src/Phaseolies/Console/Support/InteractsWithMigrations.php b/src/Phaseolies/Console/Support/InteractsWithMigrations.php new file mode 100644 index 00000000..d10c7b87 --- /dev/null +++ b/src/Phaseolies/Console/Support/InteractsWithMigrations.php @@ -0,0 +1,166 @@ +option('connection') ?: config('database.default'); + } + + /** + * Read an integer option, or null when it was not given + * + * @param string $name + * @return int|null + */ + protected function integerOption(string $name): ?int + { + $value = $this->option($name); + + if ($value === null || $value === '') { + return null; + } + + if (!ctype_digit((string) $value)) { + throw new \RuntimeException("The --{$name} option must be a positive integer."); + } + + return (int) $value; + } + + /** + * Ask before a destructive command runs in production (or always, when + * $always is set). --force skips the question. + * + * @param string $warning + * @param bool $always + * @return bool + */ + protected function confirmToProceed(string $warning, bool $always = false): bool + { + $pretending = $this->input->hasOption('pretend') && $this->option('pretend'); + + if ($this->option('force') || $pretending) { + return true; + } + + if (!$always && config('app.env', 'production') !== 'production') { + return true; + } + + $this->displayWarning($warning); + + if (!$this->isInteractive()) { + $this->displayError('Refusing to continue without a terminal. Pass --force to run it anyway.'); + + return false; + } + + if (!$this->confirm('Are you sure you want to proceed?', false)) { + $this->displayInfo('Command cancelled'); + + return false; + } + + $this->newLine(); + + return true; + } + + /** + * Build the callback the Migrator reports progress to + * + * @return \Closure + */ + protected function progressReporter(): \Closure + { + return function (string $event, string $migration, array $info): void { + switch ($event) { + case 'ran': + $this->progressLine($migration, $info['time'], 'DONE', 'green'); + break; + case 'rolled_back': + $this->progressLine($migration, $info['time'], 'ROLLED BACK', 'yellow'); + break; + case 'pretend': + $this->pretendLines($migration, $info['queries']); + break; + } + }; + } + + /** + * Print "name ........ 12ms DONE", padded to the terminal width + * + * @param string $migration + * @param int $milliseconds + * @param string $label + * @param string $color + * @return void + */ + protected function progressLine(string $migration, int $milliseconds, string $label, string $color): void + { + $name = preg_replace('/\.php$/', '', $migration); + $time = $milliseconds . 'ms'; + $dots = max(1, (new Terminal())->getWidth() - mb_strlen($name) - mb_strlen($time) - mb_strlen($label) - 6); + + $this->line(sprintf( + ' %s %s %s %s', + $name, + str_repeat('.', $dots), + $time, + $color, + $label + )); + } + + /** + * Print the SQL a migration would have executed + * + * @param string $migration + * @param array $queries + * @return void + */ + protected function pretendLines(string $migration, array $queries): void + { + $this->line(' ' . preg_replace('/\.php$/', '', $migration) . ''); + + if ($queries === []) { + $this->line(' (no statements)'); + } + + foreach ($queries as $query) { + $this->line(' ' . OutputFormatter::escape(rtrim(trim($query['sql']), ';')) . ';'); + + if (!empty($query['bindings'])) { + $this->line(' bindings ' . OutputFormatter::escape(json_encode($query['bindings'])) . ''); + } + } + + $this->newLine(); + } + + /** + * Run the database seeders through the db:seed command + * + * @return int + */ + protected function runSeeders(): int + { + $this->newLine(); + $this->line('🌱 Seeding database'); + + return $this->getApplication()->find('db:seed')->run(new ArrayInput([]), $this->output); + } +} diff --git a/src/Phaseolies/Database/Database.php b/src/Phaseolies/Database/Database.php index 0839a4a3..74b689b0 100644 --- a/src/Phaseolies/Database/Database.php +++ b/src/Phaseolies/Database/Database.php @@ -41,6 +41,13 @@ class Database */ protected static $drivers = []; + /** + * Statements captured while pretending, or null when running for real + * + * @var array|null + */ + protected static ?array $pretendLog = null; + /** * The connection name for this instance */ @@ -454,6 +461,12 @@ public function statement(string $sql, array $params = []): \PDOStatement */ public function execute(string $sql, array $params = []): int { + if (static::$pretendLog !== null) { + static::$pretendLog[] = ['sql' => $sql, 'bindings' => $params]; + + return 0; + } + $pdo = $this->getPdo(); $driver = $this->getDriver(); @@ -479,6 +492,36 @@ public function execute(string $sql, array $params = []): int return $stmt->rowCount(); } + /** + * Run the callback without executing any write statements, instead capturing them for inspection. + * + * @param \Closure $callback + * @return array + */ + public static function pretend(\Closure $callback): array + { + $previous = static::$pretendLog; + static::$pretendLog = []; + + try { + $callback(); + + return static::$pretendLog; + } finally { + static::$pretendLog = $previous; + } + } + + /** + * Determine if statements are currently being captured instead of run + * + * @return bool + */ + public static function isPretending(): bool + { + return static::$pretendLog !== null; + } + /** * Drop all tables in the database * diff --git a/src/Phaseolies/Database/Migration/Migration.php b/src/Phaseolies/Database/Migration/Migration.php index a2d99fda..3602906d 100644 --- a/src/Phaseolies/Database/Migration/Migration.php +++ b/src/Phaseolies/Database/Migration/Migration.php @@ -8,6 +8,11 @@ abstract class Migration { + // Run up() and down() inside a transaction on drivers that can roll back schema + // changes (PostgreSQL, SQLite). Set to false for statements that cannot run in a + // transaction, such as PostgreSQL's CREATE INDEX CONCURRENTLY. + public bool $withinTransaction = true; + // The 'up' method should contain the logic to apply the migration. // For example, creating tables, adding columns, or modifying indexes. abstract public function up(): void; diff --git a/src/Phaseolies/Database/Migration/MigrationRepository.php b/src/Phaseolies/Database/Migration/MigrationRepository.php index 5cc1a3fe..d8d792c1 100644 --- a/src/Phaseolies/Database/Migration/MigrationRepository.php +++ b/src/Phaseolies/Database/Migration/MigrationRepository.php @@ -2,7 +2,6 @@ namespace Phaseolies\Database\Migration; -use Phaseolies\Support\Facades\Schema; use Phaseolies\Support\Facades\DB; class MigrationRepository @@ -14,6 +13,13 @@ class MigrationRepository */ protected string $table = 'migrations'; + /** + * Whether the optional audit columns exist, keyed by connection + * + * @var array + */ + protected array $extended = []; + /** * Checks if the migrations table exists in the database * @@ -39,7 +45,54 @@ public function create(?string $connection = null): void Schema::connection($connection)->create($this->table, function ($table) { $table->string('migration'); $table->integer('batch'); + $table->string('checksum', 64)->nullable(); + $table->integer('execution_time')->nullable(); + $table->timestamp('ran_at')->nullable(); + }); + + $this->extended[$this->key($connection)] = true; + } + + /** + * Add the audit columns + * + * @param string|null $connection + * @return void + */ + public function upgrade(?string $connection = null): void + { + if ($this->hasAuditColumns($connection) || !$this->exists($connection)) { + return; + } + + $schema = Schema::connection($connection); + + $missing = array_filter( + ['checksum', 'execution_time', 'ran_at'], + fn($column) => !$schema->hasColumn($this->table, $column) + ); + + if ($missing === []) { + $this->extended[$this->key($connection)] = true; + + return; + } + + $schema->table($this->table, function ($table) use ($missing) { + if (in_array('checksum', $missing, true)) { + $table->string('checksum', 64)->nullable(); + } + + if (in_array('execution_time', $missing, true)) { + $table->integer('execution_time')->nullable(); + } + + if (in_array('ran_at', $missing, true)) { + $table->timestamp('ran_at')->nullable(); + } }); + + $this->extended[$this->key($connection)] = true; } /** @@ -65,15 +118,99 @@ public function getRan(?string $connection = null): array return $results ?: []; } + /** + * Get every recorded migration keyed by name, oldest first + * + * @param string|null $connection + * @return array + */ + public function getRecords(?string $connection = null): array + { + $connection = $connection ?? config('database.default'); + + if (!$this->exists($connection)) { + return []; + } + + $columns = $this->hasAuditColumns($connection) + ? 'migration, batch, checksum, execution_time, ran_at' + : 'migration, batch'; + + $rows = DB::connection($connection) + ->statement("SELECT {$columns} FROM {$this->table} ORDER BY batch ASC, migration ASC") + ->fetchAll(\PDO::FETCH_ASSOC); + + $records = []; + + foreach ($rows as $row) { + $records[$row['migration']] = [ + 'migration' => $row['migration'], + 'batch' => (int) $row['batch'], + 'checksum' => $row['checksum'] ?? null, + 'execution_time' => isset($row['execution_time']) ? (int) $row['execution_time'] : null, + 'ran_at' => $row['ran_at'] ?? null, + ]; + } + + return $records; + } + + /** + * Get the migrations to roll back, newest first. + * + * @param string|null $connection + * @param int|null $steps + * @param int|null $batch + * @return array + */ + public function getRollbackCandidates(?string $connection = null, ?int $steps = null, ?int $batch = null): array + { + $records = array_values($this->getRecords($connection)); + + usort($records, fn($a, $b) => [$b['batch'], $b['migration']] <=> [$a['batch'], $a['migration']]); + + if ($records === []) { + return []; + } + + if ($batch !== null) { + $records = array_filter($records, fn($record) => $record['batch'] === $batch); + } elseif ($steps !== null) { + $records = array_slice($records, 0, max(0, $steps)); + } else { + $last = $records[0]['batch']; + $records = array_filter($records, fn($record) => $record['batch'] === $last); + } + + return array_column($records, 'migration'); + } + /** * Logs a migration file as having been run * * @param string $file * @param string|null $connection + * @param int|null $batch + * @param string|null $checksum + * @param int|null $executionTime */ - public function log(string $file, ?string $connection = null): void - { - $batch = $this->getNextBatchNumber($connection); + public function log( + string $file, + ?string $connection = null, + ?int $batch = null, + ?string $checksum = null, + ?int $executionTime = null + ): void { + $batch ??= $this->getNextBatchNumber($connection); + + if ($this->hasAuditColumns($connection)) { + DB::connection($connection)->execute( + "INSERT INTO {$this->table} (migration, batch, checksum, execution_time, ran_at) VALUES (?, ?, ?, ?, ?)", + [$file, $batch, $checksum, $executionTime, date('Y-m-d H:i:s')] + ); + + return; + } DB::connection($connection)->execute( "INSERT INTO {$this->table} (migration, batch) VALUES (?, ?)", @@ -81,21 +218,75 @@ public function log(string $file, ?string $connection = null): void ); } + /** + * Remove a migration record, as when it has been rolled back + * + * @param string $file + * @param string|null $connection + * @return void + */ + public function delete(string $file, ?string $connection = null): void + { + DB::connection($connection)->execute( + "DELETE FROM {$this->table} WHERE migration = ?", + [$file] + ); + } + /** * Gets the next batch number for new migrations * * @param string|null $connection * @return int */ - protected function getNextBatchNumber(?string $connection = null): int + public function getNextBatchNumber(?string $connection = null): int + { + return $this->getLastBatchNumber($connection) + 1; + } + + /** + * Gets the highest batch number in use, or 0 when nothing has run + * + * @param string|null $connection + * @return int + */ + public function getLastBatchNumber(?string $connection = null): int { if (!$this->exists($connection)) { - return 1; + return 0; } $stmt = DB::connection($connection)->statement("SELECT MAX(batch) FROM {$this->table}"); - $maxBatch = $stmt->fetchColumn(); - return $maxBatch ? (int) $maxBatch + 1 : 1; + return (int) $stmt->fetchColumn(); + } + + /** + * Whether the checksum / execution time / run date columns are available + * + * @param string|null $connection + * @return bool + */ + protected function hasAuditColumns(?string $connection = null): bool + { + $key = $this->key($connection); + + if (!isset($this->extended[$key])) { + $this->extended[$key] = $this->exists($connection) + && Schema::connection($connection)->hasColumn($this->table, 'checksum') + && Schema::connection($connection)->hasColumn($this->table, 'execution_time') + && Schema::connection($connection)->hasColumn($this->table, 'ran_at'); + } + + return $this->extended[$key]; + } + + /** + * @param string|null $connection + * @return string + */ + protected function key(?string $connection): string + { + return $connection ?? (string) config('database.default'); } } diff --git a/src/Phaseolies/Database/Migration/Migrator.php b/src/Phaseolies/Database/Migration/Migrator.php index 62b4e351..ef32f199 100644 --- a/src/Phaseolies/Database/Migration/Migrator.php +++ b/src/Phaseolies/Database/Migration/Migrator.php @@ -2,6 +2,9 @@ namespace Phaseolies\Database\Migration; +use Phaseolies\Database\Database; +use Phaseolies\Support\Facades\DB; + class Migrator { /** @@ -60,14 +63,169 @@ public function addMigration(string $file, Migration $migration): void /** * Run all pending migrations * + * Options: + * - step (bool): give every migration its own batch so each can be rolled back on its own + * - pretend (bool): do not run anything, report the SQL each migration would execute + * - progress (callable): fn(string $event, string $migration, array $info) for 'running', + * 'ran' (info: time in ms) and 'pretend' (info: queries) + * + * @param string $connection + * @param string|null $path + * @param array $options + * @return array The names of the migrations that ran + */ + public function run(string $connection, ?string $path = null, array $options = []): array + { + $connection = $connection ?? config('database.default'); + $pretend = (bool) ($options['pretend'] ?? false); + + $execute = function () use ($connection, $path, $options, $pretend) { + // Pretending must not touch the database, so the tracking table is left alone. + if (!$pretend) { + $this->ensureMigrationTableExists($connection); + } + + $executed = $this->getPendingMigrations($connection, $path); + + $batch = $this->repository->getNextBatchNumber($connection); + + foreach ($executed as $file) { + $this->runMigration($file, $connection, $batch, $options); + + if (!empty($options['step'])) { + $batch++; + } + } + + return $executed; + }; + + return $pretend ? $execute() : $this->withLock($connection, $execute); + } + + /** + * Roll back migrations. By default the most recent batch is reverted. + * + * Options: + * - step (int): roll back the last N migrations regardless of batch + * - batch (int): roll back one specific batch + * - pretend (bool): report the SQL without running it + * - progress (callable): fn(string $event, string $migration, array $info) for + * 'rolling_back', 'rolled_back' and 'pretend' + * * @param string $connection + * @param array $options + * @return array The names of the migrations that were rolled back + * @throws \RuntimeException When a migration to roll back has no file on disk + */ + public function rollback(string $connection, array $options = []): array + { + $pretend = (bool) ($options['pretend'] ?? false); + + $execute = function () use ($connection, $options) { + if (!$this->repository->exists($connection)) { + return []; + } + + $names = $this->repository->getRollbackCandidates( + $connection, + isset($options['step']) ? (int) $options['step'] : null, + isset($options['batch']) ? (int) $options['batch'] : null + ); + + // Resolve everything first so a missing file aborts before anything is reverted. + $migrations = []; + foreach ($names as $name) { + $migrations[$name] = $this->resolveMigration($name, $connection, true); + } + + foreach ($migrations as $name => $migration) { + $this->rollbackMigration($name, $migration, $connection, $options); + } + + return $names; + }; + + return $pretend ? $execute() : $this->withLock($connection, $execute); + } + + /** + * Roll back every migration that has run + * + * @param string $connection + * @param array $options Same as rollback() * @return array */ - public function run(string $connection, ?string $path = null): array + public function reset(string $connection, array $options = []): array + { + unset($options['batch']); + $options['step'] = PHP_INT_MAX; + + return $this->rollback($connection, $options); + } + + /** + * Describe every known migration: whether it ran, in which batch, how long + * it took, and whether its file changed or disappeared since it ran. + * + * @param string|null $connection + * @return array + */ + public function status(?string $connection = null): array { $connection = $connection ?? config('database.default'); - $this->ensureMigrationTableExists($connection); + $files = $this->migrationFiles($connection); + $records = $this->repository->getRecords($connection); + + $rows = []; + + foreach ($files as $name => $file) { + $record = $records[$name] ?? null; + + $rows[$name] = [ + 'migration' => $name, + 'ran' => $record !== null, + 'batch' => $record['batch'] ?? null, + 'ran_at' => $record['ran_at'] ?? null, + 'execution_time' => $record['execution_time'] ?? null, + 'modified' => $record !== null + && $record['checksum'] !== null + && $record['checksum'] !== $this->checksum($file), + 'missing' => false, + ]; + } + + foreach ($records as $name => $record) { + if (isset($rows[$name])) { + continue; + } + + $rows[$name] = [ + 'migration' => $name, + 'ran' => true, + 'batch' => $record['batch'], + 'ran_at' => $record['ran_at'], + 'execution_time' => $record['execution_time'], + 'modified' => false, + 'missing' => true, + ]; + } + + ksort($rows); + + return array_values($rows); + } + + /** + * Get the names of the migrations that have not run yet + * + * @param string $connection + * @param string|null $path Restrict to one migration file + * @return array + */ + public function getPendingMigrations(string $connection, ?string $path = null): array + { $files = $this->getMigrationFiles($connection); $ran = $this->repository->getRan($connection); @@ -110,16 +268,10 @@ public function run(string $connection, ?string $path = null): array throw new \RuntimeException("Migration file not found: {$fullPath}\n\n"); } - $this->runMigrationList([$file], $connection); - return [$file]; } - if (empty($migrations)) { - return []; - } - - $executed = []; + $pending = []; foreach ($migrations as $file) { $fullPath = is_file($file) ? $file : $this->migrationPath . DIRECTORY_SEPARATOR . $file; @@ -128,13 +280,11 @@ public function run(string $connection, ?string $path = null): array continue; } - $executed[] = basename($fullPath); + $pending[] = basename($fullPath); } - sort($executed); + sort($pending); - $this->runMigrationList($executed, $connection); - - return $executed; + return $pending; } /** @@ -147,7 +297,11 @@ protected function ensureMigrationTableExists(?string $connection = null): void { if (!$this->repository->exists($connection)) { $this->repository->create($connection); + + return; } + + $this->repository->upgrade($connection); } /** @@ -224,68 +378,241 @@ public function getMigrationFiles(?string $connection = null): array } /** - * Run a list of migration files + * Run a single migration file * - * @param array $migrations - * @param string|null $connection + * @param string $file + * @param string $connection + * @param int $batch + * @param array $options + * @return void + * @throws \RuntimeException */ - protected function runMigrationList(array $migrations, ?string $connection = null): void + protected function runMigration(string $file, string $connection, int $batch, array $options = []): void { - foreach ($migrations as $file) { - $this->runMigration($file, $connection); + $progress = $options['progress'] ?? null; + $migration = $this->resolveMigration($file, $connection); + + if (!empty($options['pretend'])) { + $queries = Database::pretend(fn() => $migration->up()); + $progress && $progress('pretend', $file, ['queries' => $queries]); + + return; } + + $progress && $progress('running', $file, []); + + $elapsed = 0; + + try { + $this->transactional($connection, $migration, function () use ($migration, $file, $connection, $batch, &$elapsed) { + $start = hrtime(true); + $migration->up(); + $elapsed = (int) round((hrtime(true) - $start) / 1e6); + + $this->repository->log($file, $connection, $batch, $this->checksum($this->pathFor($file)), $elapsed); + }); + } catch (\Throwable $e) { + throw new \RuntimeException("Migration {$file} failed: " . $e->getMessage(), (int) $e->getCode(), $e); + } + + $progress && $progress('ran', $file, ['time' => $elapsed]); } /** - * Run a single migration file + * Revert a single migration and forget its record * * @param string $file - * @param string|null $connection + * @param Migration $migration + * @param string $connection + * @param array $options * @return void * @throws \RuntimeException */ - protected function runMigration(string $file, ?string $connection = null): void + protected function rollbackMigration(string $file, Migration $migration, string $connection, array $options = []): void { + $progress = $options['progress'] ?? null; + + if (!empty($options['pretend'])) { + $queries = Database::pretend(fn() => $migration->down()); + $progress && $progress('pretend', $file, ['queries' => $queries]); + + return; + } + + $progress && $progress('rolling_back', $file, []); + + $elapsed = 0; + try { - foreach ($this->migrations as $path => $migration) { - if (basename($path) === $file) { - $migration->up(); - $this->repository->log($file, $connection); - return; - } - } + $this->transactional($connection, $migration, function () use ($migration, $file, $connection, &$elapsed) { + $start = hrtime(true); + $migration->down(); + $elapsed = (int) round((hrtime(true) - $start) / 1e6); + + $this->repository->delete($file, $connection); + }); + } catch (\Throwable $e) { + throw new \RuntimeException("Rolling back {$file} failed: " . $e->getMessage(), (int) $e->getCode(), $e); + } + + $progress && $progress('rolled_back', $file, ['time' => $elapsed]); + } + + /** + * Run a callback atomically when the driver can roll back schema changes. + * + * PostgreSQL and SQLite support transactional DDL, so a migration that + * fails halfway leaves nothing behind. MySQL commits implicitly on DDL, + * so there the callback simply runs. A migration can opt out by setting + * `public bool $withinTransaction = false;`. + * + * @param string $connection + * @param Migration $migration + * @param \Closure $callback + * @return void + */ + protected function transactional(string $connection, Migration $migration, \Closure $callback): void + { + $db = DB::connection($connection); + + if ($migration->withinTransaction && in_array($db->getDriver(), ['pgsql', 'sqlite'], true)) { + $db->transaction($callback); + + return; + } + + $callback(); + } + + /** + * Hold a database-wide lock so two deploys cannot migrate at the same time. + * MySQL and PostgreSQL use their advisory locks; SQLite has a single writer + * already and is not locked. + * + * @param string $connection + * @param \Closure $callback + * @return mixed + * @throws \RuntimeException When another process holds the lock + */ + protected function withLock(string $connection, \Closure $callback): mixed + { + $db = DB::connection($connection); + $driver = $db->getDriver(); + + $key = 'doppar_migrations_' . md5($connection . '|' . config("database.connections.{$connection}.database")); + $number = crc32($key); + + $acquired = match ($driver) { + 'mysql' => (bool) $db->statement('SELECT GET_LOCK(?, 0)', [$key])->fetchColumn(), + 'pgsql' => (bool) $db->statement('SELECT pg_try_advisory_lock(?)', [$number])->fetchColumn(), + default => true, + }; + + if (!$acquired) { + throw new \RuntimeException( + "Another migration process is already running on connection [{$connection}]." + ); + } - $path = $this->migrationPath . DIRECTORY_SEPARATOR . $file; - if (!file_exists($path)) { - throw new \RuntimeException("Migration file not found: {$path}"); + try { + return $callback(); + } finally { + match ($driver) { + 'mysql' => $db->statement('SELECT RELEASE_LOCK(?)', [$key])->fetchColumn(), + 'pgsql' => $db->statement('SELECT pg_advisory_unlock(?)', [$number])->fetchColumn(), + default => null, + }; + } + } + + /** + * Map migration name to file path for the given connection + * + * @param string $connection + * @return array + */ + protected function migrationFiles(string $connection): array + { + $files = []; + + foreach ($this->getMigrationFiles($connection) as $file) { + $files[basename($file)] = $file; + } + + return $files; + } + + /** + * Locate the file of a migration: the project's copy wins over a package's + * + * @param string $file + * @return string|null + */ + protected function pathFor(string $file): ?string + { + $local = $this->migrationPath . DIRECTORY_SEPARATOR . $file; + + if (is_file($local)) { + return $local; + } + + foreach ($this->migrationPaths as $path) { + if (basename($path) === $file) { + return is_file($path) ? $path : base_path($path); } + } - $migration = require $path; + return null; + } - $migration->up(); - $this->repository->log($file, $connection); - } catch (\Throwable $e) { - throw $e; + /** + * Fingerprint of a migration file, insensitive to line-ending differences + * + * @param string|null $path + * @return string|null + */ + protected function checksum(?string $path): ?string + { + if ($path === null || !is_file($path)) { + return null; } + + return hash('sha256', str_replace("\r\n", "\n", (string) file_get_contents($path))); } /** * Resolve a migration file into a Migration instance * * @param string $file + * @param string|null $connection + * @param bool $forRollback * @return Migration * @throws \RuntimeException */ - protected function resolve(string $file): Migration + protected function resolveMigration(string $file, ?string $connection = null, bool $forRollback = false): Migration { - $path = $this->migrationPath . DIRECTORY_SEPARATOR . $file; + foreach ($this->migrations as $path => $migration) { + if (basename($path) === $file && !is_file($this->migrationPath . DIRECTORY_SEPARATOR . $file)) { + return $migration; + } + } + + $path = $this->pathFor($file); - if (!file_exists($path)) { - throw new \RuntimeException("Migration file not found: {$path}"); + if ($path === null) { + throw new \RuntimeException( + $forRollback + ? "Cannot roll back [{$file}]: the migration file no longer exists." + : "Migration file not found: {$this->migrationPath}" . DIRECTORY_SEPARATOR . $file + ); } $migration = require $path; + if ($migration instanceof \Closure) { + $migration = $migration(); + } + if (!$migration instanceof Migration) { throw new \RuntimeException("Migration {$file} must return an instance of Migration"); } diff --git a/tests/Builder/MigrateCommandsTest.php b/tests/Builder/MigrateCommandsTest.php new file mode 100644 index 00000000..e32b2853 --- /dev/null +++ b/tests/Builder/MigrateCommandsTest.php @@ -0,0 +1,189 @@ +migrator); + $container = \Phaseolies\DI\Container::getInstance(); + $container->bind('migrator', fn() => $this->migrator); + $container->bind('log', fn() => new class { + public function __call($method, $arguments) + { + throw $arguments[0] instanceof \Throwable ? $arguments[0] : new \RuntimeException((string) $arguments[0]); + } + }); + + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + } + + protected function tearDown(): void + { + CommandTestEnvironment::$appBindings = []; + + parent::tearDown(); + } + + private function run_(SymfonyCommand $command, array $input = []): CommandTester + { + $tester = new CommandTester($command); + $tester->execute($input, ['interactive' => false, 'decorated' => false]); + + return $tester; + } + + public function testMigrateReportsEachMigrationWithItsTime(): void + { + $tester = $this->run_(new MigrateCommand()); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertMatchesRegularExpression('/2025_01_01_000001_create_a_table \.+ \d+ms DONE/', $tester->getDisplay()); + $this->assertStringContainsString('2 migrations executed', $tester->getDisplay()); + $this->assertTrue($this->hasTable('a')); + } + + public function testMigratePretendPrintsSqlAndLeavesDatabaseUntouched(): void + { + $tester = $this->run_(new MigrateCommand(), ['--pretend' => true]); + + $this->assertStringContainsString('CREATE TABLE', $tester->getDisplay()); + $this->assertFalse($this->hasTable('a')); + $this->assertFalse($this->hasTable('migrations')); + } + + public function testMigrateFailureIsReportedWithAnErrorExitCode(): void + { + $this->write('2025_01_01_000003_broken', "throw new \\RuntimeException('kaboom');"); + + $tester = $this->run_(new MigrateCommand()); + + $this->assertSame(1, $tester->getStatusCode()); + $this->assertStringContainsString('2025_01_01_000003_broken.php failed: kaboom', $tester->getDisplay()); + } + + public function testRollbackStepRollsBackThatManyMigrations(): void + { + $this->run_(new MigrateCommand()); + + $tester = $this->run_(new MigrateRollbackCommand(), ['--step' => '1']); + + $this->assertStringContainsString('1 migration rolled back', $tester->getDisplay()); + $this->assertTrue($this->hasTable('a')); + $this->assertFalse($this->hasTable('b')); + } + + public function testRollbackRejectsConflictingAndInvalidOptions(): void + { + $this->run_(new MigrateCommand()); + + $both = $this->run_(new MigrateRollbackCommand(), ['--step' => '1', '--batch' => '1']); + $this->assertSame(1, $both->getStatusCode()); + $this->assertStringContainsString('either --step or --batch', $both->getDisplay()); + + $invalid = $this->run_(new MigrateRollbackCommand(), ['--step' => 'abc']); + $this->assertSame(1, $invalid->getStatusCode()); + $this->assertTrue($this->hasTable('a') && $this->hasTable('b')); + } + + public function testResetIsDestructiveSoItRefusesWithoutForceWhenNotInteractive(): void + { + $this->run_(new MigrateCommand()); + + $refused = $this->run_(new MigrateResetCommand()); + $this->assertSame(1, $refused->getStatusCode()); + $this->assertStringContainsString('--force', $refused->getDisplay()); + $this->assertTrue($this->hasTable('a')); + + $forced = $this->run_(new MigrateResetCommand(), ['--force' => true]); + $this->assertSame(0, $forced->getStatusCode()); + $this->assertFalse($this->hasTable('a') || $this->hasTable('b')); + } + + public function testRefreshRollsBackAndMigratesAgain(): void + { + $this->run_(new MigrateCommand()); + + $tester = $this->run_(new MigrateRefreshCommand(), ['--force' => true]); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('Refreshed (2 rolled back, 2 executed)', $tester->getDisplay()); + $this->assertTrue($this->hasTable('a') && $this->hasTable('b')); + } + + public function testFreshAndWipeDropEverything(): void + { + $this->run_(new MigrateCommand()); + + $this->assertSame(1, $this->run_(new MigrateFreshCommand())->getStatusCode()); + $this->assertTrue($this->hasTable('a')); + + $this->assertSame(0, $this->run_(new MigrateFreshCommand(), ['--force' => true])->getStatusCode()); + $this->assertTrue($this->hasTable('a')); + + $this->assertSame(0, $this->run_(new DbWipeCommand(), ['--force' => true])->getStatusCode()); + $this->assertFalse($this->hasTable('a') || $this->hasTable('migrations')); + } + + public function testStatusPendingExitCodeIsUsableInCi(): void + { + $this->assertSame(1, $this->run_(new MigrateStatusCommand(), ['--pending' => true])->getStatusCode()); + + $this->run_(new MigrateCommand()); + + $this->assertSame(0, $this->run_(new MigrateStatusCommand(), ['--pending' => true])->getStatusCode()); + } + + public function testStatusJsonIsMachineReadable(): void + { + $this->run_(new MigrateCommand()); + $this->write('2025_01_01_000003_create_c_table', "Schema::create('c', fn(Blueprint \$t) => \$t->id());"); + + $data = json_decode($this->run_(new MigrateStatusCommand(), ['--json' => true])->getDisplay(), true); + + $this->assertSame(1, $data['pending']); + $this->assertSame( + [true, true, false], + array_column($data['migrations'], 'ran') + ); + } + + public function testProductionRequiresForceEvenForPlainMigrate(): void + { + \Phaseolies\Config\Config::set('app.env', 'production'); + + $refused = $this->run_(new MigrateCommand()); + + $this->assertSame(1, $refused->getStatusCode()); + $this->assertStringContainsString('production', $refused->getDisplay()); + $this->assertFalse($this->hasTable('a')); + + $this->assertSame(0, $this->run_(new MigrateCommand(), ['--force' => true])->getStatusCode()); + $this->assertTrue($this->hasTable('a')); + } +} diff --git a/tests/Builder/MigratorPgsqlTest.php b/tests/Builder/MigratorPgsqlTest.php new file mode 100644 index 00000000..b5e7eead --- /dev/null +++ b/tests/Builder/MigratorPgsqlTest.php @@ -0,0 +1,49 @@ +markTestSkipped('Configure DOPPAR_TEST_PGSQL_HOST/DOPPAR_TEST_PGSQL_DATABASE to run this test.'); + } + + return [ + 'driver' => 'pgsql', + 'host' => $host, + 'port' => getenv('DOPPAR_TEST_PGSQL_PORT') ?: '5432', + 'database' => $database, + 'username' => getenv('DOPPAR_TEST_PGSQL_USERNAME') ?: 'postgres', + 'password' => getenv('DOPPAR_TEST_PGSQL_PASSWORD') ?: '', + 'prefix' => '', + ]; + } + + protected function wipeDatabase(): void + { + if ((getenv('DOPPAR_TEST_PGSQL_DATABASE') ?: '') === '') { + return; + } + + (new Database(self::CONNECTION))->dropAllTables(); + } +} diff --git a/tests/Builder/MigratorTest.php b/tests/Builder/MigratorTest.php new file mode 100644 index 00000000..0bfe0fe6 --- /dev/null +++ b/tests/Builder/MigratorTest.php @@ -0,0 +1,248 @@ +createTable('2025_01_01_000001_create_a_table', 'a'); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + + $ran = $this->migrator->run(self::CONNECTION); + + $this->assertSame(['2025_01_01_000001_create_a_table.php', '2025_01_01_000002_create_b_table.php'], $ran); + $this->assertSame([1, 1], array_column($this->migrator->status(self::CONNECTION), 'batch')); + $this->assertTrue($this->hasTable('a') && $this->hasTable('b')); + } + + public function testStepGivesEveryMigrationItsOwnBatch(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + + $this->migrator->run(self::CONNECTION, null, ['step' => true]); + + $this->assertSame([1, 2], array_column($this->migrator->status(self::CONNECTION), 'batch')); + } + + public function testRunIsIdempotent(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + + $this->migrator->run(self::CONNECTION); + + $this->assertSame([], $this->migrator->run(self::CONNECTION)); + } + + public function testRollbackRevertsOnlyTheLastBatch(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->migrator->run(self::CONNECTION); + + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + $this->createTable('2025_01_01_000003_create_c_table', 'c'); + $this->migrator->run(self::CONNECTION); + + $rolledBack = $this->migrator->rollback(self::CONNECTION); + + $this->assertSame(['2025_01_01_000003_create_c_table.php', '2025_01_01_000002_create_b_table.php'], $rolledBack); + $this->assertTrue($this->hasTable('a')); + $this->assertFalse($this->hasTable('b')); + $this->assertFalse($this->hasTable('c')); + } + + public function testRollbackStepIgnoresBatchBoundaries(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + $this->createTable('2025_01_01_000003_create_c_table', 'c'); + $this->migrator->run(self::CONNECTION); + + $rolledBack = $this->migrator->rollback(self::CONNECTION, ['step' => 2]); + + $this->assertSame(['2025_01_01_000003_create_c_table.php', '2025_01_01_000002_create_b_table.php'], $rolledBack); + $this->assertTrue($this->hasTable('a')); + $this->assertFalse($this->hasTable('b')); + } + + public function testRollbackSpecificBatch(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->migrator->run(self::CONNECTION); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + $this->migrator->run(self::CONNECTION); + + $rolledBack = $this->migrator->rollback(self::CONNECTION, ['batch' => 1]); + + $this->assertSame(['2025_01_01_000001_create_a_table.php'], $rolledBack); + $this->assertFalse($this->hasTable('a')); + $this->assertTrue($this->hasTable('b')); + } + + public function testResetRollsBackEverythingAndRunCanStartOver(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->migrator->run(self::CONNECTION); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + $this->migrator->run(self::CONNECTION); + + $this->assertCount(2, $this->migrator->reset(self::CONNECTION)); + $this->assertFalse($this->hasTable('a') || $this->hasTable('b')); + + $this->assertCount(2, $this->migrator->run(self::CONNECTION)); + $this->assertTrue($this->hasTable('a') && $this->hasTable('b')); + } + + public function testRollbackWithNothingToRollbackIsEmpty(): void + { + $this->assertSame([], $this->migrator->rollback(self::CONNECTION)); + } + + public function testRollbackAbortsBeforeTouchingAnythingWhenAFileIsMissing(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + $this->migrator->run(self::CONNECTION); + + unlink($this->dir . '/migrations/2025_01_01_000001_create_a_table.php'); + + try { + $this->migrator->rollback(self::CONNECTION); + $this->fail('Expected a RuntimeException'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('2025_01_01_000001_create_a_table.php', $e->getMessage()); + } + + $this->assertTrue($this->hasTable('a') && $this->hasTable('b')); + $this->assertCount(2, $this->migrator->status(self::CONNECTION)); + } + + public function testPretendReportsSqlAndChangesNothing(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + + $queries = []; + $ran = $this->migrator->run(self::CONNECTION, null, [ + 'pretend' => true, + 'progress' => function ($event, $name, $info) use (&$queries) { + if ($event === 'pretend') { + $queries = array_merge($queries, array_column($info['queries'], 'sql')); + } + }, + ]); + + $this->assertSame(['2025_01_01_000001_create_a_table.php'], $ran); + $this->assertNotEmpty($queries); + $this->assertStringContainsString('CREATE TABLE', $queries[0]); + $this->assertFalse($this->hasTable('a')); + $this->assertFalse($this->hasTable('migrations'), 'pretend must not create the tracking table'); + } + + public function testPretendRollbackDoesNotRevert(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->migrator->run(self::CONNECTION); + + $queries = []; + $this->migrator->rollback(self::CONNECTION, [ + 'pretend' => true, + 'progress' => function ($event, $name, $info) use (&$queries) { + $queries = array_merge($queries, array_column($info['queries'] ?? [], 'sql')); + }, + ]); + + $this->assertStringContainsString('DROP TABLE', $queries[0]); + $this->assertTrue($this->hasTable('a')); + $this->assertCount(1, array_filter(array_column($this->migrator->status(self::CONNECTION), 'ran'))); + } + + public function testFailedMigrationLeavesNoPartialSchemaOnTransactionalDrivers(): void + { + $this->write('2025_01_01_000001_half_done', <<<'PHP' + Schema::create('half', fn(Blueprint $t) => $t->id()); + throw new \RuntimeException('boom'); + PHP); + + try { + $this->migrator->run(self::CONNECTION); + $this->fail('Expected a RuntimeException'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('2025_01_01_000001_half_done.php', $e->getMessage()); + $this->assertStringContainsString('boom', $e->getMessage()); + } + + $this->assertFalse($this->hasTable('half')); + $this->assertSame([], array_filter(array_column($this->migrator->status(self::CONNECTION), 'ran'))); + } + + public function testMigrationCanOptOutOfTheTransaction(): void + { + $this->write('2025_01_01_000001_half_done', <<<'PHP' + Schema::create('half', fn(Blueprint $t) => $t->id()); + throw new \RuntimeException('boom'); + PHP, 'public bool $withinTransaction = false;'); + + try { + $this->migrator->run(self::CONNECTION); + } catch (\RuntimeException) { + } + + $this->assertTrue($this->hasTable('half')); + } + + public function testStatusFlagsModifiedAndMissingMigrations(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + $this->migrator->run(self::CONNECTION); + + $this->createTable('2025_01_01_000003_create_c_table', 'c'); + file_put_contents($this->dir . '/migrations/2025_01_01_000001_create_a_table.php', "\n// edited", FILE_APPEND); + unlink($this->dir . '/migrations/2025_01_01_000002_create_b_table.php'); + + $status = array_column($this->migrator->status(self::CONNECTION), null, 'migration'); + + $this->assertTrue($status['2025_01_01_000001_create_a_table.php']['modified']); + $this->assertFalse($status['2025_01_01_000001_create_a_table.php']['missing']); + $this->assertTrue($status['2025_01_01_000002_create_b_table.php']['missing']); + $this->assertFalse($status['2025_01_01_000003_create_c_table.php']['ran']); + $this->assertNull($status['2025_01_01_000003_create_c_table.php']['batch']); + } + + public function testStatusRecordsExecutionTimeAndRunDate(): void + { + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->migrator->run(self::CONNECTION); + + $row = $this->migrator->status(self::CONNECTION)[0]; + + $this->assertIsInt($row['execution_time']); + $this->assertNotEmpty($row['ran_at']); + } + + public function testExistingMigrationsTableIsUpgradedInPlace(): void + { + $pdo = Database::getPdoInstance(self::CONNECTION); + $pdo->exec('CREATE TABLE migrations (migration VARCHAR(255) NOT NULL, batch INTEGER NOT NULL)'); + $pdo->exec("INSERT INTO migrations (migration, batch) VALUES ('2025_01_01_000001_create_a_table.php', 1)"); + + $this->createTable('2025_01_01_000001_create_a_table', 'a'); + $this->createTable('2025_01_01_000002_create_b_table', 'b'); + + $this->assertSame(['2025_01_01_000002_create_b_table.php'], $this->migrator->run(self::CONNECTION)); + + $status = array_column($this->migrator->status(self::CONNECTION), null, 'migration'); + $this->assertSame(1, $status['2025_01_01_000001_create_a_table.php']['batch']); + $this->assertFalse($status['2025_01_01_000001_create_a_table.php']['modified'], 'legacy rows have no checksum to compare'); + $this->assertSame(2, $status['2025_01_01_000002_create_b_table.php']['batch']); + $this->assertNotNull($status['2025_01_01_000002_create_b_table.php']['ran_at']); + } +} diff --git a/tests/Builder/MigratorTestCase.php b/tests/Builder/MigratorTestCase.php new file mode 100644 index 00000000..31beb666 --- /dev/null +++ b/tests/Builder/MigratorTestCase.php @@ -0,0 +1,149 @@ +dir = sys_get_temp_dir() . '/doppar-migrator-' . bin2hex(random_bytes(5)); + mkdir($this->dir . '/migrations', 0755, true); + $this->dbFile = $this->dir . '/test.sqlite'; + + $container = new MockContainer(); + Container::setInstance($container); + $this->resetConfig(); + + Config::set('database.connections.' . self::CONNECTION, $this->connectionConfig()); + Config::set('database.default', self::CONNECTION); + + $container->bind('db', fn() => new Database()); + $container->bind('schema', fn() => new Schema()); + + if (method_exists(Facade::class, 'clearResolvedInstances')) { + Facade::clearResolvedInstances(); + } + + $this->wipeDatabase(); + + $this->migrator = new Migrator(new MigrationRepository(), $this->dir . '/migrations'); + } + + /** + * The scratch connection the migrator runs against + * + * @return array + */ + protected function connectionConfig(): array + { + return [ + 'driver' => 'sqlite', + 'database' => $this->dbFile, + 'prefix' => '', + 'foreign_key_constraints' => true, + ]; + } + + /** + * Remove everything the test created in the scratch database + * + * @return void + */ + protected function wipeDatabase(): void + { + } + + protected function tearDown(): void + { + $this->wipeDatabase(); + $this->setStatic(Database::class, 'connections', []); + $this->setStatic(Database::class, 'drivers', []); + $this->setStatic(Database::class, 'transactions', []); + + $this->resetConfig(); + (new \ReflectionClass(Container::class))->getProperty('instance')->setValue(null, null); + + foreach (glob($this->dir . '/migrations/*') ?: [] as $file) { + unlink($file); + } + @unlink($this->dbFile); + @rmdir($this->dir . '/migrations'); + @rmdir($this->dir); + } + + protected function createTable(string $name, string $table): void + { + $this->write($name, "Schema::create('{$table}', fn(Blueprint \$t) => \$t->id());", '', "Schema::dropIfExists('{$table}');"); + } + + protected function write(string $name, string $up, string $extra = '', string $down = ''): void + { + $code = <<dir}/migrations/{$name}.php", $code); + } + + protected function hasTable(string $table): bool + { + return (new Database(self::CONNECTION))->tableExists($table); + } + + protected function resetConfig(): void + { + $reflection = new \ReflectionClass(Config::class); + + foreach (['config' => [], 'cacheFile' => null, 'loadedFromCache' => false, 'fileHashes' => []] as $name => $value) { + if ($reflection->hasProperty($name)) { + $reflection->getProperty($name)->setValue(null, $value); + } + } + } + + protected function setStatic(string $class, string $property, mixed $value): void + { + (new \ReflectionClass($class))->getProperty($property)->setValue(null, $value); + } +}