From beb388126dce79fa43461a61ff57e5b00cd19f13 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 12 Mar 2026 11:20:36 +0100 Subject: [PATCH 1/8] Add migrations reset command for development workflow Implements the "nuclear option" for migrations as discussed in #972: - Drops all application tables (excluding migration tracking tables) - Re-runs all migrations from scratch - Requires interactive Y/N confirmation for safety (default: N) - Supports --dry-run mode to preview changes - Dispatches Migration.beforeReset and Migration.afterReset events This is useful during development when you want a fresh database without manually rolling back or dropping tables. Refs #972 --- src/Command/ResetCommand.php | 349 ++++++++++++++++++++ src/MigrationsPlugin.php | 2 + tests/TestCase/Command/ResetCommandTest.php | 183 ++++++++++ 3 files changed, 534 insertions(+) create mode 100644 src/Command/ResetCommand.php create mode 100644 tests/TestCase/Command/ResetCommandTest.php diff --git a/src/Command/ResetCommand.php b/src/Command/ResetCommand.php new file mode 100644 index 00000000..4903cf90 --- /dev/null +++ b/src/Command/ResetCommand.php @@ -0,0 +1,349 @@ + + */ + use EventDispatcherTrait; + + /** + * Tables that should never be dropped. + * + * @var array + */ + protected array $protectedTables = [ + 'cake_migrations', + 'cake_seeds', + 'phinxlog', + 'sessions', + ]; + + /** + * 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...'); + + $result = $this->runMigrations($args, $io); + $this->dispatchEvent('Migration.afterReset'); + + return $result; + } + + // 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) { + $this->dropTables($connection, $tablesToDrop, $io); + /** @var string|null $plugin */ + $plugin = $args->getOption('plugin'); + $this->clearMigrationRecords($connection, $plugin, $io); + } else { + $io->info('DRY-RUN: Would drop ' . count($tablesToDrop) . ' table(s).'); + } + + $io->out(''); + + // Re-run migrations + if (!$dryRun) { + $result = $this->runMigrations($args, $io); + $this->dispatchEvent('Migration.afterReset'); + + return $result; + } + + $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(); + $tables = $schema->listTables(); + + // Filter out protected tables + $tablesToDrop = []; + foreach ($tables as $table) { + // Skip migration tracking tables + if (in_array($table, $this->protectedTables, true)) { + continue; + } + // Skip plugin phinxlog tables + if (str_ends_with($table, '_phinxlog')) { + continue; + } + $tablesToDrop[] = $table; + } + + return $tablesToDrop; + } + + /** + * Drop tables with foreign key handling. + * + * @param \Cake\Database\Connection $connection Database connection + * @param array $tables Tables to drop + * @param \Cake\Console\ConsoleIo $io Console IO + * @return void + */ + protected function dropTables(Connection $connection, array $tables, ConsoleIo $io): void + { + $driver = $connection->getDriver(); + + // Disable foreign key checks temporarily + $this->setForeignKeyChecks($connection, false); + + try { + foreach ($tables as $table) { + $quotedTable = $driver->quoteIdentifier($table); + $io->verbose("Dropping table: {$table}"); + $connection->execute("DROP TABLE IF EXISTS {$quotedTable}"); + } + } finally { + // Re-enable foreign key checks + $this->setForeignKeyChecks($connection, true); + } + + $io->success('Dropped ' . count($tables) . ' table(s).'); + } + + /** + * Enable or disable foreign key checks. + * + * @param \Cake\Database\Connection $connection Database connection + * @param bool $enable Whether to enable or disable + * @return void + */ + protected function setForeignKeyChecks(Connection $connection, bool $enable): void + { + $driver = $connection->getDriver(); + $driverClass = get_class($driver); + + if (str_contains($driverClass, 'Mysql')) { + $connection->execute('SET FOREIGN_KEY_CHECKS = ' . ($enable ? '1' : '0')); + } elseif (str_contains($driverClass, 'Postgres')) { + // PostgreSQL handles this per-session via constraints + // We'll use CASCADE in the DROP statement instead + } elseif (str_contains($driverClass, 'Sqlite')) { + $connection->execute('PRAGMA foreign_keys = ' . ($enable ? 'ON' : 'OFF')); + } elseif (str_contains($driverClass, 'Sqlserver')) { + // SQL Server doesn't have a global toggle, handled differently + } + } + + /** + * Clear migration records from the tracking table. + * + * @param \Cake\Database\Connection $connection Database connection + * @param string|null $plugin Plugin name + * @param \Cake\Console\ConsoleIo $io Console IO + * @return void + */ + protected function clearMigrationRecords(Connection $connection, ?string $plugin, ConsoleIo $io): void + { + $schema = $connection->getDriver()->schemaDialect(); + + // Clear unified table if exists + if ($schema->hasTable('cake_migrations')) { + $query = $connection->deleteQuery()->delete('cake_migrations'); + if ($plugin !== null) { + $query->where(['plugin' => $plugin]); + } + $query->execute(); + $io->verbose('Cleared migration records from cake_migrations'); + } + + // Clear legacy phinxlog table if exists + $legacyTable = $plugin ? strtolower($plugin) . '_phinxlog' : 'phinxlog'; + if ($schema->hasTable($legacyTable)) { + $connection->deleteQuery() + ->delete($legacyTable) + ->execute(); + $io->verbose("Cleared migration records from {$legacyTable}"); + } + } + + /** + * 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/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/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'); + } +} From d76d41f089b1615837ac0245d4d7d0e740045fa8 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 12 Mar 2026 11:25:11 +0100 Subject: [PATCH 2/8] Fix foreign key handling for PostgreSQL and SQL Server - PostgreSQL: Use CASCADE in DROP TABLE statement - SQL Server: Drop foreign key constraints first before dropping tables - MySQL/SQLite: Continue using session-level FK check toggle --- src/Command/ResetCommand.php | 73 ++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/src/Command/ResetCommand.php b/src/Command/ResetCommand.php index 4903cf90..a5f897b3 100644 --- a/src/Command/ResetCommand.php +++ b/src/Command/ResetCommand.php @@ -222,24 +222,80 @@ protected function getTablesToDrop(Connection $connection): array protected function dropTables(Connection $connection, array $tables, ConsoleIo $io): void { $driver = $connection->getDriver(); + $driverClass = get_class($driver); - // Disable foreign key checks temporarily - $this->setForeignKeyChecks($connection, false); + // For PostgreSQL and SQL Server, we need to drop foreign keys first + // or use CASCADE in the drop statement + if (str_contains($driverClass, 'Postgres')) { + foreach ($tables as $table) { + $quotedTable = $driver->quoteIdentifier($table); + $io->verbose("Dropping table: {$table}"); + $connection->execute("DROP TABLE IF EXISTS {$quotedTable} CASCADE"); + } + } elseif (str_contains($driverClass, 'Sqlserver')) { + // Drop all foreign key constraints first + $this->dropForeignKeyConstraints($connection, $tables, $io); - try { + // Then drop tables foreach ($tables as $table) { $quotedTable = $driver->quoteIdentifier($table); $io->verbose("Dropping table: {$table}"); $connection->execute("DROP TABLE IF EXISTS {$quotedTable}"); } - } finally { - // Re-enable foreign key checks - $this->setForeignKeyChecks($connection, true); + } else { + // MySQL and SQLite support disabling foreign key checks + $this->setForeignKeyChecks($connection, false); + + try { + foreach ($tables as $table) { + $quotedTable = $driver->quoteIdentifier($table); + $io->verbose("Dropping table: {$table}"); + $connection->execute("DROP TABLE IF EXISTS {$quotedTable}"); + } + } finally { + $this->setForeignKeyChecks($connection, true); + } } $io->success('Dropped ' . count($tables) . ' table(s).'); } + /** + * Drop all foreign key constraints from the given tables. + * + * @param \Cake\Database\Connection $connection Database connection + * @param array $tables Tables to process + * @param \Cake\Console\ConsoleIo $io Console IO + * @return void + */ + protected function dropForeignKeyConstraints(Connection $connection, array $tables, ConsoleIo $io): void + { + $driver = $connection->getDriver(); + $driverClass = get_class($driver); + + if (!str_contains($driverClass, 'Sqlserver')) { + return; + } + + // Query to find all foreign key constraints on the specified tables + $tableList = implode("','", array_map(fn($t) => addslashes($t), $tables)); + + $sql = "SELECT + fk.name AS constraint_name, + OBJECT_NAME(fk.parent_object_id) AS table_name + FROM sys.foreign_keys fk + WHERE OBJECT_NAME(fk.parent_object_id) IN ('{$tableList}')"; + + $result = $connection->execute($sql)->fetchAll('assoc'); + + foreach ($result as $row) { + $constraintName = $driver->quoteIdentifier($row['constraint_name']); + $tableName = $driver->quoteIdentifier($row['table_name']); + $io->verbose("Dropping foreign key: {$row['constraint_name']} on {$row['table_name']}"); + $connection->execute("ALTER TABLE {$tableName} DROP CONSTRAINT {$constraintName}"); + } + } + /** * Enable or disable foreign key checks. * @@ -254,13 +310,8 @@ protected function setForeignKeyChecks(Connection $connection, bool $enable): vo if (str_contains($driverClass, 'Mysql')) { $connection->execute('SET FOREIGN_KEY_CHECKS = ' . ($enable ? '1' : '0')); - } elseif (str_contains($driverClass, 'Postgres')) { - // PostgreSQL handles this per-session via constraints - // We'll use CASCADE in the DROP statement instead } elseif (str_contains($driverClass, 'Sqlite')) { $connection->execute('PRAGMA foreign_keys = ' . ($enable ? 'ON' : 'OFF')); - } elseif (str_contains($driverClass, 'Sqlserver')) { - // SQL Server doesn't have a global toggle, handled differently } } From 0cb483f46286235a415c2da65d0088186c3e06fd Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 12 Mar 2026 11:28:29 +0100 Subject: [PATCH 3/8] Update completion test to include reset command --- tests/TestCase/Command/CompletionTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(); From b2c7f578b1d9743cdd2e9d8d03298de26ac3bab3 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 12 Mar 2026 12:58:50 +0100 Subject: [PATCH 4/8] Improve reset command logic - Remove sessions from protected tables (true nuclear option) - Rename protectedTables to trackingTables for clarity - Clear seed records (cake_seeds) along with migration records - Rename clearMigrationRecords to clearTrackingRecords --- src/Command/ResetCommand.php | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Command/ResetCommand.php b/src/Command/ResetCommand.php index a5f897b3..dd583478 100644 --- a/src/Command/ResetCommand.php +++ b/src/Command/ResetCommand.php @@ -37,15 +37,17 @@ class ResetCommand extends Command use EventDispatcherTrait; /** - * Tables that should never be dropped. + * Migration/seed tracking tables that should not be dropped. + * + * These tables are kept (structure preserved) but their contents + * are cleared so migrations can run fresh. * * @var array */ - protected array $protectedTables = [ + protected array $trackingTables = [ 'cake_migrations', 'cake_seeds', 'phinxlog', - 'sessions', ]; /** @@ -163,7 +165,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $this->dropTables($connection, $tablesToDrop, $io); /** @var string|null $plugin */ $plugin = $args->getOption('plugin'); - $this->clearMigrationRecords($connection, $plugin, $io); + $this->clearTrackingRecords($connection, $plugin, $io); } else { $io->info('DRY-RUN: Would drop ' . count($tablesToDrop) . ' table(s).'); } @@ -194,11 +196,11 @@ protected function getTablesToDrop(Connection $connection): array $schema = $connection->getDriver()->schemaDialect(); $tables = $schema->listTables(); - // Filter out protected tables + // Filter out migration/seed tracking tables $tablesToDrop = []; foreach ($tables as $table) { - // Skip migration tracking tables - if (in_array($table, $this->protectedTables, true)) { + // Skip migration/seed tracking tables (we clear their contents instead) + if (in_array($table, $this->trackingTables, true)) { continue; } // Skip plugin phinxlog tables @@ -316,18 +318,18 @@ protected function setForeignKeyChecks(Connection $connection, bool $enable): vo } /** - * Clear migration records from the tracking table. + * Clear migration and seed records from tracking tables. * * @param \Cake\Database\Connection $connection Database connection * @param string|null $plugin Plugin name * @param \Cake\Console\ConsoleIo $io Console IO * @return void */ - protected function clearMigrationRecords(Connection $connection, ?string $plugin, ConsoleIo $io): void + protected function clearTrackingRecords(Connection $connection, ?string $plugin, ConsoleIo $io): void { $schema = $connection->getDriver()->schemaDialect(); - // Clear unified table if exists + // Clear unified migrations table if exists if ($schema->hasTable('cake_migrations')) { $query = $connection->deleteQuery()->delete('cake_migrations'); if ($plugin !== null) { @@ -345,6 +347,14 @@ protected function clearMigrationRecords(Connection $connection, ?string $plugin ->execute(); $io->verbose("Cleared migration records from {$legacyTable}"); } + + // Clear seed tracking table if exists + if ($schema->hasTable('cake_seeds')) { + $connection->deleteQuery() + ->delete('cake_seeds') + ->execute(); + $io->verbose('Cleared seed records from cake_seeds'); + } } /** From 746421fc64dd7f9edee4126559ebbda30689fca5 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 12 Mar 2026 13:14:01 +0100 Subject: [PATCH 5/8] Simplify reset command to drop all tables Instead of preserving tracking tables and clearing their records, just drop everything and let migrations recreate the tables. --- src/Command/ResetCommand.php | 74 +----------------------------------- 1 file changed, 1 insertion(+), 73 deletions(-) diff --git a/src/Command/ResetCommand.php b/src/Command/ResetCommand.php index dd583478..af94d3a4 100644 --- a/src/Command/ResetCommand.php +++ b/src/Command/ResetCommand.php @@ -36,20 +36,6 @@ class ResetCommand extends Command */ use EventDispatcherTrait; - /** - * Migration/seed tracking tables that should not be dropped. - * - * These tables are kept (structure preserved) but their contents - * are cleared so migrations can run fresh. - * - * @var array - */ - protected array $trackingTables = [ - 'cake_migrations', - 'cake_seeds', - 'phinxlog', - ]; - /** * The default name added to the application command list * @@ -163,9 +149,6 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $io->out(''); if (!$dryRun) { $this->dropTables($connection, $tablesToDrop, $io); - /** @var string|null $plugin */ - $plugin = $args->getOption('plugin'); - $this->clearTrackingRecords($connection, $plugin, $io); } else { $io->info('DRY-RUN: Would drop ' . count($tablesToDrop) . ' table(s).'); } @@ -194,23 +177,8 @@ public function execute(Arguments $args, ConsoleIo $io): ?int protected function getTablesToDrop(Connection $connection): array { $schema = $connection->getDriver()->schemaDialect(); - $tables = $schema->listTables(); - - // Filter out migration/seed tracking tables - $tablesToDrop = []; - foreach ($tables as $table) { - // Skip migration/seed tracking tables (we clear their contents instead) - if (in_array($table, $this->trackingTables, true)) { - continue; - } - // Skip plugin phinxlog tables - if (str_ends_with($table, '_phinxlog')) { - continue; - } - $tablesToDrop[] = $table; - } - return $tablesToDrop; + return $schema->listTables(); } /** @@ -317,46 +285,6 @@ protected function setForeignKeyChecks(Connection $connection, bool $enable): vo } } - /** - * Clear migration and seed records from tracking tables. - * - * @param \Cake\Database\Connection $connection Database connection - * @param string|null $plugin Plugin name - * @param \Cake\Console\ConsoleIo $io Console IO - * @return void - */ - protected function clearTrackingRecords(Connection $connection, ?string $plugin, ConsoleIo $io): void - { - $schema = $connection->getDriver()->schemaDialect(); - - // Clear unified migrations table if exists - if ($schema->hasTable('cake_migrations')) { - $query = $connection->deleteQuery()->delete('cake_migrations'); - if ($plugin !== null) { - $query->where(['plugin' => $plugin]); - } - $query->execute(); - $io->verbose('Cleared migration records from cake_migrations'); - } - - // Clear legacy phinxlog table if exists - $legacyTable = $plugin ? strtolower($plugin) . '_phinxlog' : 'phinxlog'; - if ($schema->hasTable($legacyTable)) { - $connection->deleteQuery() - ->delete($legacyTable) - ->execute(); - $io->verbose("Cleared migration records from {$legacyTable}"); - } - - // Clear seed tracking table if exists - if ($schema->hasTable('cake_seeds')) { - $connection->deleteQuery() - ->delete('cake_seeds') - ->execute(); - $io->verbose('Cleared seed records from cake_seeds'); - } - } - /** * Run migrations. * From ceaf9279a9691fc532d0f8d2a401f0e67f38e340 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 14 Mar 2026 04:40:14 +0100 Subject: [PATCH 6/8] Use instanceof for driver type checking and extract helper method - Replace string operations on class names with instanceof checks - Extract runMigrationsAndDispatch() to reduce code duplication --- src/Command/ResetCommand.php | 42 ++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/Command/ResetCommand.php b/src/Command/ResetCommand.php index af94d3a4..2607f2d4 100644 --- a/src/Command/ResetCommand.php +++ b/src/Command/ResetCommand.php @@ -18,6 +18,10 @@ use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; use Cake\Database\Connection; +use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Postgres; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Driver\Sqlserver; use Cake\Datasource\ConnectionManager; use Cake\Event\EventDispatcherTrait; use Migrations\Config\ConfigInterface; @@ -118,10 +122,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $io->out(''); $io->out('Running migrations...'); - $result = $this->runMigrations($args, $io); - $this->dispatchEvent('Migration.afterReset'); - - return $result; + return $this->runMigrationsAndDispatch($args, $io); } // Show what will be dropped @@ -157,10 +158,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int // Re-run migrations if (!$dryRun) { - $result = $this->runMigrations($args, $io); - $this->dispatchEvent('Migration.afterReset'); - - return $result; + return $this->runMigrationsAndDispatch($args, $io); } $io->info('DRY-RUN: Would re-run all migrations.'); @@ -192,17 +190,16 @@ protected function getTablesToDrop(Connection $connection): array protected function dropTables(Connection $connection, array $tables, ConsoleIo $io): void { $driver = $connection->getDriver(); - $driverClass = get_class($driver); // For PostgreSQL and SQL Server, we need to drop foreign keys first // or use CASCADE in the drop statement - if (str_contains($driverClass, 'Postgres')) { + if ($driver instanceof Postgres) { foreach ($tables as $table) { $quotedTable = $driver->quoteIdentifier($table); $io->verbose("Dropping table: {$table}"); $connection->execute("DROP TABLE IF EXISTS {$quotedTable} CASCADE"); } - } elseif (str_contains($driverClass, 'Sqlserver')) { + } elseif ($driver instanceof Sqlserver) { // Drop all foreign key constraints first $this->dropForeignKeyConstraints($connection, $tables, $io); @@ -241,9 +238,8 @@ protected function dropTables(Connection $connection, array $tables, ConsoleIo $ protected function dropForeignKeyConstraints(Connection $connection, array $tables, ConsoleIo $io): void { $driver = $connection->getDriver(); - $driverClass = get_class($driver); - if (!str_contains($driverClass, 'Sqlserver')) { + if (!$driver instanceof Sqlserver) { return; } @@ -276,15 +272,29 @@ protected function dropForeignKeyConstraints(Connection $connection, array $tabl protected function setForeignKeyChecks(Connection $connection, bool $enable): void { $driver = $connection->getDriver(); - $driverClass = get_class($driver); - if (str_contains($driverClass, 'Mysql')) { + if ($driver instanceof Mysql) { $connection->execute('SET FOREIGN_KEY_CHECKS = ' . ($enable ? '1' : '0')); - } elseif (str_contains($driverClass, 'Sqlite')) { + } elseif ($driver instanceof Sqlite) { $connection->execute('PRAGMA foreign_keys = ' . ($enable ? 'ON' : 'OFF')); } } + /** + * 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. * From 8d5b0994ea4314fbda46d3425d936f159395ac29 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 15 Mar 2026 04:00:45 +0100 Subject: [PATCH 7/8] Move FK constraint handling to adapter layer - Add disableForeignKeyConstraints/enableForeignKeyConstraints to AdapterInterface - Implement FK methods in MysqlAdapter (SET FOREIGN_KEY_CHECKS) - Implement FK methods in SqliteAdapter (PRAGMA foreign_keys) - Implement FK methods in SqlserverAdapter (drop all FK constraints) - PostgresAdapter uses no-op methods with CASCADE in dropTable - Add FK methods to AdapterWrapper for delegation - Refactor ResetCommand to use adapter methods instead of dialect-specific code --- src/Command/ResetCommand.php | 110 ++++++---------------------- src/Db/Adapter/AdapterInterface.php | 20 +++++ src/Db/Adapter/AdapterWrapper.php | 16 ++++ src/Db/Adapter/MysqlAdapter.php | 16 ++++ src/Db/Adapter/PostgresAdapter.php | 20 ++++- src/Db/Adapter/SqliteAdapter.php | 16 ++++ src/Db/Adapter/SqlserverAdapter.php | 34 +++++++++ 7 files changed, 143 insertions(+), 89 deletions(-) diff --git a/src/Command/ResetCommand.php b/src/Command/ResetCommand.php index 2607f2d4..db006735 100644 --- a/src/Command/ResetCommand.php +++ b/src/Command/ResetCommand.php @@ -18,14 +18,13 @@ use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; use Cake\Database\Connection; -use Cake\Database\Driver\Mysql; -use Cake\Database\Driver\Postgres; -use Cake\Database\Driver\Sqlite; -use Cake\Database\Driver\Sqlserver; use Cake\Datasource\ConnectionManager; use Cake\Event\EventDispatcherTrait; use Migrations\Config\ConfigInterface; +use Migrations\Db\Adapter\AdapterInterface; +use Migrations\Db\Adapter\DirectActionInterface; use Migrations\Migration\ManagerFactory; +use RuntimeException; use Throwable; /** @@ -149,7 +148,15 @@ public function execute(Arguments $args, ConsoleIo $io): ?int // Drop tables $io->out(''); if (!$dryRun) { - $this->dropTables($connection, $tablesToDrop, $io); + $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).'); } @@ -182,104 +189,31 @@ protected function getTablesToDrop(Connection $connection): array /** * Drop tables with foreign key handling. * - * @param \Cake\Database\Connection $connection Database connection + * @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(Connection $connection, array $tables, ConsoleIo $io): void + protected function dropTables(AdapterInterface $adapter, array $tables, ConsoleIo $io): void { - $driver = $connection->getDriver(); + if (!$adapter instanceof DirectActionInterface) { + throw new RuntimeException('The adapter must implement DirectActionInterface'); + } - // For PostgreSQL and SQL Server, we need to drop foreign keys first - // or use CASCADE in the drop statement - if ($driver instanceof Postgres) { - foreach ($tables as $table) { - $quotedTable = $driver->quoteIdentifier($table); - $io->verbose("Dropping table: {$table}"); - $connection->execute("DROP TABLE IF EXISTS {$quotedTable} CASCADE"); - } - } elseif ($driver instanceof Sqlserver) { - // Drop all foreign key constraints first - $this->dropForeignKeyConstraints($connection, $tables, $io); + $adapter->disableForeignKeyConstraints(); - // Then drop tables + try { foreach ($tables as $table) { - $quotedTable = $driver->quoteIdentifier($table); $io->verbose("Dropping table: {$table}"); - $connection->execute("DROP TABLE IF EXISTS {$quotedTable}"); - } - } else { - // MySQL and SQLite support disabling foreign key checks - $this->setForeignKeyChecks($connection, false); - - try { - foreach ($tables as $table) { - $quotedTable = $driver->quoteIdentifier($table); - $io->verbose("Dropping table: {$table}"); - $connection->execute("DROP TABLE IF EXISTS {$quotedTable}"); - } - } finally { - $this->setForeignKeyChecks($connection, true); + $adapter->dropTable($table); } + } finally { + $adapter->enableForeignKeyConstraints(); } $io->success('Dropped ' . count($tables) . ' table(s).'); } - /** - * Drop all foreign key constraints from the given tables. - * - * @param \Cake\Database\Connection $connection Database connection - * @param array $tables Tables to process - * @param \Cake\Console\ConsoleIo $io Console IO - * @return void - */ - protected function dropForeignKeyConstraints(Connection $connection, array $tables, ConsoleIo $io): void - { - $driver = $connection->getDriver(); - - if (!$driver instanceof Sqlserver) { - return; - } - - // Query to find all foreign key constraints on the specified tables - $tableList = implode("','", array_map(fn($t) => addslashes($t), $tables)); - - $sql = "SELECT - fk.name AS constraint_name, - OBJECT_NAME(fk.parent_object_id) AS table_name - FROM sys.foreign_keys fk - WHERE OBJECT_NAME(fk.parent_object_id) IN ('{$tableList}')"; - - $result = $connection->execute($sql)->fetchAll('assoc'); - - foreach ($result as $row) { - $constraintName = $driver->quoteIdentifier($row['constraint_name']); - $tableName = $driver->quoteIdentifier($row['table_name']); - $io->verbose("Dropping foreign key: {$row['constraint_name']} on {$row['table_name']}"); - $connection->execute("ALTER TABLE {$tableName} DROP CONSTRAINT {$constraintName}"); - } - } - - /** - * Enable or disable foreign key checks. - * - * @param \Cake\Database\Connection $connection Database connection - * @param bool $enable Whether to enable or disable - * @return void - */ - protected function setForeignKeyChecks(Connection $connection, bool $enable): void - { - $driver = $connection->getDriver(); - - if ($driver instanceof Mysql) { - $connection->execute('SET FOREIGN_KEY_CHECKS = ' . ($enable ? '1' : '0')); - } elseif ($driver instanceof Sqlite) { - $connection->execute('PRAGMA foreign_keys = ' . ($enable ? 'ON' : 'OFF')); - } - } - /** * Run migrations and dispatch afterReset event. * 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 From 48ddf9efa7def8729afe16265ae7e97243235f3b Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 15 Mar 2026 04:03:05 +0100 Subject: [PATCH 8/8] Add FK constraint methods to test trait --- tests/TestCase/Db/Adapter/DefaultAdapterTrait.php | 8 ++++++++ 1 file changed, 8 insertions(+) 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 + { + } }