diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index 56e11c92..94f9459e 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -181,14 +181,120 @@ 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 +``cake seeds run`` 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 seeds run Users --force + +You can check which seeds have been executed using the status command: + +.. code-block:: bash + + bin/cake seeds status + +To reset all seeds' execution state (allowing them to run again without ``--force``): + +.. code-block:: bash + + bin/cake seeds reset + .. 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. + +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 +================ + +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 =============== @@ -246,10 +352,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 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. + .. 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 @@ -371,37 +495,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/docs/en/upgrading-to-builtin-backend.rst b/docs/en/upgrading-to-builtin-backend.rst index 001fed31..fe2a9106 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/BaseSeed.php b/src/BaseSeed.php index 742559cc..85532272 100644 --- a/src/BaseSeed.php +++ b/src/BaseSeed.php @@ -215,6 +215,14 @@ public function shouldExecute(): bool return true; } + /** + * {@inheritDoc} + */ + public function isIdempotent(): bool + { + return false; + } + /** * {@inheritDoc} */ diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index ef1d4e5b..8c74e25a 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.', @@ -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 00000000..27461d10 --- /dev/null +++ b/src/Command/SeedResetCommand.php @@ -0,0 +1,155 @@ +setDescription([ + 'The reset command removes seed execution records from the log', + 'allowing seeds to be re-run without the --force flag.', + '', + 'seeds reset', + 'seeds reset --plugin Demo', + 'seeds reset -c secondary', + ])->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(); + + // Reset all seeds + $seedsToReset = $seeds; + + 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('All 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 00000000..6647a627 --- /dev/null +++ b/src/Command/SeedStatusCommand.php @@ -0,0 +1,182 @@ +setDescription([ + 'The status command prints a list of all seeds, along with their execution status', + '', + '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', + ])->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(); + + // Ensure seed schema table exists + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + $adapter->createSeedSchemaTable(); + } + + $seedLog = $adapter->getSeedLog(); + + // 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] !== $appNamespace) { + $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[] = [ + 'seedName' => $seedName, + 'plugin' => $plugin, + 'status' => $executed ? 'executed' : 'pending', + 'executedAt' => $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['seedName']), $statuses)); + $maxPluginLength = max(array_map(fn($s) => strlen($s['plugin'] ?? ''), $statuses)); + + foreach ($statuses as $status) { + $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['executedAt'] ? ' (' . $status['executedAt'] . ')' : ''; + $io->out(" {$statusText} {$plugin} {$seedName}{$date}"); + } else { + $statusText = 'pending '; + $io->out(" {$statusText} {$plugin} {$seedName}"); + } + } + + $io->out(''); + + return self::CODE_SUCCESS; + } +} diff --git a/src/Command/SeedsEntryCommand.php b/src/Command/SeedsEntryCommand.php new file mode 100644 index 00000000..e353b7db --- /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/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 4917a237..a7282410 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; @@ -44,6 +45,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; @@ -73,6 +75,11 @@ abstract class AbstractAdapter implements AdapterInterface, DirectActionInterfac */ protected string $schemaTableName = 'phinxlog'; + /** + * @var string + */ + protected string $seedSchemaTableName = 'cake_seeds'; + /** * @var array */ @@ -108,6 +115,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']); } @@ -327,6 +338,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 */ @@ -379,6 +413,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 */ @@ -968,6 +1022,89 @@ 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); + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { + $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); + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { + $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 2cb18f90..d0de1699 100644 --- a/src/Db/Adapter/AdapterInterface.php +++ b/src/Db/Adapter/AdapterInterface.php @@ -21,6 +21,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; +use Migrations\SeedInterface; /** * Adapter Interface. @@ -178,6 +179,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/Db/Adapter/AdapterWrapper.php b/src/Db/Adapter/AdapterWrapper.php index 7f13d5f6..a291db0f 100644 --- a/src/Db/Adapter/AdapterWrapper.php +++ b/src/Db/Adapter/AdapterWrapper.php @@ -20,6 +20,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; +use Migrations\SeedInterface; /** * Adapter Wrapper. @@ -238,6 +239,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/BuiltinBackend.php b/src/Migration/BuiltinBackend.php index d1b904ed..ab1118a3 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/src/Migration/Environment.php b/src/Migration/Environment.php index a776c219..e4979cb0 100644 --- a/src/Migration/Environment.php +++ b/src/Migration/Environment.php @@ -150,6 +150,12 @@ public function executeSeed(SeedInterface $seed): void // Run the seeder $seed->{SeedInterface::RUN}(); + // Record the seed execution (skip for idempotent seeds) + if (!$seed->isIdempotent()) { + $executedTime = date('Y-m-d H:i:s'); + $adapter->seedExecuted($seed, $executedTime); + } + // 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 1e00e257..8da6ae4b 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; @@ -206,6 +207,74 @@ 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(); + + // Ensure seed schema table exists + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + return false; + } + + $seedLog = $adapter->getSeedLog(); + + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { + $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 +539,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 +553,33 @@ public function executeSeed(SeedInterface $seed): void return; } + // Check if seed has already been executed (skip for idempotent seeds) + if (!$force && !$seed->isIdempotent() && $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 +793,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 +805,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 +1039,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/src/Migration/ManagerFactory.php b/src/Migration/ManagerFactory.php index 4a18f9dd..dd79b13b 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'), ]; diff --git a/src/MigrationsPlugin.php b/src/MigrationsPlugin.php index 1fd0d3ef..f1f3535d 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/src/SeedInterface.php b/src/SeedInterface.php index 1c9b29da..6bc6a2b9 100644 --- a/src/SeedInterface.php +++ b/src/SeedInterface.php @@ -185,6 +185,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/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index b30b2304..d0d7f70e 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 211da648..e18a832e 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 @@ -46,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 @@ -65,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); @@ -84,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); @@ -94,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'); @@ -115,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'); @@ -134,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:'); @@ -152,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'); @@ -180,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'); @@ -211,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'); @@ -222,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'); @@ -238,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); @@ -248,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'); @@ -273,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); @@ -292,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'); @@ -307,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'); @@ -319,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'); @@ -338,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'); @@ -353,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'); @@ -372,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'); @@ -388,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:'); @@ -404,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:'); @@ -419,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:'); @@ -431,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'); @@ -446,4 +447,135 @@ 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('seeds run -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('seeds run -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('seeds run -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('seeds status -c test'); + $this->assertExitSuccess(); + $this->assertOutputContains('Current seed execution status:'); + $this->assertOutputContains('pending'); + + // Run a seed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + + // Check status after running seed + $this->exec('seeds status -c test'); + $this->assertExitSuccess(); + $this->assertOutputContains('executed'); + $this->assertOutputContains('NumbersSeed'); + } + + public function testSeedResetCommand(): void + { + $this->createTables(); + + // Run a seed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + // Reset the seed + $this->exec('seeds reset -c test', ['y']); + $this->assertExitSuccess(); + $this->assertOutputContains('All seeds will be reset:'); + + // Verify seed can be run again without --force + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + $this->assertOutputNotContains('already executed'); + } + + public function testIdempotentSeed(): void + { + $this->createTables(); + + // 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'); + } + + 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'); + } } diff --git a/tests/TestCase/MigrationsTest.php b/tests/TestCase/MigrationsTest.php index e9d83027..64d463dc 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; } @@ -791,7 +798,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(['*']) @@ -811,7 +818,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(['*']) diff --git a/tests/test_app/config/TestSeeds/IdempotentTestSeed.php b/tests/test_app/config/TestSeeds/IdempotentTestSeed.php new file mode 100644 index 00000000..c16fb6c9 --- /dev/null +++ b/tests/test_app/config/TestSeeds/IdempotentTestSeed.php @@ -0,0 +1,25 @@ +table('numbers') + ->insert([ + 'number' => '99', + 'radix' => '10', + ]) + ->save(); + } +}