From 4609ccfc2e8a88e1b3db23337a9478ab261c5bc6 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 22 Jul 2026 18:05:36 +0200 Subject: [PATCH 1/2] Track the plugin of executed seeds in cake_seeds Seed classes are not namespaced, so deriving the plugin from the class name never matched and plugin seeds were logged with a null plugin. The plugin of the current run is used instead, matching how migrations are tracked. Because of the null plugin, plugin seeds were also never detected as executed, so they ran again on every seeds run and were skipped by seeds reset. Seed log entries written before this fix are still matched for plugin seeds so that they are not executed a second time. --- docs/en/guides/seeding.md | 8 ++ src/Command/SeedStatusCommand.php | 15 +--- src/Db/Adapter/AbstractAdapter.php | 61 ++++++++------ src/Migration/Manager.php | 15 +--- src/Util/Util.php | 41 +++++++++ tests/TestCase/Command/SeedCommandTest.php | 96 ++++++++++++++++++++++ 6 files changed, 185 insertions(+), 51 deletions(-) diff --git a/docs/en/guides/seeding.md b/docs/en/guides/seeding.md index be61f7e7..13411cd7 100644 --- a/docs/en/guides/seeding.md +++ b/docs/en/guides/seeding.md @@ -202,6 +202,14 @@ bin/cake seeds reset > When re-running seeds with `--force`, be careful to ensure your seeds are > idempotent (safe to run multiple times) or they may create duplicate data. +Plugin seeds are tracked with their plugin name, so the `plugin` option is +required to see or reset them: + +```bash +bin/cake seeds status --plugin PluginName +bin/cake seeds reset --plugin PluginName +``` + ### Customizing the Seed Tracking Table By default, seed execution is tracked in a table named `cake_seeds`. You can diff --git a/src/Command/SeedStatusCommand.php b/src/Command/SeedStatusCommand.php index 68f98313..3a73bbd8 100644 --- a/src/Command/SeedStatusCommand.php +++ b/src/Command/SeedStatusCommand.php @@ -17,7 +17,6 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; -use Cake\Core\Configure; use Migrations\Config\ConfigInterface; use Migrations\Migration\ManagerFactory; use Migrations\Util\Util; @@ -106,24 +105,14 @@ public function execute(Arguments $args, ConsoleIo $io): ?int // Build status list $statuses = []; - $appNamespace = Configure::read('App.namespace', 'App'); foreach ($seeds as $seed) { - $plugin = null; - $className = $seed::class; - - if (str_contains($className, '\\')) { - $parts = explode('\\', $className); - if (count($parts) > 1 && $parts[0] !== $appNamespace) { - $plugin = $parts[0]; - } - } - + $plugin = Util::getSeedPlugin($seed); $seedName = $seed->getName(); $executed = false; $executedAt = null; foreach ($seedLog as $entry) { - if ($entry['seed_name'] === $seedName && $entry['plugin'] === $plugin) { + if ($entry['seed_name'] === $seedName && Util::matchesSeedPlugin($entry['plugin'], $plugin)) { $executed = true; $executedAt = $entry['executed_at']; break; diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index ee76ee1a..02034482 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -58,6 +58,7 @@ use Migrations\Db\Table\View; use Migrations\MigrationInterface; use Migrations\SeedInterface; +use Migrations\Util\Util; use PDOException; use RuntimeException; use function Cake\Core\deprecationWarning; @@ -1096,17 +1097,7 @@ public function getSeedLog(): array */ public function seedExecuted(SeedInterface $seed, string $executedTime): AdapterInterface { - $plugin = null; - $className = $seed::class; - - if (str_contains($className, '\\')) { - $parts = explode('\\', $className); - $appNamespace = Configure::read('App.namespace', 'App'); - if (count($parts) > 1 && $parts[0] !== $appNamespace) { - $plugin = $parts[0]; - } - } - + $plugin = $this->resolveSeedPlugin($seed); $seedName = substr($seed->getName(), 0, 100); $query = $this->getInsertBuilder(); @@ -1127,31 +1118,51 @@ public function seedExecuted(SeedInterface $seed, string $executedTime): Adapter */ public function removeSeedFromLog(SeedInterface $seed): AdapterInterface { - $plugin = null; - $className = $seed::class; + $plugin = $this->resolveSeedPlugin($seed); + $seedName = $seed->getName(); - if (str_contains($className, '\\')) { - $parts = explode('\\', $className); - $appNamespace = Configure::read('App.namespace', 'App'); - if (count($parts) > 1 && $parts[0] !== $appNamespace) { - $plugin = $parts[0]; - } + $conditions = ['seed_name' => $seedName]; + if ($plugin !== null) { + // Also remove entries logged before plugin attribution was fixed. + $conditions['OR'] = [ + 'plugin' => $plugin, + 'plugin IS' => null, + ]; + } else { + $conditions['plugin IS'] = null; } - $seedName = $seed->getName(); - $query = $this->getDeleteBuilder(); $query->delete() ->from($this->getSeedSchemaTableName()) - ->where([ - 'seed_name' => $seedName, - 'plugin IS' => $plugin, - ]); + ->where($conditions); $this->executeQuery($query); return $this; } + /** + * Resolve the plugin a seed belongs to. + * + * Seed classes are not namespaced, so the plugin cannot be derived from the class + * name. The plugin of the current run is used instead, matching how migrations are + * tracked. + * + * @param \Migrations\SeedInterface $seed The seed to resolve the plugin for. + * @return string|null The plugin name or null for application seeds. + */ + protected function resolveSeedPlugin(SeedInterface $seed): ?string + { + $plugin = Util::getSeedPlugin($seed); + if ($plugin !== null) { + return $plugin; + } + + $option = $this->getOption('plugin'); + + return $option ? (string)$option : null; + } + /** * {@inheritDoc} * diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 311085d0..65ef382f 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -10,7 +10,6 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; -use Cake\Core\Configure; use DateTime; use Exception; use InvalidArgumentException; @@ -214,21 +213,11 @@ public function isSeedExecuted(SeedInterface $seed): bool $seedLog = $adapter->getSeedLog(); - $plugin = null; - $className = $seed::class; - - if (str_contains($className, '\\')) { - $parts = explode('\\', $className); - $appNamespace = Configure::read('App.namespace', 'App'); - if (count($parts) > 1 && $parts[0] !== $appNamespace) { - $plugin = $parts[0]; - } - } - + $plugin = Util::getSeedPlugin($seed); $seedName = $seed->getName(); foreach ($seedLog as $entry) { - if ($entry['seed_name'] === $seedName && $entry['plugin'] === $plugin) { + if ($entry['seed_name'] === $seedName && Util::matchesSeedPlugin($entry['plugin'], $plugin)) { return true; } } diff --git a/src/Util/Util.php b/src/Util/Util.php index 25bf5e73..3634289c 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -13,6 +13,7 @@ use DateTime; use DateTimeZone; use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; +use Migrations\SeedInterface; use RuntimeException; /** @@ -208,6 +209,46 @@ public static function getSeedDisplayName(string $seedName): string return $seedName; } + /** + * Get the plugin a seed belongs to. + * + * Seed classes are not namespaced, so the plugin cannot be derived from the class + * name. The plugin of the run the seed was loaded in is used instead. + * + * @param \Migrations\SeedInterface $seed The seed to get the plugin for. + * @return string|null The plugin name, or null for application seeds. + */ + public static function getSeedPlugin(SeedInterface $seed): ?string + { + $config = $seed->getConfig(); + if ($config === null || !isset($config['plugin'])) { + return null; + } + + return (string)$config['plugin'] ?: null; + } + + /** + * Check whether a seed log entry belongs to the given plugin. + * + * Seeds executed before plugin attribution was fixed were logged without a plugin. + * Those entries are still matched for plugin seeds so that they are not executed twice. + * As a trade-off, an application seed sharing its name with a plugin seed can be + * matched as well, which is preferred over re-running a seed that already ran. + * + * @param string|null $entryPlugin The plugin stored in the seed log entry. + * @param string|null $plugin The plugin of the seed being checked. + * @return bool + */ + public static function matchesSeedPlugin(?string $entryPlugin, ?string $plugin): bool + { + if ($entryPlugin === $plugin) { + return true; + } + + return $plugin !== null && $entryPlugin === null; + } + /** * Expands a set of paths with curly braces (if supported by the OS). * diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index f98e691d..bb9109df 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -497,6 +497,102 @@ public function testSeedStatusCommand(): void $this->assertOutputContains('Numbers'); } + public function testSeederPluginIsTrackedWithPluginName(): void + { + $this->_loadTestPlugin('TestBlog'); + $this->createTables(); + + $this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed'); + $this->assertExitSuccess(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $rows = $connection + ->execute('SELECT seed_name, plugin FROM cake_seeds ORDER BY seed_name') + ->fetchAll('assoc'); + + $expected = [ + ['seed_name' => 'PluginLettersSeed', 'plugin' => 'TestBlog'], + ['seed_name' => 'PluginSubLettersSeed', 'plugin' => 'TestBlog'], + ]; + $this->assertSame($expected, $rows); + } + + public function testSeederPluginLegacyLogEntryIsNotRunAgain(): void + { + $this->_loadTestPlugin('TestBlog'); + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Simulate seeds executed before plugin attribution was fixed + $this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed'); + $this->assertExitSuccess(); + $connection->execute('UPDATE cake_seeds SET plugin = NULL'); + + $letters = $connection->execute('SELECT COUNT(*) FROM letters'); + $this->assertEquals(4, $letters->fetchColumn(0)); + + $this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed'); + $this->assertExitSuccess(); + $this->assertOutputNotContains('seeding'); + + // No additional rows were inserted by a second run + $letters = $connection->execute('SELECT COUNT(*) FROM letters'); + $this->assertEquals(4, $letters->fetchColumn(0)); + + $this->exec('seeds status -c test -p TestBlog --source CallSeeds'); + $this->assertExitSuccess(); + $this->assertOutputContains('executed'); + $this->assertOutputNotContains('pending'); + + // Resetting removes the legacy entries as well + $this->exec('seeds reset -c test -p TestBlog --source CallSeeds', ['y']); + $this->assertExitSuccess(); + + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds'); + $this->assertEquals(0, $seedLog->fetchColumn(0)); + } + + public function testSeedStatusCommandWithPlugin(): void + { + $this->_loadTestPlugin('TestBlog'); + $this->createTables(); + + $this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed'); + $this->assertExitSuccess(); + + $this->exec('seeds status -c test -p TestBlog --source CallSeeds'); + $this->assertExitSuccess(); + $this->assertOutputContains('TestBlog'); + $this->assertOutputContains('executed'); + $this->assertOutputNotContains('pending'); + } + + public function testSeedResetCommandWithPlugin(): void + { + $this->_loadTestPlugin('TestBlog'); + $this->createTables(); + + $this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed'); + $this->assertExitSuccess(); + + $this->exec('seeds reset -c test -p TestBlog --source CallSeeds', ['y']); + $this->assertExitSuccess(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds'); + $this->assertEquals(0, $seedLog->fetchColumn(0)); + + // Verify the seed can be run again without --force + $this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + $this->assertOutputNotContains('already executed'); + } + public function testSeedResetCommand(): void { $this->createTables(); From 2c5c44a5c51d76fed8a45fada4e8dc506ed32791 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 22 Jul 2026 19:58:29 +0200 Subject: [PATCH 2/2] Use instanceof check for the seed config --- src/Util/Util.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Util/Util.php b/src/Util/Util.php index 3634289c..cd151725 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -12,6 +12,7 @@ use Cake\Utility\Inflector; use DateTime; use DateTimeZone; +use Migrations\Config\ConfigInterface; use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use Migrations\SeedInterface; use RuntimeException; @@ -221,7 +222,7 @@ public static function getSeedDisplayName(string $seedName): string public static function getSeedPlugin(SeedInterface $seed): ?string { $config = $seed->getConfig(); - if ($config === null || !isset($config['plugin'])) { + if (!$config instanceof ConfigInterface || !isset($config['plugin'])) { return null; }