Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/en/seeding.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
{
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 23 additions & 6 deletions src/Command/SeedCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 . ' <info>(idempotent)</info>';
} 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('<info>The following seeds will be executed:</info>');
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('<info>Note:</info> Seeds that have already been executed will be skipped.');
$io->out('Use --force to re-run seeds.');
} else {
if ($force) {
$io->out('<warning>Warning:</warning> Running with --force will re-execute all seeds,');
$io->out('potentially creating duplicate data. Ensure your seeds are idempotent.');
}
Expand Down
6 changes: 4 additions & 2 deletions src/Command/SeedStatusCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
'plugin' => $plugin,
'status' => $executed ? 'executed' : 'pending',
'executedAt' => $executedAt,
'idempotent' => $seed->isIdempotent(),
];
}

Expand Down Expand Up @@ -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'] ? ' <info>(idempotent)</info>' : '';

if ($status['status'] === 'executed') {
$statusText = '<info>executed</info>';
$date = $status['executedAt'] ? ' (' . $status['executedAt'] . ')' : '';
$io->out(" {$statusText} {$plugin} {$seedName}{$date}");
$io->out(" {$statusText} {$plugin} {$seedName}{$date}{$idempotent}");
} else {
$statusText = '<comment>pending</comment> ';
$io->out(" {$statusText} {$plugin} {$seedName}");
$io->out(" {$statusText} {$plugin} {$seedName}{$idempotent}");
}
}

Expand Down
10 changes: 6 additions & 4 deletions src/Migration/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 7 additions & 12 deletions src/Migration/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -545,39 +545,34 @@ 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())) {
$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');

if ($seed->isIdempotent()) {
$adapter->removeSeedFromLog($seed);
}
$executedTime = date('Y-m-d H:i:s');
$adapter->seedExecuted($seed, $executedTime);

Expand Down
7 changes: 4 additions & 3 deletions src/SeedInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
29 changes: 15 additions & 14 deletions tests/TestCase/Command/SeedCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:</info> <comment>already executed');
$this->assertOutputNotContains('seeding');

// Verify no additional data was inserted
Expand Down Expand Up @@ -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
Expand All @@ -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');
}

Expand All @@ -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
Expand Down Expand Up @@ -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();

Expand All @@ -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');
}
}