diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index 80d46f69..2d483882 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -235,7 +235,7 @@ Idempotent Seeds Some seeds are designed to be run multiple times safely (idempotent), such as seeds that update configuration or reference data. For these seeds, you can override the -``isIdempotent()`` method to skip tracking entirely: +``isIdempotent()`` method: .. code-block:: php @@ -248,7 +248,7 @@ that update configuration or reference data. For these seeds, you can override t { /** * Mark this seed as idempotent. - * It will run every time without being tracked. + * It will run every time it is invoked. */ public function isIdempotent(): bool { @@ -280,8 +280,9 @@ that update configuration or reference data. For these seeds, you can override t When ``isIdempotent()`` returns ``true``: -- The seed will **not** be tracked in the ``cake_seeds`` table - The seed will run **every time** you execute ``seeds run`` +- The last execution time is still tracked in the ``cake_seeds`` table +- The ``seeds status`` command will show the seed as ``(idempotent)`` - You must ensure the seed's ``run()`` method handles duplicate executions safely This is useful for: diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index 63acbe58..9ccb761b 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -190,16 +190,33 @@ protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int // Skip confirmation in quiet mode if ($io->level() > ConsoleIo::QUIET) { + $force = (bool)$args->getOption('force'); + + // Determine which seeds will actually run + $willRun = []; + foreach ($availableSeeds as $seed) { + $displayName = Util::getSeedDisplayName($seed->getName()); + if ($seed->isIdempotent()) { + $willRun[] = $displayName . ' (idempotent)'; + } elseif ($force || !$manager->isSeedExecuted($seed)) { + $willRun[] = $displayName; + } + } + $io->out(''); + if (!$willRun) { + $io->out('All seeds have already been executed. Use --force to re-run.'); + $io->out(''); + + return self::CODE_SUCCESS; + } + $io->out('The following seeds will be executed:'); - foreach ($availableSeeds as $seed) { - $io->out(' - ' . Util::getSeedDisplayName($seed->getName())); + foreach ($willRun as $name) { + $io->out(' - ' . $name); } $io->out(''); - if (!(bool)$args->getOption('force')) { - $io->out('Note: Seeds that have already been executed will be skipped.'); - $io->out('Use --force to re-run seeds.'); - } else { + if ($force) { $io->out('Warning: Running with --force will re-execute all seeds,'); $io->out('potentially creating duplicate data. Ensure your seeds are idempotent.'); } diff --git a/src/Command/SeedStatusCommand.php b/src/Command/SeedStatusCommand.php index bf4038e1..49a49d66 100644 --- a/src/Command/SeedStatusCommand.php +++ b/src/Command/SeedStatusCommand.php @@ -138,6 +138,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int 'plugin' => $plugin, 'status' => $executed ? 'executed' : 'pending', 'executedAt' => $executedAt, + 'idempotent' => $seed->isIdempotent(), ]; } @@ -168,14 +169,15 @@ public function execute(Arguments $args, ConsoleIo $io): ?int foreach ($statuses as $status) { $seedName = str_pad($status['seedName'], $maxNameLength); $plugin = $status['plugin'] ? str_pad($status['plugin'], $maxPluginLength) : str_repeat(' ', $maxPluginLength); + $idempotent = $status['idempotent'] ? ' (idempotent)' : ''; if ($status['status'] === 'executed') { $statusText = 'executed'; $date = $status['executedAt'] ? ' (' . $status['executedAt'] . ')' : ''; - $io->out(" {$statusText} {$plugin} {$seedName}{$date}"); + $io->out(" {$statusText} {$plugin} {$seedName}{$date}{$idempotent}"); } else { $statusText = 'pending '; - $io->out(" {$statusText} {$plugin} {$seedName}"); + $io->out(" {$statusText} {$plugin} {$seedName}{$idempotent}"); } } diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php index e4979cb0..b6861cae 100644 --- a/src/Migration/Environment.php +++ b/src/Migration/Environment.php @@ -150,11 +150,13 @@ public function executeSeed(SeedInterface $seed): void // Run the seeder $seed->{SeedInterface::RUN}(); - // Record the seed execution (skip for idempotent seeds) - if (!$seed->isIdempotent()) { - $executedTime = date('Y-m-d H:i:s'); - $adapter->seedExecuted($seed, $executedTime); + // Record the seed execution + // For idempotent seeds, remove old record first to update the timestamp + if ($seed->isIdempotent()) { + $adapter->removeSeedFromLog($seed); } + $executedTime = date('Y-m-d H:i:s'); + $adapter->seedExecuted($seed, $executedTime); // commit the transaction if the adapter supports it if ($atomic) { diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index ebf7a339..4a9f1886 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -545,22 +545,21 @@ public function executeMigration(MigrationInterface $migration, string $directio */ public function executeSeed(SeedInterface $seed, bool $force = false, bool $fake = false): void { - $this->getIo()->out(''); - // Skip the seed if it should not be executed if (!$seed->shouldExecute()) { + $this->getIo()->out(''); $this->printSeedStatus($seed, 'skipped'); return; } - // Check if seed has already been executed (skip for idempotent seeds) + // Silently skip non-idempotent seeds that have already been executed if (!$force && !$seed->isIdempotent() && $this->isSeedExecuted($seed)) { - $this->printSeedStatus($seed, 'already executed'); - return; } + $this->getIo()->out(''); + // Ensure seed schema table exists $adapter = $this->getEnvironment()->getAdapter(); if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { @@ -568,16 +567,12 @@ public function executeSeed(SeedInterface $seed, bool $force = false, bool $fake } 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'); + if ($seed->isIdempotent()) { + $adapter->removeSeedFromLog($seed); + } $executedTime = date('Y-m-d H:i:s'); $adapter->seedExecuted($seed, $executedTime); diff --git a/src/SeedInterface.php b/src/SeedInterface.php index 3d21972a..e20b81bb 100644 --- a/src/SeedInterface.php +++ b/src/SeedInterface.php @@ -218,9 +218,10 @@ public function shouldExecute(): bool; * * Returns false by default, meaning the seed will be tracked and only run once. * - * If you return true, the seed will NOT be tracked in the cake_seeds table, - * allowing it to run every time. Make sure your seed is truly idempotent - * (handles duplicate data safely) before returning true. + * If you return true, the seed will run every time it is invoked. + * The last execution time is still tracked in the cake_seeds table. + * Make sure your seed is truly idempotent (handles duplicate data safely) + * before returning true. * * @return bool */ diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index ac240171..e401e460 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -457,10 +457,9 @@ public function testSeedStateTracking(): void $query = $connection->execute('SELECT COUNT(*) FROM numbers'); $this->assertEquals(1, $query->fetchColumn(0)); - // Second run should skip the seed (already executed) + // Second run should silently skip the seed (already executed) $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); - $this->assertOutputContains('Numbers seed: already executed'); $this->assertOutputNotContains('seeding'); // Verify no additional data was inserted @@ -543,9 +542,9 @@ public function testIdempotentSeed(): void $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99'); $this->assertEquals(2, $query->fetchColumn(0)); - // Verify the seed was NOT tracked in cake_seeds table + // Verify the seed WAS tracked in cake_seeds table (only one record, updated each run) $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\''); - $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked'); + $this->assertEquals(1, $seedLog->fetchColumn(0), 'Idempotent seeds should track last execution'); } public function testNonIdempotentSeedIsTracked(): void @@ -564,10 +563,9 @@ public function testNonIdempotentSeedIsTracked(): void $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); $this->assertEquals(1, $seedLog->fetchColumn(0), 'Regular seeds should be tracked'); - // Run again - should be skipped + // Run again - should be silently skipped $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); - $this->assertOutputContains('already executed'); $this->assertOutputNotContains('seeding'); } @@ -594,10 +592,10 @@ public function testFakeSeedMarksAsExecuted(): void $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 + // Running again should be silently skipped $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); - $this->assertOutputContains('already executed'); + $this->assertOutputNotContains('seeding'); } public function testFakeSeedWithForce(): void @@ -692,7 +690,7 @@ public function testResetNonExistentSeed(): void $this->assertErrorContains('Seed `NonExistent` does not exist'); } - public function testFakeIdempotentSeedIsSkipped(): void + public function testFakeIdempotentSeedIsTracked(): void { $this->createTables(); @@ -702,12 +700,15 @@ public function testFakeIdempotentSeedIsSkipped(): void // 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'); + $this->assertOutputContains('faking'); + $this->assertOutputContains('faked'); + + // Verify NO data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99'); + $this->assertEquals(0, $query->fetchColumn(0), 'Fake seed should not insert data'); - // Verify the seed was NOT tracked (idempotent seeds are never tracked) + // Verify the seed WAS 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'); + $this->assertEquals(1, $seedLog->fetchColumn(0), 'Idempotent seeds should be tracked when faked'); } }