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..cd151725 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -12,7 +12,9 @@ use Cake\Utility\Inflector; use DateTime; use DateTimeZone; +use Migrations\Config\ConfigInterface; use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; +use Migrations\SeedInterface; use RuntimeException; /** @@ -208,6 +210,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 instanceof ConfigInterface || !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();