diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php new file mode 100644 index 00000000..c14e3a03 --- /dev/null +++ b/src/Command/SeedCommand.php @@ -0,0 +1,137 @@ + + */ + use EventDispatcherTrait; + + /** + * The default name added to the application command list + * + * @return string + */ + public static function defaultName(): string + { + return 'migrations seed'; + } + + /** + * Configure the option parser + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to configure + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + 'Seed the database with data', + '', + 'Runs a seeder script that can populate the database with data, or run mutations', + '', + 'migrations seed --connection secondary --seed UserSeeder', + '', + 'The `--seed` option can be supplied multiple times to run more than one seeder', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to run seeders in', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, + 'help' => 'The folder where your seeders are.', + ])->addOption('seed', [ + 'help' => 'The name of the seeder that you want to run.', + 'multiple' => 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 + { + $event = $this->dispatchEvent('Migration.beforeSeed'); + if ($event->isStopped()) { + return $event->getResult() ? self::CODE_SUCCESS : self::CODE_ERROR; + } + $result = $this->executeSeeds($args, $io); + $this->dispatchEvent('Migration.afterSeed'); + + return $result; + } + + /** + * Execute seeders based on console inputs. + * + * @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 + */ + protected function executeSeeds(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(); + $seeds = (array)$args->getMultipleOption('seed'); + + $versionOrder = $config->getVersionOrder(); + $io->out('using connection ' . (string)$args->getOption('connection')); + $io->out('using paths ' . implode(', ', $config->getMigrationPaths())); + $io->out('ordering by ' . $versionOrder . ' time'); + + $start = microtime(true); + if (empty($seeds)) { + // run all the seed(ers) + $manager->seed(); + } else { + // run seed(ers) specified in a comma-separated list of classes + foreach ($seeds as $seed) { + $manager->seed(trim($seed)); + } + } + $end = microtime(true); + + $io->out('All Done. Took ' . sprintf('%.4fs', $end - $start) . ''); + + return self::CODE_SUCCESS; + } +} diff --git a/src/Config/ConfigInterface.php b/src/Config/ConfigInterface.php index 9af398f1..b1b2db12 100644 --- a/src/Config/ConfigInterface.php +++ b/src/Config/ConfigInterface.php @@ -19,6 +19,7 @@ interface ConfigInterface extends ArrayAccess { public const DEFAULT_MIGRATION_FOLDER = 'Migrations'; + public const DEFAULT_SEED_FOLDER = 'Seeds'; /** * Returns the configuration for the current environment. diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index d1b781e1..200efe85 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -1006,10 +1006,7 @@ public function getSeeds(): array ksort($seeds); $this->setSeeds($seeds); } - - assert(!empty($this->seeds), 'seeds must be set'); - $this->seeds = $this->orderSeedsByDependencies($this->seeds); - + $this->seeds = $this->orderSeedsByDependencies((array)$this->seeds); if (empty($this->seeds)) { return []; } diff --git a/src/Migration/ManagerFactory.php b/src/Migration/ManagerFactory.php index c37ebbe9..2ccaa6bb 100644 --- a/src/Migration/ManagerFactory.php +++ b/src/Migration/ManagerFactory.php @@ -35,7 +35,7 @@ class ManagerFactory * * ## Options * - * - source - The directory in app/config that migrations should be read from. + * - source - The directory in app/config that migrations and seeds should be read from. * - plugin - The plugin name that migrations are being run on. * - connection - The connection name. * - dry-run - Whether or not dry-run mode should be enabled. @@ -70,7 +70,8 @@ public function createConfig(): ConfigInterface { $folder = (string)$this->getOption('source'); - // Get the filepath for migrations and seeds(not implemented yet) + // Get the filepath for migrations and seeds. + // We rely on factory parameters to define which directory to use. $dir = ROOT . DS . 'config' . DS . $folder; if (defined('CONFIG')) { $dir = CONFIG . $folder; @@ -110,7 +111,9 @@ public function createConfig(): ConfigInterface $configData = [ 'paths' => [ + // TODO make paths a simple list. 'migrations' => $dir, + 'seeds' => $dir, ], 'templates' => [ 'file' => $templatePath . 'Phinx/create.php.template', diff --git a/src/MigrationsPlugin.php b/src/MigrationsPlugin.php index 417f778f..4cff3d27 100644 --- a/src/MigrationsPlugin.php +++ b/src/MigrationsPlugin.php @@ -36,6 +36,7 @@ use Migrations\Command\MigrationsSeedCommand; use Migrations\Command\MigrationsStatusCommand; use Migrations\Command\RollbackCommand; +use Migrations\Command\SeedCommand; use Migrations\Command\StatusCommand; /** @@ -95,11 +96,12 @@ public function console(CommandCollection $commands): CommandCollection { if (Configure::read('Migrations.backend') == 'builtin') { $classes = [ - StatusCommand::class, + DumpCommand::class, MarkMigratedCommand::class, MigrateCommand::class, - DumpCommand::class, RollbackCommand::class, + SeedCommand::class, + StatusCommand::class, ]; if (class_exists(SimpleBakeCommand::class)) { $classes[] = BakeMigrationCommand::class; diff --git a/tests/TestCase/Command/Phinx/SeedTest.php b/tests/TestCase/Command/Phinx/SeedTest.php index 81994d92..d80f77c1 100644 --- a/tests/TestCase/Command/Phinx/SeedTest.php +++ b/tests/TestCase/Command/Phinx/SeedTest.php @@ -85,8 +85,11 @@ public function setUp(): void public function tearDown(): void { parent::tearDown(); + $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); $this->connection->execute('DROP TABLE IF EXISTS numbers'); + $this->connection->execute('DROP TABLE IF EXISTS letters'); + $this->connection->execute('DROP TABLE IF EXISTS stores'); } /** diff --git a/tests/TestCase/Command/RollbackCommandTest.php b/tests/TestCase/Command/RollbackCommandTest.php index 3c45bbc3..3fdbd08e 100644 --- a/tests/TestCase/Command/RollbackCommandTest.php +++ b/tests/TestCase/Command/RollbackCommandTest.php @@ -4,13 +4,13 @@ namespace Migrations\Test\TestCase\Command; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Console\TestSuite\StubConsoleOutput; use Cake\Core\Configure; use Cake\Database\Exception\DatabaseException; use Cake\Event\EventInterface; use Cake\Event\EventManager; use Cake\TestSuite\TestCase; use InvalidArgumentException; +use ReflectionProperty; class RollbackCommandTest extends TestCase { @@ -46,7 +46,10 @@ public function tearDown(): void protected function resetOutput(): void { - $this->_out = new StubConsoleOutput(); + if ($this->_out) { + $property = new ReflectionProperty($this->_out, '_out'); + $property->setValue($this->_out, []); + } } public function testHelp(): void diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php new file mode 100644 index 00000000..8a9bedcc --- /dev/null +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -0,0 +1,176 @@ +fetchTable('Phinxlog'); + try { + $table->deleteAll('1=1'); + } catch (DatabaseException $e) { + } + } + + public function tearDown(): void + { + parent::tearDown(); + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + $connection->execute('DROP TABLE IF EXISTS numbers'); + $connection->execute('DROP TABLE IF EXISTS letters'); + $connection->execute('DROP TABLE IF EXISTS stores'); + } + + protected function resetOutput(): void + { + if ($this->_out) { + $property = new ReflectionProperty($this->_out, '_out'); + $property->setValue($this->_out, []); + } + } + + protected function createTables(): void + { + $this->exec('migrations migrate -c test -s TestsMigrations --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + } + + public function testHelp(): void + { + $this->exec('migrations seed --help'); + $this->assertExitSuccess(); + $this->assertOutputContains('Seed the database with data'); + $this->assertOutputContains('migrations seed --connection secondary --seed UserSeeder'); + } + + public function testSeederEvents(): void + { + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeSeed', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + EventManager::instance()->on('Migration.afterSeed', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + + $this->createTables(); + $this->exec('migrations seed -c test --seed NumbersSeed'); + $this->assertExitSuccess(); + + $this->assertSame(['Migration.beforeSeed', 'Migration.afterSeed'], $fired); + } + + public function testBeforeSeederAbort(): void + { + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeSeed', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + $event->stopPropagation(); + }); + EventManager::instance()->on('Migration.afterSeed', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + + $this->createTables(); + $this->exec('migrations seed -c test --seed NumbersSeed'); + $this->assertExitError(); + + $this->assertSame(['Migration.beforeSeed'], $fired); + } + + public function testSeederUnknown(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The seed class "NotThere" does not exist'); + $this->exec('migrations seed -c test --seed NotThere'); + } + + public function testSeederOne(): void + { + $this->createTables(); + $this->exec('migrations seed -c test --seed NumbersSeed'); + + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersSeed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testSeederImplictAll(): void + { + $this->createTables(); + $this->exec('migrations seed -c test'); + + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersSeed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testSeederMultipleNotFound(): void + { + $this->createTables(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The seed class "NotThere" does not exist'); + $this->exec('migrations seed -c test --seed NumbersSeed --seed NotThere'); + } + + public function testSeederMultiple(): void + { + $this->createTables(); + $this->exec('migrations seed -c test --source CallSeeds --seed LettersSeed --seed NumbersCallSeed'); + + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersCallSeed: seeding'); + $this->assertOutputContains('LettersSeed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + + $query = $connection->execute('SELECT COUNT(*) FROM letters'); + $this->assertEquals(2, $query->fetchColumn(0)); + } + + public function testSeederSourceNotFound(): void + { + $this->createTables(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The seed class "LettersSeed" does not exist'); + + $this->exec('migrations seed -c test --source NotThere --seed LettersSeed'); + } +} diff --git a/tests/TestCase/Migration/ManagerTest.php b/tests/TestCase/Migration/ManagerTest.php index a0a452a9..9e109235 100644 --- a/tests/TestCase/Migration/ManagerTest.php +++ b/tests/TestCase/Migration/ManagerTest.php @@ -31,7 +31,7 @@ class ManagerTest extends TestCase protected $io; /** - * @var \Cake\Console\StubConsoleOutput $io + * @var \Cake\Console\TestSuite\StubConsoleOutput $io */ protected $out; @@ -601,7 +601,7 @@ public function testGetMigrationsWithDuplicateMigrationVersions() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/Duplicate migration/'); - $this->expectExceptionMessageMatches('/20120111235330_duplicate_migration_2.php" has the same version as "20120111235330"/'); + $this->expectExceptionMessageMatches('/20120111235330_duplicate_migration(_2)?.php" has the same version as "20120111235330"/'); $manager->getMigrations(); }