diff --git a/src/CakeManager.php b/src/CakeManager.php index 8c9db2cc..b28096ec 100644 --- a/src/CakeManager.php +++ b/src/CakeManager.php @@ -71,7 +71,7 @@ public function printStatus(string $environment, ?string $format = null): array $migrations = []; $isJson = $format === 'json'; $defaultMigrations = $this->getMigrations('default'); - if (count($defaultMigrations)) { + if ($defaultMigrations) { $env = $this->getEnvironment($environment); $versions = $env->getVersionLog(); $this->maxNameLength = $versions ? max(array_map(function ($version) { diff --git a/src/Command/MigrateCommand.php b/src/Command/MigrateCommand.php index 6c567f63..426645e8 100644 --- a/src/Command/MigrateCommand.php +++ b/src/Command/MigrateCommand.php @@ -20,6 +20,7 @@ use Cake\Event\EventDispatcherTrait; use DateTime; use Exception; +use LogicException; use Migrations\Config\ConfigInterface; use Migrations\Migration\ManagerFactory; use Throwable; @@ -126,9 +127,15 @@ protected function executeMigrations(Arguments $args, ConsoleIo $io): ?int $date = $args->getOption('date'); $fake = (bool)$args->getOption('fake'); - $count = $args->getOption('count'); - if ($count) { - $io->abort('The `--count` option is not supported yet in this command. Use `--target` instead.'); + $count = $args->getOption('count') !== null ? (int)$args->getOption('count') : null; + if ($count !== null && $count < 1) { + throw new LogicException('Count must be > 0.'); + } + if ($count && $date) { + throw new LogicException('Can only use one of `--count` or `--date` options at a time.'); + } + if ($version && $date) { + throw new LogicException('Can only use one of `--version` or `--date` options at a time.'); } $factory = new ManagerFactory([ @@ -160,7 +167,7 @@ protected function executeMigrations(Arguments $args, ConsoleIo $io): ?int if ($date !== null) { $manager->migrateToDateTime(new DateTime((string)$date), $fake); } else { - $manager->migrate($version, $fake); + $manager->migrate($version, $fake, $count); } $end = microtime(true); } catch (Exception $e) { diff --git a/src/Command/RollbackCommand.php b/src/Command/RollbackCommand.php index feda38a1..de33c27a 100644 --- a/src/Command/RollbackCommand.php +++ b/src/Command/RollbackCommand.php @@ -21,6 +21,7 @@ use DateTime; use Exception; use InvalidArgumentException; +use LogicException; use Migrations\Config\ConfigInterface; use Migrations\Migration\ManagerFactory; use Throwable; @@ -77,6 +78,9 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar ])->addOption('date', [ 'short' => 'd', 'help' => 'The date to rollback to', + ])->addOption('count', [ + 'short' => 'k', + 'help' => 'The number of migrations to rollback', ])->addOption('fake', [ 'help' => "Mark any migrations selected as run, but don't actually execute them", 'boolean' => true, @@ -130,6 +134,17 @@ protected function executeMigrations(Arguments $args, ConsoleIo $io): ?int $force = (bool)$args->getOption('force'); $dryRun = (bool)$args->getOption('dry-run'); + $count = $args->getOption('count') !== null ? (int)$args->getOption('count') : null; + if ($count !== null && $count < 1) { + throw new LogicException('Count must be > 0.'); + } + if ($count && $date) { + throw new LogicException('Can only use one of `--count` or `--date` options at a time.'); + } + if ($version && $date) { + throw new LogicException('Can only use one of `--version` or `--date` options at a time.'); + } + $factory = new ManagerFactory([ 'plugin' => $args->getOption('plugin'), 'source' => $args->getOption('source'), @@ -162,7 +177,11 @@ protected function executeMigrations(Arguments $args, ConsoleIo $io): ?int try { // run the migrations $start = microtime(true); - $manager->rollback($target, $force, $targetMustMatch, $fake); + if ($count) { + $manager->rollbackByCount($count, $force, $fake); + } else { + $manager->rollback($target, $force, $targetMustMatch, $fake); + } $end = microtime(true); } catch (Exception $e) { $io->err('' . $e->getMessage() . ''); diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 9d41d18d..37bdc9b4 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -82,7 +82,7 @@ public function printStatus(?string $format = null): array $migrations = []; $isJson = $format === 'json'; $defaultMigrations = $this->getMigrations(); - if (count($defaultMigrations)) { + if ($defaultMigrations) { $env = $this->getEnvironment(); $versions = $env->getVersionLog(); @@ -361,13 +361,14 @@ public function markVersionsAsMigrated(string $path, array $versions): array } /** - * Migrate an environment to the specified version. + * Migrate an environment to the specified version or by count of migrations. * * @param int|null $version version to migrate to * @param bool $fake flag that if true, we just record running the migration, but not actually do the migration + * @param int|null $count Number of migrations to run, all migrations will be run if not set and no version is given. * @return void */ - public function migrate(?int $version = null, bool $fake = false): void + public function migrate(?int $version = null, bool $fake = false, ?int $count = null): void { $migrations = $this->getMigrations(); $env = $this->getEnvironment(); @@ -409,13 +410,15 @@ public function migrate(?int $version = null, bool $fake = false): void } ksort($migrations); + $done = 0; foreach ($migrations as $migration) { - if ($migration->getVersion() > $version) { + if ($migration->getVersion() > $version || ($count && $done >= $count)) { break; } if (!in_array($migration->getVersion(), $versions)) { $this->executeMigration($migration, MigrationInterface::UP, $fake); + $done++; } } } @@ -535,6 +538,38 @@ protected function printStatusOutput(string $name, string $status, ?string $dura ); } + /** + * Rollback an environment by a specific count of migrations. + * + * Note: If the count is greater than the number of migrations, it will rollback all migrations. + * + * @param int $count Count + * @param bool $force Force + * @param bool $fake Flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function rollbackByCount(int $count, bool $force = false, bool $fake = false): void + { + // note that the version log are also indexed by name with the proper ascending order according to the version order + $executedVersions = $this->getEnvironment()->getVersionLog(); + + $total = count($executedVersions); + $pos = 0; + while ($pos < $count && $pos < $total) { + array_pop($executedVersions); + $pos++; + } + + if ($executedVersions) { + $last = end($executedVersions); + $target = $last['version']; + } else { + $target = 0; + } + + $this->rollback($target, $force, false, $fake); + } + /** * Rollback an environment to the specified version. * diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index 262456dd..46d8405d 100644 --- a/tests/TestCase/Command/CompletionTest.php +++ b/tests/TestCase/Command/CompletionTest.php @@ -103,7 +103,7 @@ public function testMigrationsOptionsRollback() $this->exec('completion options migrations.migrations rollback'); $this->assertCount(1, $this->_out->messages()); $output = $this->_out->messages()[0]; - $expected = '--connection -c --date -d --dry-run -x --fake --force -f --help -h --no-lock --plugin -p'; + $expected = '--connection -c --count -k --date -d --dry-run -x --fake --force -f --help -h --no-lock --plugin -p'; $expected .= ' --quiet -q --source -s --target -t --verbose -v'; $outputExplode = explode(' ', trim($output)); sort($outputExplode); diff --git a/tests/TestCase/Migration/ManagerTest.php b/tests/TestCase/Migration/ManagerTest.php index ae9db1db..26035d24 100644 --- a/tests/TestCase/Migration/ManagerTest.php +++ b/tests/TestCase/Migration/ManagerTest.php @@ -16,6 +16,7 @@ use Phinx\Console\Command\AbstractCommand; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionClass; use RuntimeException; class ManagerTest extends TestCase @@ -678,6 +679,63 @@ public function testMigrationsByDate(array $availableMigrations, $dateString, $e } } + /** + * Test that migrating by date chooses the correct + * migration to point to. + * + * @param string[] $availableMigrations + * @param int $count + * @param string $expectedMigration + * @param string $message + */ + #[DataProvider('migrateByCountDataProvider')] + public function testMigrationsByCount(array $availableMigrations, $count, $expectedMigration, $message) + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + + // getVersions returns already executed migrations, so it should be empty for new migrations + $envStub->expects($this->any()) + ->method('getVersions') + ->willReturn([]); + + // getCurrentVersion returns 0 when no migrations have been run + $envStub->expects($this->any()) + ->method('getCurrentVersion') + ->willReturn(0); + + // Mock getMigrations to return the available migrations + $migrations = []; + foreach ($availableMigrations as $version) { + $migration = $this->getMockBuilder('\Migrations\MigrationInterface') + ->getMock(); + $migration->expects($this->any()) + ->method('getVersion') + ->willReturn((int)$version); + $migration->expects($this->any()) + ->method('getName') + ->willReturn('TestMigration'); + $migrations[$version] = $migration; + } + + // Use reflection to set the migrations property + $reflection = new ReflectionClass($this->manager); + $migrationsProperty = $reflection->getProperty('migrations'); + $migrationsProperty->setAccessible(true); + $migrationsProperty->setValue($this->manager, $migrations); + + $this->manager->setEnvironment($envStub); + $this->manager->migrate(null, false, $count); + $output = $this->getOutput(); + if (is_null($expectedMigration)) { + $this->assertEmpty($output, $message); + } else { + $this->assertStringContainsString($expectedMigration, $output, $message); + } + } + /** * Test that rollbacking to version chooses the correct * migration to point to. @@ -742,6 +800,60 @@ public function testRollbackToDate($availableRollbacks, $version, $expectedOutpu } } + /** + * Test that rollbacking with count stops at the right migration. + */ + #[DataProvider('rollbackByCountDataProvider')] + public function testRollbackByCount($availableRollbacks, $count, $expectedOutput) + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->willReturn($availableRollbacks); + + // Mock getMigrations to return migrations matching the version log + $migrations = []; + foreach ($availableRollbacks as $version => $details) { + $migration = $this->getMockBuilder('\Migrations\MigrationInterface') + ->getMock(); + $migration->expects($this->any()) + ->method('getVersion') + ->willReturn((int)$version); + $migration->expects($this->any()) + ->method('getName') + ->willReturn($details['migration_name'] ?: 'TestMigration'); + $migration->expects($this->any()) + ->method('shouldExecute') + ->willReturn(true); + $migrations[$version] = $migration; + } + + // Use reflection to set the migrations property + $reflection = new ReflectionClass($this->manager); + $migrationsProperty = $reflection->getProperty('migrations'); + $migrationsProperty->setAccessible(true); + $migrationsProperty->setValue($this->manager, $migrations); + + $this->manager->setEnvironment($envStub); + $this->manager->rollbackByCount($count); + $output = $this->getOutput(); + if (is_null($expectedOutput)) { + $output = explode("\n", $output); + $this->assertEquals('No migrations to rollback', array_pop($output)); + } else { + if (is_string($expectedOutput)) { + $expectedOutput = [$expectedOutput]; + } + + foreach ($expectedOutput as $expectedLine) { + $this->assertStringContainsString($expectedLine, $output); + } + } + } + /** * Test that rollbacking to version by execution time chooses the correct * migration to point to. @@ -1164,6 +1276,134 @@ public static function rollbackToDateDataProvider(): array ]; } + /** + * Data provider for testMigrateByCount + * + * @return array + */ + public static function migrateByCountDataProvider(): array + { + return [ + 'no migrations' => [ + [], + 1, + null, + 'No migrations should be run when no migrations are available.', + ], + 'one migration' => [ + ['20120111235330'], + 1, + '20120111235330', + 'Should run the only available migration.', + ], + 'two migrations, count 1' => [ + ['20120111235330', '20120116183504'], + 1, + '20120111235330', + 'Should run the first available migration.', + ], + 'two migrations, count 2' => [ + ['20120111235330', '20120116183504'], + 2, + '20120116183504', + 'Should run the second available migration.', + ], + 'three migrations, count 2' => [ + ['20120111235330', '20120116183504', '20200101120000'], + 2, + '20120116183504', + 'Should run the first two available migrations.', + ], + ]; + } + + /** + * Migration lists, version, and expected migration version to rollback to. + * + * @return array + */ + public static function rollbackByCountDataProvider(): array + { + return [ + // No breakpoints set + 'Rollback last 1 migration - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => 'TestMigration', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => 'TestMigration2', 'breakpoint' => 0], + ], + 1, + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback last 2 migrations - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => 'TestMigration', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => 'TestMigration2', 'breakpoint' => 0], + ], + 2, + ['== 20120116183504 TestMigration2: reverted', '== 20120111235330 TestMigration: reverted'], + ], + // Breakpoint set on first migration + 'Rollback last 1 migration - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => 'TestMigration', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => 'TestMigration2', 'breakpoint' => 0], + ], + 1, + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback last 2 migrations - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => 'TestMigration', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => 'TestMigration2', 'breakpoint' => 0], + ], + 2, + ['== 20120116183504 TestMigration2: reverted', 'Breakpoint reached. Further rollbacks inhibited.'], + ], + // Breakpoint set on last migration + 'Rollback last 1 migration - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => 'TestMigration', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => 'TestMigration2', 'breakpoint' => 1], + ], + 1, + null, + ], + 'Rollback last 2 migrations - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => 'TestMigration', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => 'TestMigration2', 'breakpoint' => 1], + ], + 2, + null, + ], + // Breakpoint set on all migrations + 'Rollback last 1 migration - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => 'TestMigration', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => 'TestMigration2', 'breakpoint' => 1], + ], + 1, + null, + ], + 'Rollback last 2 migrations - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => 'TestMigration', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => 'TestMigration2', 'breakpoint' => 1], + ], + 2, + null, + ], + ]; + } + /** * Migration lists, dates, and expected migration version to rollback to. *