Skip to content

Commit 6d5a3df

Browse files
authored
Track last execution time for idempotent seeds (#1017)
Idempotent seeds now record their last run in the cake_seeds table instead of opting out of tracking entirely. This gives visibility into when they last ran via the status command. Non-idempotent seeds that were already executed are now silently skipped instead of printing "already executed" messages.
1 parent 8836d47 commit 6d5a3df

File tree

7 files changed

+63
-44
lines changed

7 files changed

+63
-44
lines changed

docs/en/seeding.rst

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ Idempotent Seeds
235235

236236
Some seeds are designed to be run multiple times safely (idempotent), such as seeds
237237
that update configuration or reference data. For these seeds, you can override the
238-
``isIdempotent()`` method to skip tracking entirely:
238+
``isIdempotent()`` method:
239239

240240
.. code-block:: php
241241
@@ -248,7 +248,7 @@ that update configuration or reference data. For these seeds, you can override t
248248
{
249249
/**
250250
* Mark this seed as idempotent.
251-
* It will run every time without being tracked.
251+
* It will run every time it is invoked.
252252
*/
253253
public function isIdempotent(): bool
254254
{
@@ -280,8 +280,9 @@ that update configuration or reference data. For these seeds, you can override t
280280
281281
When ``isIdempotent()`` returns ``true``:
282282

283-
- The seed will **not** be tracked in the ``cake_seeds`` table
284283
- The seed will run **every time** you execute ``seeds run``
284+
- The last execution time is still tracked in the ``cake_seeds`` table
285+
- The ``seeds status`` command will show the seed as ``(idempotent)``
285286
- You must ensure the seed's ``run()`` method handles duplicate executions safely
286287

287288
This is useful for:

src/Command/SeedCommand.php

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,16 +190,33 @@ protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int
190190

191191
// Skip confirmation in quiet mode
192192
if ($io->level() > ConsoleIo::QUIET) {
193+
$force = (bool)$args->getOption('force');
194+
195+
// Determine which seeds will actually run
196+
$willRun = [];
197+
foreach ($availableSeeds as $seed) {
198+
$displayName = Util::getSeedDisplayName($seed->getName());
199+
if ($seed->isIdempotent()) {
200+
$willRun[] = $displayName . ' <info>(idempotent)</info>';
201+
} elseif ($force || !$manager->isSeedExecuted($seed)) {
202+
$willRun[] = $displayName;
203+
}
204+
}
205+
193206
$io->out('');
207+
if (!$willRun) {
208+
$io->out('All seeds have already been executed. Use --force to re-run.');
209+
$io->out('');
210+
211+
return self::CODE_SUCCESS;
212+
}
213+
194214
$io->out('<info>The following seeds will be executed:</info>');
195-
foreach ($availableSeeds as $seed) {
196-
$io->out(' - ' . Util::getSeedDisplayName($seed->getName()));
215+
foreach ($willRun as $name) {
216+
$io->out(' - ' . $name);
197217
}
198218
$io->out('');
199-
if (!(bool)$args->getOption('force')) {
200-
$io->out('<info>Note:</info> Seeds that have already been executed will be skipped.');
201-
$io->out('Use --force to re-run seeds.');
202-
} else {
219+
if ($force) {
203220
$io->out('<warning>Warning:</warning> Running with --force will re-execute all seeds,');
204221
$io->out('potentially creating duplicate data. Ensure your seeds are idempotent.');
205222
}

src/Command/SeedStatusCommand.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
138138
'plugin' => $plugin,
139139
'status' => $executed ? 'executed' : 'pending',
140140
'executedAt' => $executedAt,
141+
'idempotent' => $seed->isIdempotent(),
141142
];
142143
}
143144

@@ -168,14 +169,15 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
168169
foreach ($statuses as $status) {
169170
$seedName = str_pad($status['seedName'], $maxNameLength);
170171
$plugin = $status['plugin'] ? str_pad($status['plugin'], $maxPluginLength) : str_repeat(' ', $maxPluginLength);
172+
$idempotent = $status['idempotent'] ? ' <info>(idempotent)</info>' : '';
171173

172174
if ($status['status'] === 'executed') {
173175
$statusText = '<info>executed</info>';
174176
$date = $status['executedAt'] ? ' (' . $status['executedAt'] . ')' : '';
175-
$io->out(" {$statusText} {$plugin} {$seedName}{$date}");
177+
$io->out(" {$statusText} {$plugin} {$seedName}{$date}{$idempotent}");
176178
} else {
177179
$statusText = '<comment>pending</comment> ';
178-
$io->out(" {$statusText} {$plugin} {$seedName}");
180+
$io->out(" {$statusText} {$plugin} {$seedName}{$idempotent}");
179181
}
180182
}
181183

src/Migration/Environment.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,13 @@ public function executeSeed(SeedInterface $seed): void
150150
// Run the seeder
151151
$seed->{SeedInterface::RUN}();
152152

153-
// Record the seed execution (skip for idempotent seeds)
154-
if (!$seed->isIdempotent()) {
155-
$executedTime = date('Y-m-d H:i:s');
156-
$adapter->seedExecuted($seed, $executedTime);
153+
// Record the seed execution
154+
// For idempotent seeds, remove old record first to update the timestamp
155+
if ($seed->isIdempotent()) {
156+
$adapter->removeSeedFromLog($seed);
157157
}
158+
$executedTime = date('Y-m-d H:i:s');
159+
$adapter->seedExecuted($seed, $executedTime);
158160

159161
// commit the transaction if the adapter supports it
160162
if ($atomic) {

src/Migration/Manager.php

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -545,39 +545,34 @@ public function executeMigration(MigrationInterface $migration, string $directio
545545
*/
546546
public function executeSeed(SeedInterface $seed, bool $force = false, bool $fake = false): void
547547
{
548-
$this->getIo()->out('');
549-
550548
// Skip the seed if it should not be executed
551549
if (!$seed->shouldExecute()) {
550+
$this->getIo()->out('');
552551
$this->printSeedStatus($seed, 'skipped');
553552

554553
return;
555554
}
556555

557-
// Check if seed has already been executed (skip for idempotent seeds)
556+
// Silently skip non-idempotent seeds that have already been executed
558557
if (!$force && !$seed->isIdempotent() && $this->isSeedExecuted($seed)) {
559-
$this->printSeedStatus($seed, 'already executed');
560-
561558
return;
562559
}
563560

561+
$this->getIo()->out('');
562+
564563
// Ensure seed schema table exists
565564
$adapter = $this->getEnvironment()->getAdapter();
566565
if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) {
567566
$adapter->createSeedSchemaTable();
568567
}
569568

570569
if ($fake) {
571-
// Idempotent seeds are not tracked, so faking doesn't apply
572-
if ($seed->isIdempotent()) {
573-
$this->printSeedStatus($seed, 'skipped (idempotent)');
574-
575-
return;
576-
}
577-
578570
// Record seed as executed without running it
579571
$this->printSeedStatus($seed, 'faking');
580572

573+
if ($seed->isIdempotent()) {
574+
$adapter->removeSeedFromLog($seed);
575+
}
581576
$executedTime = date('Y-m-d H:i:s');
582577
$adapter->seedExecuted($seed, $executedTime);
583578

src/SeedInterface.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,10 @@ public function shouldExecute(): bool;
218218
*
219219
* Returns false by default, meaning the seed will be tracked and only run once.
220220
*
221-
* If you return true, the seed will NOT be tracked in the cake_seeds table,
222-
* allowing it to run every time. Make sure your seed is truly idempotent
223-
* (handles duplicate data safely) before returning true.
221+
* If you return true, the seed will run every time it is invoked.
222+
* The last execution time is still tracked in the cake_seeds table.
223+
* Make sure your seed is truly idempotent (handles duplicate data safely)
224+
* before returning true.
224225
*
225226
* @return bool
226227
*/

tests/TestCase/Command/SeedCommandTest.php

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -457,10 +457,9 @@ public function testSeedStateTracking(): void
457457
$query = $connection->execute('SELECT COUNT(*) FROM numbers');
458458
$this->assertEquals(1, $query->fetchColumn(0));
459459

460-
// Second run should skip the seed (already executed)
460+
// Second run should silently skip the seed (already executed)
461461
$this->exec('seeds run -c test NumbersSeed');
462462
$this->assertExitSuccess();
463-
$this->assertOutputContains('Numbers seed:</info> <comment>already executed');
464463
$this->assertOutputNotContains('seeding');
465464

466465
// Verify no additional data was inserted
@@ -543,9 +542,9 @@ public function testIdempotentSeed(): void
543542
$query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99');
544543
$this->assertEquals(2, $query->fetchColumn(0));
545544

546-
// Verify the seed was NOT tracked in cake_seeds table
545+
// Verify the seed WAS tracked in cake_seeds table (only one record, updated each run)
547546
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\'');
548-
$this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked');
547+
$this->assertEquals(1, $seedLog->fetchColumn(0), 'Idempotent seeds should track last execution');
549548
}
550549

551550
public function testNonIdempotentSeedIsTracked(): void
@@ -564,10 +563,9 @@ public function testNonIdempotentSeedIsTracked(): void
564563
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\'');
565564
$this->assertEquals(1, $seedLog->fetchColumn(0), 'Regular seeds should be tracked');
566565

567-
// Run again - should be skipped
566+
// Run again - should be silently skipped
568567
$this->exec('seeds run -c test NumbersSeed');
569568
$this->assertExitSuccess();
570-
$this->assertOutputContains('already executed');
571569
$this->assertOutputNotContains('seeding');
572570
}
573571

@@ -594,10 +592,10 @@ public function testFakeSeedMarksAsExecuted(): void
594592
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\'');
595593
$this->assertEquals(1, $seedLog->fetchColumn(0), 'Fake seeds should be tracked');
596594

597-
// Running again should show already executed
595+
// Running again should be silently skipped
598596
$this->exec('seeds run -c test NumbersSeed');
599597
$this->assertExitSuccess();
600-
$this->assertOutputContains('already executed');
598+
$this->assertOutputNotContains('seeding');
601599
}
602600

603601
public function testFakeSeedWithForce(): void
@@ -692,7 +690,7 @@ public function testResetNonExistentSeed(): void
692690
$this->assertErrorContains('Seed `NonExistent` does not exist');
693691
}
694692

695-
public function testFakeIdempotentSeedIsSkipped(): void
693+
public function testFakeIdempotentSeedIsTracked(): void
696694
{
697695
$this->createTables();
698696

@@ -702,12 +700,15 @@ public function testFakeIdempotentSeedIsSkipped(): void
702700
// Run idempotent seed with --fake flag
703701
$this->exec('seeds run -c test -s TestSeeds IdempotentTest --fake');
704702
$this->assertExitSuccess();
705-
$this->assertOutputContains('skipped (idempotent)');
706-
$this->assertOutputNotContains('faking');
707-
$this->assertOutputNotContains('faked');
703+
$this->assertOutputContains('faking');
704+
$this->assertOutputContains('faked');
705+
706+
// Verify NO data was inserted
707+
$query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99');
708+
$this->assertEquals(0, $query->fetchColumn(0), 'Fake seed should not insert data');
708709

709-
// Verify the seed was NOT tracked (idempotent seeds are never tracked)
710+
// Verify the seed WAS tracked
710711
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\'');
711-
$this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked even when faked');
712+
$this->assertEquals(1, $seedLog->fetchColumn(0), 'Idempotent seeds should be tracked when faked');
712713
}
713714
}

0 commit comments

Comments
 (0)