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
+ {
+ }
}