diff --git a/src/Command/ResetCommand.php b/src/Command/ResetCommand.php new file mode 100644 index 00000000..db006735 --- /dev/null +++ b/src/Command/ResetCommand.php @@ -0,0 +1,282 @@ + + */ + use EventDispatcherTrait; + + /** + * The default name added to the application command list + * + * @return string + */ + public static function defaultName(): string + { + return 'migrations reset'; + } + + /** + * Configure the option parser + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to configure + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + 'Drop all tables and re-run all migrations.', + '', + 'This is a destructive operation!', + 'All data in the database will be lost.', + '', + 'migrations reset', + 'migrations reset -c secondary', + 'migrations reset --dry-run', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to run migrations for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'default' => ConfigInterface::DEFAULT_MIGRATION_FOLDER, + 'help' => 'The folder where your migrations are', + ])->addOption('dry-run', [ + 'short' => 'x', + 'help' => 'Preview what tables would be dropped without making changes', + 'boolean' => true, + ])->addOption('no-lock', [ + 'help' => 'If present, no lock file will be generated after migrating', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $event = $this->dispatchEvent('Migration.beforeReset'); + if ($event->isStopped()) { + return $event->getResult() ? self::CODE_SUCCESS : self::CODE_ERROR; + } + + $connectionName = (string)$args->getOption('connection'); + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + $dryRun = (bool)$args->getOption('dry-run'); + + if ($dryRun) { + $io->out('DRY-RUN mode enabled - no changes will be made'); + $io->out(''); + } + + // Get tables to drop + $tablesToDrop = $this->getTablesToDrop($connection); + + if (empty($tablesToDrop)) { + $io->out('No tables to drop.'); + $io->out(''); + $io->out('Running migrations...'); + + return $this->runMigrationsAndDispatch($args, $io); + } + + // Show what will be dropped + $io->out('The following tables will be dropped:'); + foreach ($tablesToDrop as $table) { + $io->out(' - ' . $table); + } + $io->out(''); + + // Ask for confirmation (unless dry-run) + if (!$dryRun) { + $continue = $io->askChoice( + 'This will permanently delete all data. Do you want to continue?', + ['y', 'n'], + 'n', + ); + if ($continue !== 'y') { + $io->warning('Reset operation aborted.'); + + return self::CODE_SUCCESS; + } + } + + // Drop tables + $io->out(''); + if (!$dryRun) { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + ]); + $manager = $factory->createManager($io); + $adapter = $manager->getEnvironment()->getAdapter(); + + $this->dropTables($adapter, $tablesToDrop, $io); + } else { + $io->info('DRY-RUN: Would drop ' . count($tablesToDrop) . ' table(s).'); + } + + $io->out(''); + + // Re-run migrations + if (!$dryRun) { + return $this->runMigrationsAndDispatch($args, $io); + } + + $io->info('DRY-RUN: Would re-run all migrations.'); + + return self::CODE_SUCCESS; + } + + /** + * Get list of tables to drop. + * + * @param \Cake\Database\Connection $connection Database connection + * @return array List of table names + */ + protected function getTablesToDrop(Connection $connection): array + { + $schema = $connection->getDriver()->schemaDialect(); + + return $schema->listTables(); + } + + /** + * Drop tables with foreign key handling. + * + * @param \Migrations\Db\Adapter\AdapterInterface $adapter The adapter + * @param array $tables Tables to drop + * @param \Cake\Console\ConsoleIo $io Console IO + * @return void + */ + protected function dropTables(AdapterInterface $adapter, array $tables, ConsoleIo $io): void + { + if (!$adapter instanceof DirectActionInterface) { + throw new RuntimeException('The adapter must implement DirectActionInterface'); + } + + $adapter->disableForeignKeyConstraints(); + + try { + foreach ($tables as $table) { + $io->verbose("Dropping table: {$table}"); + $adapter->dropTable($table); + } + } finally { + $adapter->enableForeignKeyConstraints(); + } + + $io->success('Dropped ' . count($tables) . ' table(s).'); + } + + /** + * Run migrations and dispatch afterReset event. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code + */ + protected function runMigrationsAndDispatch(Arguments $args, ConsoleIo $io): ?int + { + $result = $this->runMigrations($args, $io); + $this->dispatchEvent('Migration.afterReset'); + + return $result; + } + + /** + * Run migrations. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code + */ + protected function runMigrations(Arguments $args, ConsoleIo $io): ?int + { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + 'dry-run' => (bool)$args->getOption('dry-run'), + ]); + + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + + $io->verbose('using connection ' . (string)$args->getOption('connection')); + $io->verbose('using paths ' . $config->getMigrationPath()); + + try { + $start = microtime(true); + $manager->migrate(null, false, null); + $end = microtime(true); + } catch (Throwable $e) { + $io->err('' . $e->getMessage() . ''); + $io->verbose($e->getTraceAsString()); + + return self::CODE_ERROR; + } + + $io->comment('All Done. Took ' . sprintf('%.4fs', $end - $start)); + $io->out(''); + + $exitCode = self::CODE_SUCCESS; + + // Run dump command to generate lock file + if (!$args->getOption('no-lock') && !$args->getOption('dry-run')) { + $io->verbose(''); + $io->verbose('Dumping the current schema of the database to be used while baking a diff'); + $io->verbose(''); + + $newArgs = DumpCommand::extractArgs($args); + $exitCode = $this->executeCommand(DumpCommand::class, $newArgs, $io); + } + + return $exitCode; + } +} diff --git a/src/Db/Adapter/AdapterInterface.php b/src/Db/Adapter/AdapterInterface.php index 0dad1ca3..c1c011ee 100644 --- a/src/Db/Adapter/AdapterInterface.php +++ b/src/Db/Adapter/AdapterInterface.php @@ -553,6 +553,26 @@ public function createTable(TableMetadata $table, array $columns = [], array $in */ public function truncateTable(string $tableName): void; + /** + * Disable foreign key constraint checking. + * + * This is useful when dropping tables or performing bulk operations + * that would otherwise fail due to foreign key constraints. + * + * @return void + */ + public function disableForeignKeyConstraints(): void; + + /** + * Enable foreign key constraint checking. + * + * This should be called after disableForeignKeyConstraints() to + * restore normal constraint checking behavior. + * + * @return void + */ + public function enableForeignKeyConstraints(): void; + /** * Returns table columns * diff --git a/src/Db/Adapter/AdapterWrapper.php b/src/Db/Adapter/AdapterWrapper.php index 597e9926..054f547f 100644 --- a/src/Db/Adapter/AdapterWrapper.php +++ b/src/Db/Adapter/AdapterWrapper.php @@ -590,4 +590,20 @@ public function getSchemaTableName(): string { return $this->getAdapter()->getSchemaTableName(); } + + /** + * @inheritDoc + */ + public function disableForeignKeyConstraints(): void + { + $this->getAdapter()->disableForeignKeyConstraints(); + } + + /** + * @inheritDoc + */ + public function enableForeignKeyConstraints(): void + { + $this->getAdapter()->enableForeignKeyConstraints(); + } } diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index 7e3f3872..e55d1884 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -560,6 +560,22 @@ public function truncateTable(string $tableName): void $this->execute($sql); } + /** + * @inheritDoc + */ + public function disableForeignKeyConstraints(): void + { + $this->execute('SET FOREIGN_KEY_CHECKS = 0'); + } + + /** + * @inheritDoc + */ + public function enableForeignKeyConstraints(): void + { + $this->execute('SET FOREIGN_KEY_CHECKS = 1'); + } + /** * Convert from cakephp/database conventions to migrations\column * diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index 31e77135..ea2f1ac2 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -333,7 +333,7 @@ protected function getRenameTableInstructions(string $tableName, string $newTabl protected function getDropTableInstructions(string $tableName): AlterInstructions { $this->removeCreatedTable($tableName); - $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + $sql = sprintf('DROP TABLE %s CASCADE', $this->quoteTableName($tableName)); return new AlterInstructions([], [$sql]); } @@ -351,6 +351,24 @@ public function truncateTable(string $tableName): void $this->execute($sql); } + /** + * @inheritDoc + */ + public function disableForeignKeyConstraints(): void + { + // PostgreSQL uses CASCADE on DROP TABLE instead of disabling FK checks. + // This method is a no-op for PostgreSQL since dropTable already uses CASCADE. + } + + /** + * @inheritDoc + */ + public function enableForeignKeyConstraints(): void + { + // PostgreSQL uses CASCADE on DROP TABLE instead of disabling FK checks. + // This method is a no-op for PostgreSQL. + } + /** * @inheritDoc */ diff --git a/src/Db/Adapter/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php index a0d599d9..28f6861a 100644 --- a/src/Db/Adapter/SqliteAdapter.php +++ b/src/Db/Adapter/SqliteAdapter.php @@ -408,6 +408,22 @@ public function truncateTable(string $tableName): void } } + /** + * @inheritDoc + */ + public function disableForeignKeyConstraints(): void + { + $this->execute('PRAGMA foreign_keys = OFF'); + } + + /** + * @inheritDoc + */ + public function enableForeignKeyConstraints(): void + { + $this->execute('PRAGMA foreign_keys = ON'); + } + /** * Parses a default-value expression to yield either a Literal representing * a string value, a string representing an expression, or some other scalar diff --git a/src/Db/Adapter/SqlserverAdapter.php b/src/Db/Adapter/SqlserverAdapter.php index d961e58b..0f500d67 100644 --- a/src/Db/Adapter/SqlserverAdapter.php +++ b/src/Db/Adapter/SqlserverAdapter.php @@ -280,6 +280,40 @@ public function truncateTable(string $tableName): void $this->execute($sql); } + /** + * @inheritDoc + */ + public function disableForeignKeyConstraints(): void + { + // SQL Server doesn't support disabling FK checks globally. + // We drop all foreign key constraints instead. + $sql = "SELECT + fk.name AS constraint_name, + SCHEMA_NAME(t.schema_id) AS schema_name, + t.name AS table_name + FROM sys.foreign_keys fk + INNER JOIN sys.tables t ON fk.parent_object_id = t.object_id + WHERE SCHEMA_NAME(t.schema_id) = ?"; + + $rows = $this->query($sql, [$this->schema])->fetchAll('assoc'); + + foreach ($rows as $row) { + $constraintName = $this->quoteColumnName($row['constraint_name']); + $tableName = $this->quoteTableName($row['table_name']); + $this->execute("ALTER TABLE {$tableName} DROP CONSTRAINT {$constraintName}"); + } + } + + /** + * @inheritDoc + */ + public function enableForeignKeyConstraints(): void + { + // SQL Server FK constraints were dropped, not disabled. + // They would need to be recreated, but after a reset/drop operation + // the tables will be recreated with their constraints by migrations. + } + /** * @param string $tableName Table name * @param ?string $columnName Column name diff --git a/src/MigrationsPlugin.php b/src/MigrationsPlugin.php index b627f1cd..f0fbded8 100644 --- a/src/MigrationsPlugin.php +++ b/src/MigrationsPlugin.php @@ -26,6 +26,7 @@ use Migrations\Command\EntryCommand; use Migrations\Command\MarkMigratedCommand; use Migrations\Command\MigrateCommand; +use Migrations\Command\ResetCommand; use Migrations\Command\RollbackCommand; use Migrations\Command\SeedCommand; use Migrations\Command\SeedResetCommand; @@ -73,6 +74,7 @@ public function console(CommandCollection $commands): CommandCollection DumpCommand::class, MarkMigratedCommand::class, MigrateCommand::class, + ResetCommand::class, RollbackCommand::class, StatusCommand::class, ]; diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index b05f0a4d..78520c4f 100644 --- a/tests/TestCase/Command/CompletionTest.php +++ b/tests/TestCase/Command/CompletionTest.php @@ -47,11 +47,11 @@ public function testMigrationsSubcommands() // Upgrade command is hidden when legacyTables is disabled if (Configure::read('Migrations.legacyTables') === false) { $expected = [ - 'dump mark_migrated migrate rollback status', + 'dump mark_migrated migrate reset rollback status', ]; } else { $expected = [ - 'dump mark_migrated migrate rollback status upgrade', + 'dump mark_migrated migrate reset rollback status upgrade', ]; } $actual = $this->_out->messages(); diff --git a/tests/TestCase/Command/ResetCommandTest.php b/tests/TestCase/Command/ResetCommandTest.php new file mode 100644 index 00000000..81a713ea --- /dev/null +++ b/tests/TestCase/Command/ResetCommandTest.php @@ -0,0 +1,183 @@ +clearMigrationRecords('test'); + $this->clearMigrationRecords('test', 'Migrator'); + + // Reset event manager to avoid pollution from other tests + EventManager::instance()->off('Migration.beforeReset'); + EventManager::instance()->off('Migration.afterReset'); + } + + public function tearDown(): void + { + parent::tearDown(); + foreach ($this->createdFiles as $file) { + unlink($file); + } + + // Clean up event listeners + EventManager::instance()->off('Migration.beforeReset'); + EventManager::instance()->off('Migration.afterReset'); + } + + protected function resetOutput(): void + { + if ($this->_out) { + $property = new ReflectionProperty($this->_out, '_out'); + $property->setValue($this->_out, []); + } + $this->_in = null; + } + + public function testHelp(): void + { + $this->exec('migrations reset --help'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Drop all tables and re-run all migrations'); + $this->assertOutputContains('destructive operation'); + } + + public function testDryRunWithTables(): void + { + // The test db has fixture tables, so there will be tables to drop + $this->exec('migrations reset -c test --dry-run --no-lock'); + $this->assertExitSuccess(); + + $this->assertOutputContains('DRY-RUN mode enabled'); + $this->assertOutputContains('DRY-RUN: Would drop'); + $this->assertOutputContains('DRY-RUN: Would re-run all migrations'); + } + + public function testResetAborted(): void + { + // Test aborting the reset (fixture tables exist) + $this->exec('migrations reset -c test --no-lock', ['n']); + $this->assertExitSuccess(); + + $this->assertOutputContains('The following tables will be dropped'); + $this->assertErrorContains('Reset operation aborted'); + } + + public function testResetConfirmed(): void + { + // First run migrations to create some tables + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + // Test confirming the reset + $this->exec('migrations reset -c test --no-lock', ['y']); + $this->assertExitSuccess(); + + $this->assertOutputContains('The following tables will be dropped'); + $this->assertOutputContains('Dropped'); + $this->assertOutputContains('All Done'); + } + + public function testResetWithLock(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + + // First run migrations + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + // Test reset with lock file generation + $this->exec('migrations reset -c test', ['y']); + $this->assertExitSuccess(); + + $this->assertOutputContains('All Done'); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->createdFiles[] = $dumpFile; + $this->assertFileExists($dumpFile); + } + + public function testEventsFired(): void + { + // First create something to reset + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + // Clean up any existing listeners before registering test listeners + EventManager::instance()->off('Migration.beforeReset'); + EventManager::instance()->off('Migration.afterReset'); + + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeReset', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + EventManager::instance()->on('Migration.afterReset', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + + $this->exec('migrations reset -c test --no-lock', ['y']); + $this->assertExitSuccess(); + $this->assertSame(['Migration.beforeReset', 'Migration.afterReset'], $fired); + } + + public function testBeforeResetEventAbort(): void + { + // First create something to reset + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeReset', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + $event->stopPropagation(); + $event->setResult(0); + }); + EventManager::instance()->on('Migration.afterReset', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + + $this->exec('migrations reset -c test --no-lock', ['y']); + $this->assertExitError(); + + // Only one event was fired + $this->assertSame(['Migration.beforeReset'], $fired); + } + + public function testResetWithPlugin(): void + { + $this->loadPlugins(['Migrator']); + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $connection->execute('DROP TABLE IF EXISTS migrator'); + + // Run plugin migrations + $this->exec('migrations migrate -c test --plugin Migrator --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + // Reset with plugin option + $this->exec('migrations reset -c test --plugin Migrator --no-lock', ['y']); + $this->assertExitSuccess(); + + $this->assertOutputContains('All Done'); + } +} diff --git a/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php b/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php index e92a5965..59ecb01f 100644 --- a/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php +++ b/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php @@ -209,4 +209,12 @@ protected function getDropTriggerInstructions(string $tableName, string $trigger { return new AlterInstructions(); } + + public function disableForeignKeyConstraints(): void + { + } + + public function enableForeignKeyConstraints(): void + { + } }