From 8e35170318cce4f28178021c6b96751d1f5f8797 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 3 Nov 2025 03:02:27 +0100 Subject: [PATCH 1/9] Add seed logs. --- docs/en/seeding.rst | 58 ++++++- src/Command/SeedCommand.php | 19 ++- src/Command/SeedResetCommand.php | 189 +++++++++++++++++++++ src/Command/SeedStatusCommand.php | 174 +++++++++++++++++++ src/Db/Adapter/AbstractAdapter.php | 130 ++++++++++++++ src/Db/Adapter/AdapterInterface.php | 39 +++++ src/Migration/Environment.php | 4 + src/Migration/Manager.php | 98 ++++++++++- tests/TestCase/Command/SeedCommandTest.php | 81 +++++++++ 9 files changed, 777 insertions(+), 15 deletions(-) create mode 100644 src/Command/SeedResetCommand.php create mode 100644 src/Command/SeedStatusCommand.php diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index 56e11c922..683481971 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -184,11 +184,41 @@ The run method is automatically invoked by Migrations when you execute the ``cake migration seed`` command. You should use this method to insert your test data. +Seed Execution Tracking +======================== + +Seeds track their execution state in the ``cake_seeds`` database table. By default, +a seed will only run once. If you attempt to run a seed that has already been +executed, it will be skipped with an "already executed" message. + +To re-run a seed that has already been executed, use the ``--force`` flag: + +.. code-block:: bash + + bin/cake migrations seed UserSeeder --force + +You can check which seeds have been executed using the status command: + +.. code-block:: bash + + bin/cake migrations seed:status + +To reset a seed's execution state (allowing it to run again without ``--force``): + +.. code-block:: bash + + bin/cake migrations seed:reset UserSeeder + + # Reset multiple seeds + bin/cake migrations seed:reset UserSeeder,PostSeeder + + # Reset all seeds + bin/cake migrations seed:reset --all + .. note:: - Unlike with migrations, seeds do not keep track of which seed classes have - been run. This means database seeds can be run repeatedly. Keep this in - mind when developing them. + 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. The Init Method =============== @@ -246,10 +276,28 @@ You can also use the full seed name including the ``Seed`` suffix: Both forms are supported and work identically. +Automatic Dependency Execution +------------------------------- + +When you run a seed that has dependencies, the system will automatically check if +those dependencies have been executed. If any dependencies haven't run yet, they +will be executed automatically before the current seed runs. This ensures proper +execution order and prevents foreign key constraint violations. + +For example, if you run: + +.. code-block:: bash + + bin/cake migrations seed ShoppingCartSeed + +And ``ShoppingCartSeed`` depends on ``UserSeed`` and ``ShopItemSeed``, the system +will automatically execute those dependencies first if they haven't been run yet. + .. note:: - Dependencies are only considered when executing all seed classes (default behavior). - They won't be considered when running specific seed classes. + Dependencies that have already been executed (according to the ``cake_seeds`` + table) will be skipped, unless you use the ``--force`` flag which will + re-execute all seeds including dependencies. Calling a Seed from another Seed diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index ef1d4e5b3..72cfbf0a7 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -87,6 +87,11 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar 'short' => 's', 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, 'help' => 'The folder where your seeds are.', + ]) + ->addOption('force', [ + 'short' => 'f', + 'help' => 'Force re-running seeds that have already been executed', + 'boolean' => true, ]); return $parser; @@ -184,9 +189,13 @@ protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int $io->out(' - ' . $seedName); } $io->out(''); - $io->out('Note: Seeds do not track execution state. They will run'); - $io->out('regardless of whether they have been executed before. Ensure your'); - $io->out('seeds are idempotent or manually verify they should be (re)run.'); + 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 { + $io->out('Warning: Running with --force will re-execute all seeds,'); + $io->out('potentially creating duplicate data. Ensure your seeds are idempotent.'); + } $io->out(''); // Ask for confirmation @@ -199,11 +208,11 @@ protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int } // run all the seed(ers) - $manager->seed(); + $manager->seed(null, (bool)$args->getOption('force')); } else { // run seed(ers) specified as arguments foreach ($seeds as $seed) { - $manager->seed(trim($seed)); + $manager->seed(trim($seed), (bool)$args->getOption('force')); } } $end = microtime(true); diff --git a/src/Command/SeedResetCommand.php b/src/Command/SeedResetCommand.php new file mode 100644 index 000000000..f0941e5f3 --- /dev/null +++ b/src/Command/SeedResetCommand.php @@ -0,0 +1,189 @@ +setDescription([ + 'The seed:reset command removes seed execution records from the log', + 'allowing seeds to be re-run without the --force flag.', + '', + 'migrations seed:reset Posts', + 'migrations seed:reset Users,Posts', + 'migrations seed:reset --all', + 'migrations seed:reset --plugin Demo', + ])->addArgument('seed', [ + 'help' => 'The name(s) of the seed(s) to reset (comma-separated for multiple).', + 'required' => false, + ])->addOption('all', [ + 'help' => 'Reset all seeds', + 'boolean' => true, + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to reset seeds for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'help' => 'The folder under config that seeds are in', + 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, + ])->addOption('dry-run', [ + 'short' => 'd', + 'help' => 'Show what would be reset without actually doing it', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + 'dry-run' => (bool)$args->getOption('dry-run'), + ]); + + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + + if ($config->isDryRun()) { + $io->info('DRY-RUN mode enabled'); + } + + $io->verbose('using connection ' . (string)$args->getOption('connection')); + $io->verbose('using paths ' . $config->getSeedPath()); + + $seeds = $manager->getSeeds(); + $adapter = $manager->getEnvironment()->getAdapter(); + + // Determine which seeds to reset + $seedsToReset = []; + $resetAll = (bool)$args->getOption('all'); + + if ($resetAll) { + $seedsToReset = $seeds; + } elseif ($args->hasArgument('seed')) { + $seedArg = $args->getArgument('seed'); + if ($seedArg !== null) { + $seedList = explode(',', $seedArg); + foreach ($seedList as $seedName) { + $trimmed = trim($seedName); + if ($trimmed !== '') { + $normalizedName = $manager->normalizeSeedName($trimmed, $seeds); + if ($normalizedName !== null && isset($seeds[$normalizedName])) { + $seedsToReset[] = $seeds[$normalizedName]; + } else { + $io->error("Seed '{$trimmed}' not found."); + + return self::CODE_ERROR; + } + } + } + } + } else { + $io->error('Please specify seed name(s) or use --all to reset all seeds.'); + + return self::CODE_ERROR; + } + + if (empty($seedsToReset)) { + $io->warning('No seeds to reset.'); + + return self::CODE_SUCCESS; + } + + // Show what will be reset and ask for confirmation + $io->out(''); + $io->out('The following seeds will be reset:'); + foreach ($seedsToReset as $seed) { + $seedName = $seed->getName(); + if (str_ends_with($seedName, 'Seed')) { + $seedName = substr($seedName, 0, -4); + } + $io->out(' - ' . $seedName); + } + $io->out(''); + + if (!$config->isDryRun()) { + $continue = $io->askChoice('Do you want to continue?', ['y', 'n'], 'n'); + if ($continue !== 'y') { + $io->warning('Reset operation aborted.'); + + return self::CODE_SUCCESS; + } + } + + // Reset the seeds + $count = 0; + foreach ($seedsToReset as $seed) { + if ($manager->isSeedExecuted($seed)) { + if (!$config->isDryRun()) { + $adapter->removeSeedFromLog($seed); + } + $io->info("Reset: {$seed->getName()}"); + $count++; + } else { + $io->verbose("Skipped (not executed): {$seed->getName()}"); + } + } + + $io->out(''); + if ($config->isDryRun()) { + $io->success("DRY-RUN: Would reset {$count} seed(s)."); + } else { + $io->success("Reset {$count} seed(s)."); + } + + return self::CODE_SUCCESS; + } +} diff --git a/src/Command/SeedStatusCommand.php b/src/Command/SeedStatusCommand.php new file mode 100644 index 000000000..84dfc5d3b --- /dev/null +++ b/src/Command/SeedStatusCommand.php @@ -0,0 +1,174 @@ +setDescription([ + 'The seed:status command prints a list of all seeds, along with their execution status', + '', + 'migrations seed:status', + 'migrations seed:status --plugin Demo', + 'migrations seed:status -c secondary', + 'migrations seed:status -f json', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to check seed status for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'help' => 'The folder under config that seeds are in', + 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, + ])->addOption('format', [ + 'short' => 'f', + 'help' => 'The output format: text or json. Defaults to text.', + 'choices' => ['text', 'json'], + 'default' => 'text', + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + ]); + + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + + $io->verbose('using connection ' . (string)$args->getOption('connection')); + $io->verbose('using paths ' . $config->getSeedPath()); + + $seeds = $manager->getSeeds(); + $adapter = $manager->getEnvironment()->getAdapter(); + $seedLog = $adapter->getSeedLog(); + + // Build status list + $statuses = []; + foreach ($seeds as $seed) { + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + if (count($parts) > 1 && $parts[0] !== 'App') { + $plugin = $parts[0]; + } + } + + $seedName = $seed->getName(); + $executed = false; + $executedAt = null; + + foreach ($seedLog as $entry) { + if ($entry['seed_name'] === $seedName && $entry['plugin'] === $plugin) { + $executed = true; + $executedAt = $entry['executed_at']; + break; + } + } + + $statuses[] = [ + 'seed_name' => $seedName, + 'plugin' => $plugin, + 'status' => $executed ? 'executed' : 'pending', + 'executed_at' => $executedAt, + ]; + } + + $format = (string)$args->getOption('format'); + if ($format === 'json') { + $json = json_encode($statuses, JSON_PRETTY_PRINT); + if ($json !== false) { + $io->out($json); + } + + return self::CODE_SUCCESS; + } + + // Text format + if (!$statuses) { + $io->warning('No seeds found.'); + + return self::CODE_SUCCESS; + } + + $io->out(''); + $io->out('Current seed execution status:'); + $io->out(''); + + $maxNameLength = max(array_map(fn($s) => strlen($s['seed_name']), $statuses)); + $maxPluginLength = max(array_map(fn($s) => strlen($s['plugin'] ?? ''), $statuses)); + + foreach ($statuses as $status) { + $seedName = str_pad($status['seed_name'], $maxNameLength); + $plugin = $status['plugin'] ? str_pad($status['plugin'], $maxPluginLength) : str_repeat(' ', $maxPluginLength); + + if ($status['status'] === 'executed') { + $statusText = 'executed'; + $date = $status['executed_at'] ? ' (' . $status['executed_at'] . ')' : ''; + $io->out(" {$statusText} {$plugin} {$seedName}{$date}"); + } else { + $statusText = 'pending '; + $io->out(" {$statusText} {$plugin} {$seedName}"); + } + } + + $io->out(''); + + return self::CODE_SUCCESS; + } +} diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index c6a32e7ab..140aaea9e 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -43,6 +43,7 @@ use Migrations\Db\Table\Index; use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; +use Migrations\SeedInterface; use PDOException; use RuntimeException; use function Cake\Core\deprecationWarning; @@ -72,6 +73,11 @@ abstract class AbstractAdapter implements AdapterInterface, DirectActionInterfac */ protected string $schemaTableName = 'phinxlog'; + /** + * @var string + */ + protected string $seedSchemaTableName = 'cake_seeds'; + /** * @var array */ @@ -326,6 +332,29 @@ public function setSchemaTableName(string $schemaTableName) return $this; } + /** + * Gets the seed schema table name. + * + * @return string + */ + public function getSeedSchemaTableName(): string + { + return $this->seedSchemaTableName; + } + + /** + * Sets the seed schema table name. + * + * @param string $seedSchemaTableName Seed Schema Table Name + * @return $this + */ + public function setSeedSchemaTableName(string $seedSchemaTableName) + { + $this->seedSchemaTableName = $seedSchemaTableName; + + return $this; + } + /** * @inheritdoc */ @@ -378,6 +407,26 @@ public function createSchemaTable(): void } } + /** + * @inheritDoc + */ + public function createSeedSchemaTable(): void + { + try { + $table = new Table($this->getSeedSchemaTableName(), [], $this); + $table->addColumn('plugin', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('seed_name', 'string', ['limit' => 100, 'null' => false]) + ->addColumn('executed_at', 'timestamp', ['default' => null, 'null' => true]) + ->save(); + } catch (Exception $exception) { + throw new InvalidArgumentException( + 'There was a problem creating the seed schema table: ' . $exception->getMessage(), + (int)$exception->getCode(), + $exception, + ); + } + } + /** * @inheritDoc */ @@ -948,6 +997,87 @@ protected function markBreakpoint(MigrationInterface $migration, bool $state): A return $this; } + /** + * @inheritDoc + */ + public function getSeedLog(): array + { + $query = $this->getSelectBuilder(); + $query->select('*') + ->from($this->getSeedSchemaTableName()) + ->orderBy(['executed_at' => 'ASC', 'id' => 'ASC']); + + try { + $rows = $query->execute()->fetchAll('assoc'); + } catch (PDOException $e) { + if (!$this->isDryRunEnabled()) { + throw $e; + } + $rows = []; + } + + return $rows; + } + + /** + * @inheritDoc + */ + public function seedExecuted(SeedInterface $seed, string $executedTime): AdapterInterface + { + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + if (count($parts) > 1 && $parts[0] !== 'App') { + $plugin = $parts[0]; + } + } + + $seedName = substr($seed->getName(), 0, 100); + + $query = $this->getInsertBuilder(); + $query->insert(['plugin', 'seed_name', 'executed_at']) + ->into($this->getSeedSchemaTableName()) + ->values([ + 'plugin' => $plugin, + 'seed_name' => $seedName, + 'executed_at' => $executedTime, + ]); + $this->executeQuery($query); + + return $this; + } + + /** + * @inheritDoc + */ + public function removeSeedFromLog(SeedInterface $seed): AdapterInterface + { + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + if (count($parts) > 1 && $parts[0] !== 'App') { + $plugin = $parts[0]; + } + } + + $seedName = $seed->getName(); + + $query = $this->getDeleteBuilder(); + $query->delete() + ->from($this->getSeedSchemaTableName()) + ->where([ + 'seed_name' => $seedName, + 'plugin IS' => $plugin, + ]); + $this->executeQuery($query); + + return $this; + } + /** * {@inheritDoc} * diff --git a/src/Db/Adapter/AdapterInterface.php b/src/Db/Adapter/AdapterInterface.php index 60fba9305..8bd8e8d5a 100644 --- a/src/Db/Adapter/AdapterInterface.php +++ b/src/Db/Adapter/AdapterInterface.php @@ -20,6 +20,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; +use Migrations\SeedInterface; /** * Adapter Interface. @@ -177,6 +178,44 @@ public function unsetBreakpoint(MigrationInterface $migration); */ public function createSchemaTable(): void; + /** + * Creates the seed schema table. + * + * @return void + */ + public function createSeedSchemaTable(): void; + + /** + * Gets the seed schema table name. + * + * @return string + */ + public function getSeedSchemaTableName(): string; + + /** + * Get all seed log entries. + * + * @return array + */ + public function getSeedLog(): array; + + /** + * Records a seed being executed. + * + * @param \Migrations\SeedInterface $seed Seed + * @param string $executedTime Executed Time + * @return $this + */ + public function seedExecuted(SeedInterface $seed, string $executedTime); + + /** + * Removes a seed from the log. + * + * @param \Migrations\SeedInterface $seed Seed + * @return $this + */ + public function removeSeedFromLog(SeedInterface $seed); + /** * Returns the adapter type. * diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php index a776c2193..98f8ace02 100644 --- a/src/Migration/Environment.php +++ b/src/Migration/Environment.php @@ -150,6 +150,10 @@ public function executeSeed(SeedInterface $seed): void // Run the seeder $seed->{SeedInterface::RUN}(); + // Record the seed execution + $executedTime = date('Y-m-d H:i:s'); + $adapter->seedExecuted($seed, $executedTime); + // commit the transaction if the adapter supports it if ($atomic) { $adapter->commitTransaction(); diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 1e00e2578..b8c004335 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -206,6 +206,67 @@ public function isMigrated(int $version): bool return isset($versions[$version]); } + /** + * Check if a seed has been executed. + * + * @param \Migrations\SeedInterface $seed Seed to check + * @return bool + */ + public function isSeedExecuted(SeedInterface $seed): bool + { + $adapter = $this->getEnvironment()->getAdapter(); + $seedLog = $adapter->getSeedLog(); + + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + if (count($parts) > 1 && $parts[0] !== 'App') { + $plugin = $parts[0]; + } + } + + $seedName = $seed->getName(); + + foreach ($seedLog as $entry) { + if ($entry['seed_name'] === $seedName && $entry['plugin'] === $plugin) { + return true; + } + } + + return false; + } + + /** + * Get dependencies of a seed that have not been executed yet. + * + * @param \Migrations\SeedInterface $seed Seed to check dependencies for + * @return array<\Migrations\SeedInterface> + */ + public function getSeedDependenciesNotExecuted(SeedInterface $seed): array + { + $dependencies = $seed->getDependencies(); + if (!$dependencies) { + return []; + } + + $seeds = $this->getSeeds(); + $notExecuted = []; + + foreach ($dependencies as $depName) { + $normalizedName = $this->normalizeSeedName($depName, $seeds); + if ($normalizedName !== null && isset($seeds[$normalizedName])) { + $depSeed = $seeds[$normalizedName]; + if (!$this->isSeedExecuted($depSeed)) { + $notExecuted[] = $depSeed; + } + } + } + + return $notExecuted; + } + /** * Marks migration with version number $version migrated * @@ -470,9 +531,10 @@ public function executeMigration(MigrationInterface $migration, string $directio * Execute a seeder against the specified environment. * * @param \Migrations\SeedInterface $seed Seed + * @param bool $force Force re-execution even if seed has already been executed * @return void */ - public function executeSeed(SeedInterface $seed): void + public function executeSeed(SeedInterface $seed, bool $force = false): void { $this->getIo()->out(''); @@ -483,8 +545,33 @@ public function executeSeed(SeedInterface $seed): void return; } + // Check if seed has already been executed + if (!$force && $this->isSeedExecuted($seed)) { + $this->printSeedStatus($seed, 'already executed'); + + return; + } + + // Auto-execute missing dependencies + $missingDeps = $this->getSeedDependenciesNotExecuted($seed); + if (!empty($missingDeps)) { + foreach ($missingDeps as $depSeed) { + $this->getIo()->verbose(sprintf( + ' Auto-executing dependency: %s', + $depSeed->getName(), + )); + $this->executeSeed($depSeed, $force); + } + } + $this->printSeedStatus($seed, 'seeding'); + // Ensure seed schema table exists + $adapter = $this->getEnvironment()->getAdapter(); + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + $adapter->createSeedSchemaTable(); + } + // Execute the seeder and log the time elapsed. $start = microtime(true); $this->getEnvironment()->executeSeed($seed); @@ -698,10 +785,11 @@ public function rollback(int|string|null $target = null, bool $force = false, bo * Run database seeders against an environment. * * @param string|null $seed Seeder + * @param bool $force Force re-execution even if seed has already been executed * @throws \InvalidArgumentException * @return void */ - public function seed(?string $seed = null): void + public function seed(?string $seed = null, bool $force = false): void { $seeds = $this->getSeeds(); @@ -709,14 +797,14 @@ public function seed(?string $seed = null): void // run all seeders foreach ($seeds as $seeder) { if (array_key_exists($seeder->getName(), $seeds)) { - $this->executeSeed($seeder); + $this->executeSeed($seeder, $force); } } } else { // run only one seeder $normalizedName = $this->normalizeSeedName($seed, $seeds); if ($normalizedName !== null) { - $this->executeSeed($seeds[$normalizedName]); + $this->executeSeed($seeds[$normalizedName], $force); } else { throw new InvalidArgumentException(sprintf('The seed `%s` does not exist', $seed)); } @@ -943,7 +1031,7 @@ public function setSeeds(array $seeds) * @param array $seeds Seeds array to search in * @return string|null The normalized seed name, or null if not found */ - protected function normalizeSeedName(string $name, array $seeds): ?string + public function normalizeSeedName(string $name, array $seeds): ?string { // Try with 'Seed' suffix first if (array_key_exists($name . 'Seed', $seeds)) { diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 211da6484..690e7bb85 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -35,6 +35,7 @@ public function tearDown(): void $connection->execute('DROP TABLE IF EXISTS numbers'); $connection->execute('DROP TABLE IF EXISTS letters'); $connection->execute('DROP TABLE IF EXISTS stores'); + $connection->execute('DROP TABLE IF EXISTS cake_seeds'); } protected function createTables(): void @@ -446,4 +447,84 @@ public function testSeederCommaSeparated(): void $query = $connection->execute('SELECT COUNT(*) FROM letters'); $this->assertEquals(2, $query->fetchColumn(0)); } + + public function testSeedStateTracking(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // First run should execute the seed + $this->exec('migrations seed -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersSeed: seeding'); + $this->assertOutputContains('All Done'); + + // Verify data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + + // Second run should skip the seed (already executed) + $this->exec('migrations seed -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersSeed: already executed'); + $this->assertOutputNotContains('seeding'); + + // Verify no additional data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + + // Run with --force should re-execute + $this->exec('migrations seed -c test NumbersSeed --force'); + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersSeed: seeding'); + + // Verify data was inserted again (now 2 records) + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(2, $query->fetchColumn(0)); + } + + public function testSeedStatusCommand(): void + { + $this->createTables(); + + // Check status before running seeds + $this->exec('migrations seed:status -c test'); + $this->assertExitSuccess(); + $this->assertOutputContains('Current seed execution status:'); + $this->assertOutputContains('pending'); + + // Run a seed + $this->exec('migrations seed -c test NumbersSeed'); + $this->assertExitSuccess(); + + // Check status after running seed + $this->exec('migrations seed:status -c test'); + $this->assertExitSuccess(); + $this->assertOutputContains('executed'); + $this->assertOutputContains('NumbersSeed'); + } + + public function testSeedResetCommand(): void + { + $this->createTables(); + + // Run a seed + $this->exec('migrations seed -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + // Reset the seed + $this->_in = ['y']; + $this->exec('migrations seed:reset -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('Reset: NumbersSeed'); + + // Verify seed can be run again without --force + $this->exec('migrations seed -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + $this->assertOutputNotContains('already executed'); + } } From fbe352661beb2508473390c367e69fc267090a13 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 3 Nov 2025 03:49:04 +0100 Subject: [PATCH 2/9] Adjust public API on commands. --- docs/en/seeding.rst | 36 +++-- src/Command/SeedCommand.php | 10 +- src/Command/SeedResetCommand.php | 50 ++----- src/Command/SeedStatusCommand.php | 12 +- src/Command/SeedsEntryCommand.php | 150 +++++++++++++++++++++ src/MigrationsPlugin.php | 31 +++-- tests/TestCase/Command/SeedCommandTest.php | 76 +++++------ 7 files changed, 245 insertions(+), 120 deletions(-) create mode 100644 src/Command/SeedsEntryCommand.php diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index 683481971..0e5fa16ff 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -181,7 +181,7 @@ The Run Method ============== The run method is automatically invoked by Migrations when you execute the -``cake migration seed`` command. You should use this method to insert your test +``seeds run`` command. You should use this method to insert your test data. Seed Execution Tracking @@ -195,25 +195,19 @@ To re-run a seed that has already been executed, use the ``--force`` flag: .. code-block:: bash - bin/cake migrations seed UserSeeder --force + bin/cake seeds run Users --force You can check which seeds have been executed using the status command: .. code-block:: bash - bin/cake migrations seed:status + bin/cake seeds status -To reset a seed's execution state (allowing it to run again without ``--force``): +To reset all seeds' execution state (allowing them to run again without ``--force``): .. code-block:: bash - bin/cake migrations seed:reset UserSeeder - - # Reset multiple seeds - bin/cake migrations seed:reset UserSeeder,PostSeeder - - # Reset all seeds - bin/cake migrations seed:reset --all + bin/cake seeds reset .. note:: @@ -288,7 +282,7 @@ For example, if you run: .. code-block:: bash - bin/cake migrations seed ShoppingCartSeed + bin/cake seeds run ShoppingCartSeed And ``ShoppingCartSeed`` depends on ``UserSeed`` and ``ShopItemSeed``, the system will automatically execute those dependencies first if they haven't been run yet. @@ -419,37 +413,37 @@ SQL `TRUNCATE` command: Executing Seed Classes ====================== -This is the easy part. To seed your database, simply use the ``migrations seed`` command: +This is the easy part. To seed your database, simply use the ``seeds run`` command: .. code-block:: bash - $ bin/cake migrations seed + $ bin/cake seeds run By default, Migrations will execute all available seed classes. If you would like to -run a specific class, simply pass in the name of it using the ``--seed`` parameter. +run a specific seed, simply pass in the seed name as an argument. You can use either the short name (without the ``Seed`` suffix) or the full name: .. code-block:: bash - $ bin/cake migrations seed --seed User + $ bin/cake seeds run User # or - $ bin/cake migrations seed --seed UserSeed + $ bin/cake seeds run UserSeed Both commands work identically. -You can also run multiple seeds: +You can also run multiple seeds by separating them with commas: .. code-block:: bash - $ bin/cake migrations seed --seed User --seed Permission --seed Log + $ bin/cake seeds run User,Permission,Log # or with full names - $ bin/cake migrations seed --seed UserSeed --seed PermissionSeed --seed LogSeed + $ bin/cake seeds run UserSeed,PermissionSeed,LogSeed You can also use the `-v` parameter for more output verbosity: .. code-block:: bash - $ bin/cake migrations seed -v + $ bin/cake seeds run -v The Migrations seed functionality provides a simple mechanism to easily and repeatably insert test data into your database, this is great for development environment diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index 72cfbf0a7..8c74e25ac 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -39,7 +39,7 @@ class SeedCommand extends Command */ public static function defaultName(): string { - return 'migrations seed'; + return 'seeds run'; } /** @@ -55,10 +55,10 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar '', 'Runs a seeder script that can populate the database with data, or run mutations:', '', - 'migrations seed Posts', - 'migrations seed Users,Posts', - 'migrations seed --plugin Demo', - 'migrations seed --connection secondary', + 'seeds run Posts', + 'seeds run Users,Posts', + 'seeds run --plugin Demo', + 'seeds run --connection secondary', '', 'Runs all seeds if no seed names are specified. When running all seeds', 'in an interactive terminal, a confirmation prompt is shown.', diff --git a/src/Command/SeedResetCommand.php b/src/Command/SeedResetCommand.php index f0941e5f3..27461d10f 100644 --- a/src/Command/SeedResetCommand.php +++ b/src/Command/SeedResetCommand.php @@ -32,7 +32,7 @@ class SeedResetCommand extends Command */ public static function defaultName(): string { - return 'migrations seed:reset'; + return 'seeds reset'; } /** @@ -44,19 +44,12 @@ public static function defaultName(): string public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser { $parser->setDescription([ - 'The seed:reset command removes seed execution records from the log', + 'The reset command removes seed execution records from the log', 'allowing seeds to be re-run without the --force flag.', '', - 'migrations seed:reset Posts', - 'migrations seed:reset Users,Posts', - 'migrations seed:reset --all', - 'migrations seed:reset --plugin Demo', - ])->addArgument('seed', [ - 'help' => 'The name(s) of the seed(s) to reset (comma-separated for multiple).', - 'required' => false, - ])->addOption('all', [ - 'help' => 'Reset all seeds', - 'boolean' => true, + 'seeds reset', + 'seeds reset --plugin Demo', + 'seeds reset -c secondary', ])->addOption('plugin', [ 'short' => 'p', 'help' => 'The plugin to reset seeds for', @@ -106,35 +99,8 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $seeds = $manager->getSeeds(); $adapter = $manager->getEnvironment()->getAdapter(); - // Determine which seeds to reset - $seedsToReset = []; - $resetAll = (bool)$args->getOption('all'); - - if ($resetAll) { - $seedsToReset = $seeds; - } elseif ($args->hasArgument('seed')) { - $seedArg = $args->getArgument('seed'); - if ($seedArg !== null) { - $seedList = explode(',', $seedArg); - foreach ($seedList as $seedName) { - $trimmed = trim($seedName); - if ($trimmed !== '') { - $normalizedName = $manager->normalizeSeedName($trimmed, $seeds); - if ($normalizedName !== null && isset($seeds[$normalizedName])) { - $seedsToReset[] = $seeds[$normalizedName]; - } else { - $io->error("Seed '{$trimmed}' not found."); - - return self::CODE_ERROR; - } - } - } - } - } else { - $io->error('Please specify seed name(s) or use --all to reset all seeds.'); - - return self::CODE_ERROR; - } + // Reset all seeds + $seedsToReset = $seeds; if (empty($seedsToReset)) { $io->warning('No seeds to reset.'); @@ -144,7 +110,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int // Show what will be reset and ask for confirmation $io->out(''); - $io->out('The following seeds will be reset:'); + $io->out('All seeds will be reset:'); foreach ($seedsToReset as $seed) { $seedName = $seed->getName(); if (str_ends_with($seedName, 'Seed')) { diff --git a/src/Command/SeedStatusCommand.php b/src/Command/SeedStatusCommand.php index 84dfc5d3b..95bc40a61 100644 --- a/src/Command/SeedStatusCommand.php +++ b/src/Command/SeedStatusCommand.php @@ -32,7 +32,7 @@ class SeedStatusCommand extends Command */ public static function defaultName(): string { - return 'migrations seed:status'; + return 'seeds status'; } /** @@ -44,12 +44,12 @@ public static function defaultName(): string public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser { $parser->setDescription([ - 'The seed:status command prints a list of all seeds, along with their execution status', + 'The status command prints a list of all seeds, along with their execution status', '', - 'migrations seed:status', - 'migrations seed:status --plugin Demo', - 'migrations seed:status -c secondary', - 'migrations seed:status -f json', + 'seeds status', + 'seeds status --plugin Demo', + 'seeds status -c secondary', + 'seeds status -f json', ])->addOption('plugin', [ 'short' => 'p', 'help' => 'The plugin to check seed status for', diff --git a/src/Command/SeedsEntryCommand.php b/src/Command/SeedsEntryCommand.php new file mode 100644 index 000000000..e353b7db5 --- /dev/null +++ b/src/Command/SeedsEntryCommand.php @@ -0,0 +1,150 @@ +commands = $commands; + } + + /** + * Run the command. + * + * Override the run() method for special handling of the `--help` option. + * + * @param array $argv Arguments from the CLI environment. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null Exit code or null for success. + */ + public function run(array $argv, ConsoleIo $io): ?int + { + $this->initialize(); + + $parser = $this->getOptionParser(); + try { + [$options, $arguments] = $parser->parse($argv); + $args = new Arguments( + $arguments, + $options, + $parser->argumentNames(), + ); + } catch (ConsoleException $e) { + $io->err('Error: ' . $e->getMessage()); + + return static::CODE_ERROR; + } + $this->setOutputLevel($args, $io); + + // This is the variance from Command::run() + if (!$args->getArgumentAt(0) && $args->getOption('help')) { + $io->out([ + 'Seeds', + '', + 'Seeds provides commands for managing your application database seed data.', + '', + ]); + $help = $this->getHelp(); + $this->executeCommand($help, [], $io); + + return static::CODE_SUCCESS; + } + + return $this->execute($args, $io); + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + if ($args->hasArgumentAt(0)) { + $name = $args->getArgumentAt(0); + $io->err( + "Could not find seeds command named `$name`." + . ' Run `seeds --help` to get a list of commands.', + ); + + return static::CODE_ERROR; + } + $io->err('No command provided. Run `seeds --help` to get a list of commands.'); + + return static::CODE_ERROR; + } + + /** + * Gets the generated help command + * + * @return \Cake\Console\Command\HelpCommand + */ + public function getHelp(): HelpCommand + { + $help = new HelpCommand(); + $commands = []; + foreach ($this->commands as $command => $class) { + if (str_starts_with($command, 'seeds')) { + $parts = explode(' ', $command); + + // Remove `seeds` + array_shift($parts); + if (count($parts) === 0) { + continue; + } + $commands[$command] = $class; + } + } + + $CommandCollection = new CommandCollection($commands); + $help->setCommandCollection($CommandCollection); + + return $help; + } +} diff --git a/src/MigrationsPlugin.php b/src/MigrationsPlugin.php index 1fd0d3ef5..f1f3535d0 100644 --- a/src/MigrationsPlugin.php +++ b/src/MigrationsPlugin.php @@ -27,6 +27,9 @@ use Migrations\Command\MigrateCommand; use Migrations\Command\RollbackCommand; use Migrations\Command\SeedCommand; +use Migrations\Command\SeedResetCommand; +use Migrations\Command\SeedsEntryCommand; +use Migrations\Command\SeedStatusCommand; use Migrations\Command\StatusCommand; /** @@ -63,24 +66,29 @@ public function bootstrap(PluginApplicationInterface $app): void */ public function console(CommandCollection $commands): CommandCollection { - $classes = [ - DumpCommand::class, + $migrationClasses = [ EntryCommand::class, + DumpCommand::class, MarkMigratedCommand::class, MigrateCommand::class, RollbackCommand::class, - SeedCommand::class, StatusCommand::class, ]; + $seedClasses = [ + SeedsEntryCommand::class, + SeedCommand::class, + SeedResetCommand::class, + SeedStatusCommand::class, + ]; $hasBake = class_exists(SimpleBakeCommand::class); if ($hasBake) { - $classes[] = BakeMigrationCommand::class; - $classes[] = BakeMigrationDiffCommand::class; - $classes[] = BakeMigrationSnapshotCommand::class; - $classes[] = BakeSeedCommand::class; + $migrationClasses[] = BakeMigrationCommand::class; + $migrationClasses[] = BakeMigrationDiffCommand::class; + $migrationClasses[] = BakeMigrationSnapshotCommand::class; + $migrationClasses[] = BakeSeedCommand::class; } $found = []; - foreach ($classes as $class) { + foreach ($migrationClasses as $class) { $name = $class::defaultName(); // If the short name has been used, use the full name. // This allows app commands to have name preference. @@ -90,6 +98,13 @@ public function console(CommandCollection $commands): CommandCollection } $found['migrations.' . $name] = $class; } + foreach ($seedClasses as $class) { + $name = $class::defaultName(); + if (!$commands->has($name)) { + $found[$name] = $class; + } + $found['seeds.' . $name] = $class; + } if ($hasBake) { $found['migrations create'] = BakeMigrationCommand::class; } diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 690e7bb85..3aa6e3a40 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -47,11 +47,11 @@ protected function createTables(): void public function testHelp(): void { - $this->exec('migrations seed --help'); + $this->exec('seeds run --help'); $this->assertExitSuccess(); $this->assertOutputContains('Seed the database with data'); - $this->assertOutputContains('migrations seed Posts'); - $this->assertOutputContains('migrations seed Users,Posts'); + $this->assertOutputContains('seeds run Posts'); + $this->assertOutputContains('seeds run Users,Posts'); } public function testSeederEvents(): void @@ -66,7 +66,7 @@ public function testSeederEvents(): void }); $this->createTables(); - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); $this->assertSame(['Migration.beforeSeed', 'Migration.afterSeed'], $fired); @@ -85,7 +85,7 @@ public function testBeforeSeederAbort(): void }); $this->createTables(); - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitError(); $this->assertSame(['Migration.beforeSeed'], $fired); @@ -95,13 +95,13 @@ public function testSeederUnknown(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The seed `NotThere` does not exist'); - $this->exec('migrations seed -c test NotThere'); + $this->exec('seeds run -c test NotThere'); } public function testSeederOne(): void { $this->createTables(); - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); $this->assertOutputContains('NumbersSeed: seeding'); @@ -116,7 +116,7 @@ public function testSeederOne(): void public function testSeederBaseSeed(): void { $this->createTables(); - $this->exec('migrations seed -c test --source BaseSeeds MigrationSeedNumbers'); + $this->exec('seeds run -c test --source BaseSeeds MigrationSeedNumbers'); $this->assertExitSuccess(); $this->assertOutputContains('MigrationSeedNumbers: seeding'); $this->assertOutputContains('AnotherNumbersSeed: seeding'); @@ -135,7 +135,7 @@ public function testSeederBaseSeed(): void public function testSeederImplicitAll(): void { $this->createTables(); - $this->exec('migrations seed -c test -q'); + $this->exec('seeds run -c test -q'); $this->assertExitSuccess(); $this->assertOutputNotContains('The following seeds will be executed:'); @@ -153,13 +153,13 @@ public function testSeederMultipleNotFound(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The seed `NotThere` does not exist'); - $this->exec('migrations seed -c test NumbersSeed,NotThere'); + $this->exec('seeds run -c test NumbersSeed,NotThere'); } public function testSeederMultiple(): void { $this->createTables(); - $this->exec('migrations seed -c test --source CallSeeds LettersSeed,NumbersCallSeed'); + $this->exec('seeds run -c test --source CallSeeds LettersSeed,NumbersCallSeed'); $this->assertExitSuccess(); $this->assertOutputContains('NumbersCallSeed: seeding'); @@ -181,13 +181,13 @@ public function testSeederSourceNotFound(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The seed `LettersSeed` does not exist'); - $this->exec('migrations seed -c test --source NotThere LettersSeed'); + $this->exec('seeds run -c test --source NotThere LettersSeed'); } public function testSeederWithTimestampFields(): void { $this->createTables(); - $this->exec('migrations seed -c test StoresSeed'); + $this->exec('seeds run -c test StoresSeed'); $this->assertExitSuccess(); $this->assertOutputContains('StoresSeed: seeding'); @@ -212,7 +212,7 @@ public function testSeederWithTimestampFields(): void public function testDryRunModeWarning(): void { $this->createTables(); - $this->exec('migrations seed -c test NumbersSeed --dry-run'); + $this->exec('seeds run -c test NumbersSeed --dry-run'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); @@ -223,7 +223,7 @@ public function testDryRunModeWarning(): void public function testDryRunModeShortOption(): void { $this->createTables(); - $this->exec('migrations seed -c test NumbersSeed -d'); + $this->exec('seeds run -c test NumbersSeed -d'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); @@ -239,7 +239,7 @@ public function testDryRunModeNoDataChanges(): void $connection = ConnectionManager::get('test'); $initialCount = $connection->execute('SELECT COUNT(*) FROM numbers')->fetchColumn(0); - $this->exec('migrations seed -c test NumbersSeed --dry-run'); + $this->exec('seeds run -c test NumbersSeed --dry-run'); $this->assertExitSuccess(); $finalCount = $connection->execute('SELECT COUNT(*) FROM numbers')->fetchColumn(0); @@ -249,7 +249,7 @@ public function testDryRunModeNoDataChanges(): void public function testDryRunModeMultipleSeeds(): void { $this->createTables(); - $this->exec('migrations seed -c test --source CallSeeds LettersSeed,NumbersCallSeed --dry-run'); + $this->exec('seeds run -c test --source CallSeeds LettersSeed,NumbersCallSeed --dry-run'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); @@ -274,7 +274,7 @@ public function testDryRunModeAllSeeds(): void $connection = ConnectionManager::get('test'); $initialCount = $connection->execute('SELECT COUNT(*) FROM numbers')->fetchColumn(0); - $this->exec('migrations seed -c test --dry-run -q'); + $this->exec('seeds run -c test --dry-run -q'); $this->assertExitSuccess(); $finalCount = $connection->execute('SELECT COUNT(*) FROM numbers')->fetchColumn(0); @@ -293,7 +293,7 @@ public function testDryRunModeWithEvents(): void }); $this->createTables(); - $this->exec('migrations seed -c test NumbersSeed --dry-run'); + $this->exec('seeds run -c test NumbersSeed --dry-run'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); @@ -308,7 +308,7 @@ public function testDryRunModeWithStoresSeed(): void $connection = ConnectionManager::get('test'); $initialCount = $connection->execute('SELECT COUNT(*) FROM stores')->fetchColumn(0); - $this->exec('migrations seed -c test StoresSeed --dry-run'); + $this->exec('seeds run -c test StoresSeed --dry-run'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); $this->assertOutputContains('StoresSeed: seeding'); @@ -320,7 +320,7 @@ public function testDryRunModeWithStoresSeed(): void public function testSeederAnonymousClass(): void { $this->createTables(); - $this->exec('migrations seed -c test AnonymousStoreSeed'); + $this->exec('seeds run -c test AnonymousStoreSeed'); $this->assertExitSuccess(); $this->assertOutputContains('AnonymousStoreSeed: seeding'); @@ -339,7 +339,7 @@ public function testSeederAnonymousClass(): void public function testSeederShortName(): void { $this->createTables(); - $this->exec('migrations seed -c test Numbers'); + $this->exec('seeds run -c test Numbers'); $this->assertExitSuccess(); $this->assertOutputContains('NumbersSeed: seeding'); @@ -354,7 +354,7 @@ public function testSeederShortName(): void public function testSeederShortNameMultiple(): void { $this->createTables(); - $this->exec('migrations seed -c test --source CallSeeds Letters,NumbersCall'); + $this->exec('seeds run -c test --source CallSeeds Letters,NumbersCall'); $this->assertExitSuccess(); $this->assertOutputContains('NumbersCallSeed: seeding'); @@ -373,7 +373,7 @@ public function testSeederShortNameMultiple(): void public function testSeederShortNameAnonymous(): void { $this->createTables(); - $this->exec('migrations seed -c test AnonymousStore'); + $this->exec('seeds run -c test AnonymousStore'); $this->assertExitSuccess(); $this->assertOutputContains('AnonymousStoreSeed: seeding'); @@ -389,7 +389,7 @@ public function testSeederAllWithQuietModeSkipsConfirmation(): void { $this->createTables(); // Quiet mode should skip confirmation prompt - $this->exec('migrations seed -c test -q'); + $this->exec('seeds run -c test -q'); $this->assertExitSuccess(); $this->assertOutputNotContains('The following seeds will be executed:'); @@ -405,7 +405,7 @@ public function testSeederAllHasConfirmation(): void { $this->createTables(); // Confirm run all. - $this->exec('migrations seed -c test', ['y']); + $this->exec('seeds run -c test', ['y']); $this->assertExitSuccess(); $this->assertOutputContains('The following seeds will be executed:'); @@ -420,7 +420,7 @@ public function testSeederAllHasConfirmation(): void public function testSeederSpecificSeedSkipsConfirmation(): void { $this->createTables(); - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); $this->assertOutputNotContains('The following seeds will be executed:'); @@ -432,7 +432,7 @@ public function testSeederSpecificSeedSkipsConfirmation(): void public function testSeederCommaSeparated(): void { $this->createTables(); - $this->exec('migrations seed -c test --source CallSeeds Letters,NumbersCall'); + $this->exec('seeds run -c test --source CallSeeds Letters,NumbersCall'); $this->assertExitSuccess(); $this->assertOutputContains('NumbersCallSeed: seeding'); @@ -456,7 +456,7 @@ public function testSeedStateTracking(): void $connection = ConnectionManager::get('test'); // First run should execute the seed - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); $this->assertOutputContains('NumbersSeed: seeding'); $this->assertOutputContains('All Done'); @@ -466,7 +466,7 @@ public function testSeedStateTracking(): void $this->assertEquals(1, $query->fetchColumn(0)); // Second run should skip the seed (already executed) - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); $this->assertOutputContains('NumbersSeed: already executed'); $this->assertOutputNotContains('seeding'); @@ -476,7 +476,7 @@ public function testSeedStateTracking(): void $this->assertEquals(1, $query->fetchColumn(0)); // Run with --force should re-execute - $this->exec('migrations seed -c test NumbersSeed --force'); + $this->exec('seeds run -c test NumbersSeed --force'); $this->assertExitSuccess(); $this->assertOutputContains('NumbersSeed: seeding'); @@ -490,17 +490,17 @@ public function testSeedStatusCommand(): void $this->createTables(); // Check status before running seeds - $this->exec('migrations seed:status -c test'); + $this->exec('seeds status -c test'); $this->assertExitSuccess(); $this->assertOutputContains('Current seed execution status:'); $this->assertOutputContains('pending'); // Run a seed - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); // Check status after running seed - $this->exec('migrations seed:status -c test'); + $this->exec('seeds status -c test'); $this->assertExitSuccess(); $this->assertOutputContains('executed'); $this->assertOutputContains('NumbersSeed'); @@ -511,18 +511,18 @@ public function testSeedResetCommand(): void $this->createTables(); // Run a seed - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); $this->assertOutputContains('seeding'); // Reset the seed $this->_in = ['y']; - $this->exec('migrations seed:reset -c test NumbersSeed'); + $this->exec('seeds reset -c test'); $this->assertExitSuccess(); - $this->assertOutputContains('Reset: NumbersSeed'); + $this->assertOutputContains('Reset all seeds'); // Verify seed can be run again without --force - $this->exec('migrations seed -c test NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); $this->assertOutputContains('seeding'); $this->assertOutputNotContains('already executed'); From 4841a7bb6a6cc379e24987733f02764445dfeded Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 3 Nov 2025 03:56:42 +0100 Subject: [PATCH 3/9] Auto create table if not exists. --- src/Command/SeedStatusCommand.php | 6 +++ src/Db/Adapter/AdapterWrapper.php | 45 ++++++++++++++++++++++ src/Migration/Manager.php | 6 +++ tests/TestCase/Command/CompletionTest.php | 2 +- tests/TestCase/Command/SeedCommandTest.php | 3 +- 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/Command/SeedStatusCommand.php b/src/Command/SeedStatusCommand.php index 95bc40a61..abe181ebb 100644 --- a/src/Command/SeedStatusCommand.php +++ b/src/Command/SeedStatusCommand.php @@ -94,6 +94,12 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $seeds = $manager->getSeeds(); $adapter = $manager->getEnvironment()->getAdapter(); + + // Ensure seed schema table exists + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + $adapter->createSeedSchemaTable(); + } + $seedLog = $adapter->getSeedLog(); // Build status list diff --git a/src/Db/Adapter/AdapterWrapper.php b/src/Db/Adapter/AdapterWrapper.php index 51f726006..79a35fa2c 100644 --- a/src/Db/Adapter/AdapterWrapper.php +++ b/src/Db/Adapter/AdapterWrapper.php @@ -19,6 +19,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; +use Migrations\SeedInterface; /** * Adapter Wrapper. @@ -237,6 +238,50 @@ public function createSchemaTable(): void $this->getAdapter()->createSchemaTable(); } + /** + * @inheritDoc + */ + public function createSeedSchemaTable(): void + { + $this->getAdapter()->createSeedSchemaTable(); + } + + /** + * @inheritDoc + */ + public function getSeedSchemaTableName(): string + { + return $this->getAdapter()->getSeedSchemaTableName(); + } + + /** + * @inheritDoc + */ + public function getSeedLog(): array + { + return $this->getAdapter()->getSeedLog(); + } + + /** + * @inheritDoc + */ + public function seedExecuted(SeedInterface $seed, string $executedTime): AdapterInterface + { + $this->getAdapter()->seedExecuted($seed, $executedTime); + + return $this; + } + + /** + * @inheritDoc + */ + public function removeSeedFromLog(SeedInterface $seed): AdapterInterface + { + $this->getAdapter()->removeSeedFromLog($seed); + + return $this; + } + /** * @inheritDoc */ diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index b8c004335..eca2e218b 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -215,6 +215,12 @@ public function isMigrated(int $version): bool public function isSeedExecuted(SeedInterface $seed): bool { $adapter = $this->getEnvironment()->getAdapter(); + + // Ensure seed schema table exists + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + return false; + } + $seedLog = $adapter->getSeedLog(); $plugin = null; diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index b30b23040..d0d7f70ee 100644 --- a/tests/TestCase/Command/CompletionTest.php +++ b/tests/TestCase/Command/CompletionTest.php @@ -44,7 +44,7 @@ public function testMigrationsSubcommands() { $this->exec('completion subcommands migrations.migrations'); $expected = [ - 'dump mark_migrated migrate rollback seed status', + 'dump mark_migrated migrate rollback status', ]; $actual = $this->_out->messages(); $this->assertEquals($expected, $actual); diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 3aa6e3a40..2a701b041 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -516,8 +516,7 @@ public function testSeedResetCommand(): void $this->assertOutputContains('seeding'); // Reset the seed - $this->_in = ['y']; - $this->exec('seeds reset -c test'); + $this->exec('seeds reset -c test', ['y']); $this->assertExitSuccess(); $this->assertOutputContains('Reset all seeds'); From a986999805e809ca01fcd1e17601ce7a382887d7 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 3 Nov 2025 04:04:54 +0100 Subject: [PATCH 4/9] Fix tests. --- src/Migration/BuiltinBackend.php | 3 ++- tests/TestCase/Command/SeedCommandTest.php | 2 +- tests/TestCase/MigrationsTest.php | 11 +++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Migration/BuiltinBackend.php b/src/Migration/BuiltinBackend.php index d1b904edf..ab1118a31 100644 --- a/src/Migration/BuiltinBackend.php +++ b/src/Migration/BuiltinBackend.php @@ -150,9 +150,10 @@ public function seed(array $options = []): bool { $options['source'] ??= ConfigInterface::DEFAULT_SEED_FOLDER; $seed = $options['seed'] ?? null; + $force = $options['force'] ?? false; $manager = $this->getManager($options); - $manager->seed($seed); + $manager->seed($seed, $force); return true; } diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 2a701b041..1c8ca6d2b 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -518,7 +518,7 @@ public function testSeedResetCommand(): void // Reset the seed $this->exec('seeds reset -c test', ['y']); $this->assertExitSuccess(); - $this->assertOutputContains('Reset all seeds'); + $this->assertOutputContains('All seeds will be reset:'); // Verify seed can be run again without --force $this->exec('seeds run -c test NumbersSeed'); diff --git a/tests/TestCase/MigrationsTest.php b/tests/TestCase/MigrationsTest.php index 7b86f1c12..8565b8b6f 100644 --- a/tests/TestCase/MigrationsTest.php +++ b/tests/TestCase/MigrationsTest.php @@ -87,6 +87,13 @@ public function setUp(): void $connection->execute($stmt); } } + if (in_array('cake_seeds', $allTables)) { + $ormTable = $this->getTableLocator()->get('cake_seeds', ['connection' => $this->Connection]); + $query = $connection->getDriver()->schemaDialect()->truncateTableSql($ormTable->getSchema()); + foreach ($query as $stmt) { + $connection->execute($stmt); + } + } $this->Connection = $connection; } @@ -790,7 +797,7 @@ public function testSeed() ]; $this->assertEquals($expected, $result); - $seed = $this->migrations->seed(['source' => 'Seeds']); + $seed = $this->migrations->seed(['source' => 'Seeds', 'force' => true]); $this->assertTrue($seed); $result = $this->Connection->selectQuery() ->select(['*']) @@ -810,7 +817,7 @@ public function testSeed() ]; $this->assertEquals($expected, $result); - $seed = $this->migrations->seed(['source' => 'AltSeeds']); + $seed = $this->migrations->seed(['source' => 'AltSeeds', 'force' => true]); $this->assertTrue($seed); $result = $this->Connection->selectQuery() ->select(['*']) From 510bfd05ff5d54f1829bde0d6ce33cf143e827dd Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 3 Nov 2025 15:39:30 +0100 Subject: [PATCH 5/9] Allow adding seeds as idempotent. --- docs/en/seeding.rst | 66 +++++++++++++++ src/BaseSeed.php | 8 ++ src/Migration/Environment.php | 8 +- src/Migration/Manager.php | 4 +- src/SeedInterface.php | 13 +++ tests/TestCase/Command/SeedCommandTest.php | 95 ++++++++++++++++++++++ 6 files changed, 189 insertions(+), 5 deletions(-) diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index 0e5fa16ff..ad56d3171 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -214,6 +214,72 @@ To reset all seeds' execution state (allowing them to run again without ``--forc 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. +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: + +.. code-block:: php + + execute(" + INSERT INTO settings (setting_key, setting_value) + VALUES ('app_version', '2.0.0') + ON DUPLICATE KEY UPDATE setting_value = '2.0.0' + "); + + // Or check before inserting + $exists = $this->fetchRow( + "SELECT COUNT(*) as count FROM settings WHERE setting_key = 'maintenance_mode'" + ); + + if ($exists['count'] == 0) { + $this->table('settings')->insert([ + 'setting_key' => 'maintenance_mode', + 'setting_value' => 'false', + ])->save(); + } + } + } + +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`` +- You must ensure the seed's ``run()`` method handles duplicate executions safely + +This is useful for: + +- Configuration seeds that should always reflect current values +- Reference data that may need periodic updates +- Seeds that use ``INSERT ... ON DUPLICATE KEY UPDATE`` or similar patterns +- Development/testing seeds that need to run repeatedly + +.. warning:: + + Only mark a seed as idempotent if you've verified it's safe to run multiple times. + Otherwise, you may create duplicate data or other unexpected behavior. + The Init Method =============== diff --git a/src/BaseSeed.php b/src/BaseSeed.php index 03d399d72..78db2c5bb 100644 --- a/src/BaseSeed.php +++ b/src/BaseSeed.php @@ -205,6 +205,14 @@ public function shouldExecute(): bool return true; } + /** + * {@inheritDoc} + */ + public function isIdempotent(): bool + { + return false; + } + /** * {@inheritDoc} */ diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php index 98f8ace02..e4979cb01 100644 --- a/src/Migration/Environment.php +++ b/src/Migration/Environment.php @@ -150,9 +150,11 @@ public function executeSeed(SeedInterface $seed): void // Run the seeder $seed->{SeedInterface::RUN}(); - // Record the seed execution - $executedTime = date('Y-m-d H:i:s'); - $adapter->seedExecuted($seed, $executedTime); + // Record the seed execution (skip for idempotent seeds) + if (!$seed->isIdempotent()) { + $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 eca2e218b..787aef308 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -551,8 +551,8 @@ public function executeSeed(SeedInterface $seed, bool $force = false): void return; } - // Check if seed has already been executed - if (!$force && $this->isSeedExecuted($seed)) { + // Check if seed has already been executed (skip for idempotent seeds) + if (!$force && !$seed->isIdempotent() && $this->isSeedExecuted($seed)) { $this->printSeedStatus($seed, 'already executed'); return; diff --git a/src/SeedInterface.php b/src/SeedInterface.php index 356fa3307..4fe659d3d 100644 --- a/src/SeedInterface.php +++ b/src/SeedInterface.php @@ -172,6 +172,19 @@ public function table(string $tableName, array $options = []): Table; */ public function shouldExecute(): bool; + /** + * Checks if this seed is idempotent (can run multiple times safely). + * + * 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. + * + * @return bool + */ + public function isIdempotent(): bool; + /** * Gives the ability to a seeder to call another seeder. * This is particularly useful if you need to run the seeders of your applications in a specific sequences, diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 1c8ca6d2b..1563dcb30 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -526,4 +526,99 @@ public function testSeedResetCommand(): void $this->assertOutputContains('seeding'); $this->assertOutputNotContains('already executed'); } + + public function testIdempotentSeed(): void + { + $this->createTables(); + + // Create an idempotent seed file + $seedPath = ROOT . DS . 'config' . DS . 'TestSeeds'; + if (!is_dir($seedPath)) { + mkdir($seedPath, 0777, true); + } + + $seedFile = $seedPath . DS . 'IdempotentTestSeed.php'; + $seedContent = <<<'PHP' +table('numbers') + ->insert([ + 'number' => '99', + 'radix' => '10', + ]) + ->save(); + } +} +PHP; + file_put_contents($seedFile, $seedContent); + + try { + // First run - should insert data + $this->exec('seeds run -c test -s TestSeeds IdempotentTest'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99'); + $this->assertEquals(1, $query->fetchColumn(0)); + + // Second run - should run again (not skip) and insert another row + $this->exec('seeds run -c test -s TestSeeds IdempotentTest'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + $this->assertOutputNotContains('already executed'); + + // Verify it ran again and inserted another row + $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 + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\''); + $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked'); + } finally { + // Cleanup + if (file_exists($seedFile)) { + unlink($seedFile); + } + if (is_dir($seedPath)) { + rmdir($seedPath); + } + } + } + + public function testNonIdempotentSeedIsTracked(): void + { + $this->createTables(); + + // Run a regular (non-idempotent) seed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Verify the seed WAS tracked in cake_seeds table + $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 + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('already executed'); + $this->assertOutputNotContains('seeding'); + } } From 299996f4856b9db0a5180e28a2844f508ac92c2d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Tue, 4 Nov 2025 16:36:21 +0100 Subject: [PATCH 6/9] Update docs/en/seeding.rst Co-authored-by: Kevin Pfeifer --- docs/en/seeding.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index ad56d3171..6070b2313 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -181,7 +181,7 @@ The Run Method ============== The run method is automatically invoked by Migrations when you execute the -``seeds run`` command. You should use this method to insert your test +``cake seeds run`` command. You should use this method to insert your test data. Seed Execution Tracking From 159f7efa3c65f47c91abd89d1ecd5452ec42abb4 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 11 Nov 2025 19:35:19 +0100 Subject: [PATCH 7/9] Adjust as per review. --- docs/en/upgrading-to-builtin-backend.rst | 62 +++++++++++++- src/Command/SeedStatusCommand.php | 14 ++-- src/Db/Adapter/AbstractAdapter.php | 11 ++- src/Migration/Manager.php | 4 +- tests/TestCase/Command/SeedCommandTest.php | 81 +++++-------------- .../config/Seeds/IdempotentTestSeed.php | 25 ++++++ 6 files changed, 125 insertions(+), 72 deletions(-) create mode 100644 tests/test_app/config/Seeds/IdempotentTestSeed.php diff --git a/docs/en/upgrading-to-builtin-backend.rst b/docs/en/upgrading-to-builtin-backend.rst index 001fed313..fe2a91067 100644 --- a/docs/en/upgrading-to-builtin-backend.rst +++ b/docs/en/upgrading-to-builtin-backend.rst @@ -18,6 +18,66 @@ changes outlined below, please open an issue. What is different? ================== +Command Structure Changes +------------------------- + +As of migrations 5.0, the command structure has changed. The old phinx wrapper +commands have been removed and replaced with new command names: + +**Seeds:** + +.. code-block:: bash + + # Old (4.x and earlier) + bin/cake migrations seed + bin/cake migrations seed --seed Articles + + # New (5.x and later) + bin/cake seeds run + bin/cake seeds run Articles + +The new commands are: + +- ``bin/cake seeds run`` - Run seed classes +- ``bin/cake seeds status`` - Show seed execution status +- ``bin/cake seeds reset`` - Reset seed execution tracking +- ``bin/cake bake seed`` - Generate new seed classes + +Maintaining Backward Compatibility +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you need to maintain the old command structure for existing scripts or CI/CD +pipelines, you can add command aliases in your application. In your +``src/Application.php`` file, add the following to the ``console()`` method: + +.. code-block:: php + + public function console(CommandCollection $commands): CommandCollection + { + // Add your application's commands + $commands = $this->addConsoleCommands($commands); + + // Add backward compatibility aliases for migrations 4.x commands + $commands->add('migrations seed', \Migrations\Command\SeedCommand::class); + + return $commands; + } + +For multiple aliases, you can add them all together: + +.. code-block:: php + + // Add multiple backward compatibility aliases + $commands->add('migrations seed', \Migrations\Command\SeedCommand::class); + $commands->add('migrations seed:run', \Migrations\Command\SeedCommand::class); + $commands->add('migrations seed:status', \Migrations\Command\SeedStatusCommand::class); + +This allows gradual migration of scripts and documentation without modifying the +migrations plugin or creating wrapper command classes. + +API Changes +----------- + If your migrations are using the ``AdapterInterface`` to fetch rows or update rows you will need to update your code. If you use ``Adapter::query()`` to execute queries, the return of this method is now @@ -45,5 +105,5 @@ Similar changes are for fetching a single row:: Problems with the builtin backend? ================================== -If your migrations contain errors when run with the builtin backend, please +If your migrations contain errors when run with the builtin backend, please open `an issue `_. diff --git a/src/Command/SeedStatusCommand.php b/src/Command/SeedStatusCommand.php index abe181ebb..6647a627b 100644 --- a/src/Command/SeedStatusCommand.php +++ b/src/Command/SeedStatusCommand.php @@ -17,6 +17,7 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; +use Cake\Core\Configure; use Migrations\Config\ConfigInterface; use Migrations\Migration\ManagerFactory; @@ -104,13 +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 = get_class($seed); if (str_contains($className, '\\')) { $parts = explode('\\', $className); - if (count($parts) > 1 && $parts[0] !== 'App') { + if (count($parts) > 1 && $parts[0] !== $appNamespace) { $plugin = $parts[0]; } } @@ -128,10 +130,10 @@ public function execute(Arguments $args, ConsoleIo $io): ?int } $statuses[] = [ - 'seed_name' => $seedName, + 'seedName' => $seedName, 'plugin' => $plugin, 'status' => $executed ? 'executed' : 'pending', - 'executed_at' => $executedAt, + 'executedAt' => $executedAt, ]; } @@ -156,16 +158,16 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $io->out('Current seed execution status:'); $io->out(''); - $maxNameLength = max(array_map(fn($s) => strlen($s['seed_name']), $statuses)); + $maxNameLength = max(array_map(fn($s) => strlen($s['seedName']), $statuses)); $maxPluginLength = max(array_map(fn($s) => strlen($s['plugin'] ?? ''), $statuses)); foreach ($statuses as $status) { - $seedName = str_pad($status['seed_name'], $maxNameLength); + $seedName = str_pad($status['seedName'], $maxNameLength); $plugin = $status['plugin'] ? str_pad($status['plugin'], $maxPluginLength) : str_repeat(' ', $maxPluginLength); if ($status['status'] === 'executed') { $statusText = 'executed'; - $date = $status['executed_at'] ? ' (' . $status['executed_at'] . ')' : ''; + $date = $status['executedAt'] ? ' (' . $status['executedAt'] . ')' : ''; $io->out(" {$statusText} {$plugin} {$seedName}{$date}"); } else { $statusText = 'pending '; diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 140aaea9e..d8097dcc7 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -10,6 +10,7 @@ use BadMethodCallException; use Cake\Console\ConsoleIo; +use Cake\Core\Configure; use Cake\Database\Connection; use Cake\Database\Query; use Cake\Database\Query\DeleteQuery; @@ -113,6 +114,10 @@ public function setOptions(array $options): AdapterInterface $this->setSchemaTableName($options['migration_table']); } + if (isset($options['seed_table'])) { + $this->setSeedSchemaTableName($options['seed_table']); + } + if (isset($options['connection']) && $options['connection'] instanceof Connection) { $this->setConnection($options['connection']); } @@ -1029,7 +1034,8 @@ public function seedExecuted(SeedInterface $seed, string $executedTime): Adapter if (str_contains($className, '\\')) { $parts = explode('\\', $className); - if (count($parts) > 1 && $parts[0] !== 'App') { + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { $plugin = $parts[0]; } } @@ -1059,7 +1065,8 @@ public function removeSeedFromLog(SeedInterface $seed): AdapterInterface if (str_contains($className, '\\')) { $parts = explode('\\', $className); - if (count($parts) > 1 && $parts[0] !== 'App') { + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { $plugin = $parts[0]; } } diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 787aef308..8da6ae4b6 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -10,6 +10,7 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; +use Cake\Core\Configure; use DateTime; use Exception; use InvalidArgumentException; @@ -228,7 +229,8 @@ public function isSeedExecuted(SeedInterface $seed): bool if (str_contains($className, '\\')) { $parts = explode('\\', $className); - if (count($parts) > 1 && $parts[0] !== 'App') { + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { $plugin = $parts[0]; } } diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 1563dcb30..5fd43e07e 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -531,72 +531,29 @@ public function testIdempotentSeed(): void { $this->createTables(); - // Create an idempotent seed file - $seedPath = ROOT . DS . 'config' . DS . 'TestSeeds'; - if (!is_dir($seedPath)) { - mkdir($seedPath, 0777, true); - } - - $seedFile = $seedPath . DS . 'IdempotentTestSeed.php'; - $seedContent = <<<'PHP' -exec('seeds run -c test IdempotentTest'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); -use Migrations\BaseSeed; + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99'); + $this->assertEquals(1, $query->fetchColumn(0)); -class IdempotentTestSeed extends BaseSeed -{ - public function isIdempotent(): bool - { - return true; - } + // Second run - should run again (not skip) and insert another row + $this->exec('seeds run -c test IdempotentTest'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + $this->assertOutputNotContains('already executed'); - public function run(): void - { - $this->table('numbers') - ->insert([ - 'number' => '99', - 'radix' => '10', - ]) - ->save(); - } -} -PHP; - file_put_contents($seedFile, $seedContent); + // Verify it ran again and inserted another row + $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99'); + $this->assertEquals(2, $query->fetchColumn(0)); - try { - // First run - should insert data - $this->exec('seeds run -c test -s TestSeeds IdempotentTest'); - $this->assertExitSuccess(); - $this->assertOutputContains('seeding'); - - /** @var \Cake\Database\Connection $connection */ - $connection = ConnectionManager::get('test'); - $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99'); - $this->assertEquals(1, $query->fetchColumn(0)); - - // Second run - should run again (not skip) and insert another row - $this->exec('seeds run -c test -s TestSeeds IdempotentTest'); - $this->assertExitSuccess(); - $this->assertOutputContains('seeding'); - $this->assertOutputNotContains('already executed'); - - // Verify it ran again and inserted another row - $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 - $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\''); - $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked'); - } finally { - // Cleanup - if (file_exists($seedFile)) { - unlink($seedFile); - } - if (is_dir($seedPath)) { - rmdir($seedPath); - } - } + // Verify the seed was NOT tracked in cake_seeds table + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\''); + $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked'); } public function testNonIdempotentSeedIsTracked(): void diff --git a/tests/test_app/config/Seeds/IdempotentTestSeed.php b/tests/test_app/config/Seeds/IdempotentTestSeed.php new file mode 100644 index 000000000..c16fb6c96 --- /dev/null +++ b/tests/test_app/config/Seeds/IdempotentTestSeed.php @@ -0,0 +1,25 @@ +table('numbers') + ->insert([ + 'number' => '99', + 'radix' => '10', + ]) + ->save(); + } +} From b23c61497bc01fb46febf8bf35c496d9833113aa Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 11 Nov 2025 19:42:17 +0100 Subject: [PATCH 8/9] Adjust as per review. --- tests/TestCase/Command/SeedCommandTest.php | 4 ++-- .../config/{Seeds => TestSeeds}/IdempotentTestSeed.php | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename tests/test_app/config/{Seeds => TestSeeds}/IdempotentTestSeed.php (100%) diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 5fd43e07e..e18a832ee 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -532,7 +532,7 @@ public function testIdempotentSeed(): void $this->createTables(); // First run - should insert data - $this->exec('seeds run -c test IdempotentTest'); + $this->exec('seeds run -c test -s TestSeeds IdempotentTest'); $this->assertExitSuccess(); $this->assertOutputContains('seeding'); @@ -542,7 +542,7 @@ public function testIdempotentSeed(): void $this->assertEquals(1, $query->fetchColumn(0)); // Second run - should run again (not skip) and insert another row - $this->exec('seeds run -c test IdempotentTest'); + $this->exec('seeds run -c test -s TestSeeds IdempotentTest'); $this->assertExitSuccess(); $this->assertOutputContains('seeding'); $this->assertOutputNotContains('already executed'); diff --git a/tests/test_app/config/Seeds/IdempotentTestSeed.php b/tests/test_app/config/TestSeeds/IdempotentTestSeed.php similarity index 100% rename from tests/test_app/config/Seeds/IdempotentTestSeed.php rename to tests/test_app/config/TestSeeds/IdempotentTestSeed.php From d1ddd580b7c4f646561363b2273bfc082b4629b9 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 11 Nov 2025 19:48:11 +0100 Subject: [PATCH 9/9] Adjust as per review. --- docs/en/seeding.rst | 16 ++++++++++++++++ src/Migration/ManagerFactory.php | 1 + 2 files changed, 17 insertions(+) diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index 6070b2313..94f9459e5 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -214,6 +214,22 @@ To reset all seeds' execution state (allowing them to run again without ``--forc 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. +Customizing the Seed Tracking Table +------------------------------------ + +By default, seed execution is tracked in a table named ``cake_seeds``. You can +customize this table name by configuring it in your ``config/app.php`` or +``config/app_local.php``: + +.. code-block:: php + + 'Migrations' => [ + 'seed_table' => 'my_custom_seeds_table', + ], + +This is useful if you need to avoid table name conflicts or want to follow +a specific naming convention in your database. + Idempotent Seeds ================ diff --git a/src/Migration/ManagerFactory.php b/src/Migration/ManagerFactory.php index 4a18f9dd1..dd79b13b9 100644 --- a/src/Migration/ManagerFactory.php +++ b/src/Migration/ManagerFactory.php @@ -111,6 +111,7 @@ public function createConfig(): ConfigInterface 'connection' => $connectionName, 'database' => $connectionConfig['database'], 'migration_table' => $table, + 'seed_table' => Configure::read('Migrations.seed_table', 'cake_seeds'), 'dryrun' => $this->getOption('dry-run'), ];