diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index b7562809..88e233b0 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -93,6 +93,10 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar 'short' => 'f', 'help' => 'Force re-running seeds that have already been executed', 'boolean' => true, + ]) + ->addOption('fake', [ + 'help' => 'Mark seeds as executed without actually running them', + 'boolean' => true, ]); return $parser; @@ -154,9 +158,14 @@ protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int $versionOrder = $config->getVersionOrder(); + $fake = (bool)$args->getOption('fake'); + if ($config->isDryRun()) { $io->info('DRY-RUN mode enabled'); } + if ($fake) { + $io->warning('performing fake seeding'); + } $io->verbose('using connection ' . (string)$args->getOption('connection')); $io->verbose('using paths ' . $config->getMigrationPath()); $io->verbose('ordering by ' . $versionOrder . ' time'); @@ -205,11 +214,11 @@ protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int } // run all the seed(ers) - $manager->seed(null, (bool)$args->getOption('force')); + $manager->seed(null, (bool)$args->getOption('force'), $fake); } else { // run seed(ers) specified as arguments foreach ($seeds as $seed) { - $manager->seed(trim($seed), (bool)$args->getOption('force')); + $manager->seed(trim($seed), (bool)$args->getOption('force'), $fake); } } $end = microtime(true); diff --git a/src/Command/SeedResetCommand.php b/src/Command/SeedResetCommand.php index 18ef30b1..f11964d0 100644 --- a/src/Command/SeedResetCommand.php +++ b/src/Command/SeedResetCommand.php @@ -49,8 +49,12 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar 'allowing seeds to be re-run without the --force flag.', '', 'seeds reset', + 'seeds reset --seed Users', + 'seeds reset --seed Users,Posts', 'seeds reset --plugin Demo', 'seeds reset -c secondary', + ])->addOption('seed', [ + 'help' => 'Comma-separated list of specific seeds to reset. Resets all seeds if not specified.', ])->addOption('plugin', [ 'short' => 'p', 'help' => 'The plugin to reset seeds for', @@ -100,9 +104,25 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $seeds = $manager->getSeeds(); $adapter = $manager->getEnvironment()->getAdapter(); - // Reset all seeds + // Filter seeds if --seed option is specified + $seedOption = $args->getOption('seed'); $seedsToReset = $seeds; + if ($seedOption) { + $requestedSeeds = array_map('trim', explode(',', (string)$seedOption)); + $seedsToReset = []; + + foreach ($requestedSeeds as $requestedSeed) { + $normalizedName = $manager->normalizeSeedName($requestedSeed, $seeds); + if ($normalizedName === null) { + $io->error("Seed `{$requestedSeed}` does not exist."); + + return self::CODE_ERROR; + } + $seedsToReset[$normalizedName] = $seeds[$normalizedName]; + } + } + if (empty($seedsToReset)) { $io->warning('No seeds to reset.'); @@ -111,7 +131,8 @@ public function execute(Arguments $args, ConsoleIo $io): ?int // Show what will be reset and ask for confirmation $io->out(''); - $io->out('All seeds will be reset:'); + $resetAllMessage = $seedOption ? 'The following seeds will be reset:' : 'All seeds will be reset:'; + $io->out($resetAllMessage); foreach ($seedsToReset as $seed) { $io->out(' - ' . Util::getSeedDisplayName($seed->getName())); } diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 963bd57f..2ce00a17 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -540,9 +540,10 @@ public function executeMigration(MigrationInterface $migration, string $directio * * @param \Migrations\SeedInterface $seed Seed * @param bool $force Force re-execution even if seed has already been executed + * @param bool $fake Record seed as executed without actually running it * @return void */ - public function executeSeed(SeedInterface $seed, bool $force = false): void + public function executeSeed(SeedInterface $seed, bool $force = false, bool $fake = false): void { $this->getIo()->out(''); @@ -560,6 +561,31 @@ public function executeSeed(SeedInterface $seed, bool $force = false): void return; } + // Ensure seed schema table exists + $adapter = $this->getEnvironment()->getAdapter(); + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + $adapter->createSeedSchemaTable(); + } + + if ($fake) { + // Idempotent seeds are not tracked, so faking doesn't apply + if ($seed->isIdempotent()) { + $this->printSeedStatus($seed, 'skipped (idempotent)'); + + return; + } + + // Record seed as executed without running it + $this->printSeedStatus($seed, 'faking'); + + $executedTime = date('Y-m-d H:i:s'); + $adapter->seedExecuted($seed, $executedTime); + + $this->printSeedStatus($seed, 'faked'); + + return; + } + // Auto-execute missing dependencies $missingDeps = $this->getSeedDependenciesNotExecuted($seed); if (!empty($missingDeps)) { @@ -568,18 +594,12 @@ public function executeSeed(SeedInterface $seed, bool $force = false): void ' Auto-executing dependency: %s', $depSeed->getName(), )); - $this->executeSeed($depSeed, $force); + $this->executeSeed($depSeed, $force, $fake); } } $this->printSeedStatus($seed, 'seeding'); - // Ensure seed schema table exists - $adapter = $this->getEnvironment()->getAdapter(); - if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { - $adapter->createSeedSchemaTable(); - } - // Execute the seeder and log the time elapsed. $start = microtime(true); $this->getEnvironment()->executeSeed($seed); @@ -794,10 +814,11 @@ public function rollback(int|string|null $target = null, bool $force = false, bo * * @param string|null $seed Seeder * @param bool $force Force re-execution even if seed has already been executed + * @param bool $fake Record seed as executed without actually running it * @throws \InvalidArgumentException * @return void */ - public function seed(?string $seed = null, bool $force = false): void + public function seed(?string $seed = null, bool $force = false, bool $fake = false): void { $seeds = $this->getSeeds(); @@ -805,14 +826,14 @@ public function seed(?string $seed = null, bool $force = false): void // run all seeders foreach ($seeds as $seeder) { if (array_key_exists($seeder->getName(), $seeds)) { - $this->executeSeed($seeder, $force); + $this->executeSeed($seeder, $force, $fake); } } } else { // run only one seeder $normalizedName = $this->normalizeSeedName($seed, $seeds); if ($normalizedName !== null) { - $this->executeSeed($seeds[$normalizedName], $force); + $this->executeSeed($seeds[$normalizedName], $force, $fake); } else { throw new InvalidArgumentException(sprintf('The seed `%s` does not exist', $seed)); } diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 77935ae4..ac240171 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -570,4 +570,144 @@ public function testNonIdempotentSeedIsTracked(): void $this->assertOutputContains('already executed'); $this->assertOutputNotContains('seeding'); } + + public function testFakeSeedMarksAsExecuted(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run with --fake flag + $this->exec('seeds run -c test NumbersSeed --fake'); + $this->assertExitSuccess(); + $this->assertErrorContains('performing fake seeding'); + $this->assertOutputContains('faking'); + $this->assertOutputContains('faked'); + $this->assertOutputNotContains('seeding'); + + // Verify NO data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(0, $query->fetchColumn(0), 'Fake seed should not insert data'); + + // Verify the seed WAS tracked in cake_seeds table + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(1, $seedLog->fetchColumn(0), 'Fake seeds should be tracked'); + + // Running again should show already executed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('already executed'); + } + + public function testFakeSeedWithForce(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run with --fake first + $this->exec('seeds run -c test NumbersSeed --fake'); + $this->assertExitSuccess(); + + // Verify seed is tracked + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(1, $seedLog->fetchColumn(0)); + + // Run with --force to actually execute it + $this->exec('seeds run -c test NumbersSeed --force'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + // Verify data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testResetSpecificSeed(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run two seeds + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + + $this->exec('seeds run -c test StoresSeed'); + $this->assertExitSuccess(); + + // Verify both are tracked + $numbersLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(1, $numbersLog->fetchColumn(0)); + + $storesLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'StoresSeed\''); + $this->assertEquals(1, $storesLog->fetchColumn(0)); + + // Reset only Numbers seed + $this->exec('seeds reset -c test --seed Numbers', ['y']); + $this->assertExitSuccess(); + $this->assertOutputContains('The following seeds will be reset:'); + $this->assertOutputNotContains('All seeds will be reset:'); + + // Verify Numbers is reset but Stores is still tracked + $numbersLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(0, $numbersLog->fetchColumn(0), 'Numbers seed should be reset'); + + $storesLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'StoresSeed\''); + $this->assertEquals(1, $storesLog->fetchColumn(0), 'Stores seed should still be tracked'); + } + + public function testResetMultipleSpecificSeeds(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run seeds + $this->exec('seeds run -c test NumbersSeed'); + $this->exec('seeds run -c test StoresSeed'); + + // Reset both with comma-separated list + $this->exec('seeds reset -c test --seed Numbers,Stores', ['y']); + $this->assertExitSuccess(); + + // Verify both are reset + $numbersLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(0, $numbersLog->fetchColumn(0)); + + $storesLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'StoresSeed\''); + $this->assertEquals(0, $storesLog->fetchColumn(0)); + } + + public function testResetNonExistentSeed(): void + { + $this->createTables(); + + $this->exec('seeds reset -c test --seed NonExistent'); + $this->assertExitError(); + $this->assertErrorContains('Seed `NonExistent` does not exist'); + } + + public function testFakeIdempotentSeedIsSkipped(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run idempotent seed with --fake flag + $this->exec('seeds run -c test -s TestSeeds IdempotentTest --fake'); + $this->assertExitSuccess(); + $this->assertOutputContains('skipped (idempotent)'); + $this->assertOutputNotContains('faking'); + $this->assertOutputNotContains('faked'); + + // Verify the seed was NOT tracked (idempotent seeds are never tracked) + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\''); + $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked even when faked'); + } }