From 34e4f7b65e626d7833e4d6b1397569d63d7cb75c Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 9 Feb 2024 00:47:58 -0500 Subject: [PATCH 1/4] Start implementing commands using builtin backend - Wire up a simple status command that uses the new backend. This command needs a bunch of cleanup. But before I do any of that I want to clean up the Manager and Environment interfaces more now that I won't be able to simply inject this manager into the existing commands. Instead because of method types and property types I'm going to need to re-implement all the commands. This will let us simplify the Manager interface as it no longer needs to be swappable with Phinx's Manager class. - Deprecate ConfigurationTrait we won't need it after phinx is removed. --- src/Command/StatusCommand.php | 235 +++++++++++++++++++ src/ConfigurationTrait.php | 6 +- src/Migration/Environment.php | 1 + src/MigrationsPlugin.php | 65 +++-- tests/TestCase/Command/StatusCommandTest.php | 37 +++ 5 files changed, 323 insertions(+), 21 deletions(-) create mode 100644 src/Command/StatusCommand.php create mode 100644 tests/TestCase/Command/StatusCommandTest.php diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php new file mode 100644 index 00000000..a2ef56a6 --- /dev/null +++ b/src/Command/StatusCommand.php @@ -0,0 +1,235 @@ +setDescription([ + 'The status command prints a list of all migrations, along with their current status', + '', + 'migrations status -c secondary', + 'migrations status -c secondary -f json', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to run migrations for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'help' => 'The folder under src/Config that migrations are in', + 'default' => 'Migrations', + ])->addOption('format', [ + 'short' => 'f', + 'help' => 'The output format: text or json. Defaults to text.', + 'choices' => ['text', 'json'], + 'default' => 'text', + ]); + + return $parser; + } + + /** + * Generate a configuration object for the migrations operation. + * + * @param \Cake\Console\Arguments $args The console arguments + * @return \Migrations\Config\Config The generated config instance. + */ + protected function getConfig(Arguments $args): Config + { + $folder = (string)$args->getOption('source'); + + // Get the filepath for migrations and seeds(not implemented yet) + $dir = ROOT . '/config/' . $folder; + if (defined('CONFIG')) { + $dir = CONFIG . $folder; + } + $plugin = $args->getOption('plugin'); + if ($plugin && is_string($plugin)) { + $dir = Plugin::path($plugin) . 'config/' . $folder; + } + + // Get the phinxlog table name. Plugins have separate migration history. + // The names and separate table history is something we could change in the future. + $table = 'phinxlog'; + if ($plugin && is_string($plugin)) { + $prefix = Inflector::underscore($plugin) . '_'; + $prefix = str_replace(['\\', '/', '.'], '_', $prefix); + $table = $prefix . $table; + } + $templatePath = dirname(__DIR__) . DS . 'templates' . DS; + $connectionName = (string)$args->getOption('connection'); + + // TODO this all needs to go away. But first Environment and Manager need to work + // with Cake's ConnectionManager. + $connectionConfig = ConnectionManager::getConfig($connectionName); + if (!$connectionConfig) { + throw new StopException("Could not find connection `{$connectionName}`"); + } + + /** @var array $connectionConfig */ + $adapter = $connectionConfig['scheme'] ?? null; + $adapterConfig = [ + 'adapter' => $adapter, + 'user' => $connectionConfig['username'], + 'pass' => $connectionConfig['password'], + 'host' => $connectionConfig['host'], + 'name' => $connectionConfig['database'], + ]; + + $configData = [ + 'paths' => [ + 'migrations' => $dir, + ], + 'templates' => [ + 'file' => $templatePath . 'Phinx/create.php.template', + ], + 'migration_base_class' => 'Migrations\AbstractMigration', + 'environments' => [ + 'default_migration_table' => $table, + 'default' => $adapterConfig, + ], + // TODO do we want to support the DI container in migrations? + ]; + + return new Config($configData); + } + + /** + * Get the migration manager for the current CLI options and application configuration. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @return \Migrations\Migration\Manager + */ + protected function getManager(Arguments $args): Manager + { + $config = $this->getConfig($args); + + return new Manager($config, new ArgvInput(), new StreamOutput(STDOUT)); + } + + /** + * 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 + { + /** @var string|null $format */ + $format = $args->getOption('format'); + $migrations = $this->getManager($args)->printStatus('default', $format); + + switch ($format) { + case 'json': + $flags = 0; + if ($args->getOption('verbose')) { + $flags = JSON_PRETTY_PRINT; + } + $migrationString = (string)json_encode($migrations, $flags); + $io->out($migrationString); + break; + default: + $this->display($migrations, $io); + break; + } + + return Command::CODE_SUCCESS; + } + + /** + * Print migration status to stdout. + * + * @param array $migrations + * @param \Cake\Console\ConsoleIo $io The console io + * @return void + */ + protected function display(array $migrations, ConsoleIo $io): void + { + if (!empty($migrations)) { + $rows = []; + $rows[] = ['Status', 'Migration ID', 'Migration Name']; + + foreach ($migrations as $migration) { + $status = $migration['status'] === 'up' ? 'up' : 'down'; + $name = $migration['name'] ? + '' . $migration['name'] . '' : + '** MISSING **'; + + $missingComment = ''; + if (!empty($migration['missing'])) { + $missingComment = '** MISSING **'; + } + $rows[] = [$status, sprintf('%14.0f ', $migration['id']), $name . $missingComment]; + } + $io->helper('table')->output($rows); + } else { + $msg = 'There are no available migrations. Try creating one using the create command.'; + $io->err(''); + $io->err($msg); + $io->err(''); + } + } +} diff --git a/src/ConfigurationTrait.php b/src/ConfigurationTrait.php index f982caf4..bed99308 100644 --- a/src/ConfigurationTrait.php +++ b/src/ConfigurationTrait.php @@ -33,6 +33,8 @@ * the methods in phinx that are responsible for reading the project configuration. * This is needed so that we can use the application configuration instead of having * a configuration yaml file. + * + * @deprecated 4.2.0 Will be removed in 5.0 alongside phinx. */ trait ConfigurationTrait { @@ -114,15 +116,11 @@ public function getConfig(bool $forceRefresh = false): ConfigInterface mkdir($seedsPath, 0777, true); } - // TODO this should use Migrations\Config $phinxTable = $this->getPhinxTable($plugin); $connection = $this->getConnectionName($this->input()); $connectionConfig = (array)ConnectionManager::getConfig($connection); - - // TODO(mark) Replace this with cakephp connection - // instead of array parameter passing $adapterName = $this->getAdapterName($connectionConfig['driver']); $dsnOptions = $this->extractDsnOptions($adapterName, $connectionConfig); diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php index bbb2029d..bdb108d3 100644 --- a/src/Migration/Environment.php +++ b/src/Migration/Environment.php @@ -376,6 +376,7 @@ public function getAdapter(): AdapterInterface $adapter->setOutput($output); } + // TODO remove this, cake connections don't do prefixes. // Use the TablePrefixAdapter if table prefix/suffixes are in use if ($adapter->hasOption('table_prefix') || $adapter->hasOption('table_suffix')) { $adapter = AdapterFactory::instance() diff --git a/src/MigrationsPlugin.php b/src/MigrationsPlugin.php index c09bc718..ef4d8ea2 100644 --- a/src/MigrationsPlugin.php +++ b/src/MigrationsPlugin.php @@ -18,6 +18,10 @@ use Cake\Core\BasePlugin; use Cake\Core\Configure; use Cake\Core\PluginApplicationInterface; +use Migrations\Command\BakeMigrationCommand; +use Migrations\Command\BakeMigrationDiffCommand; +use Migrations\Command\BakeMigrationSnapshotCommand; +use Migrations\Command\BakeSeedCommand; use Migrations\Command\MigrationsCacheBuildCommand; use Migrations\Command\MigrationsCacheClearCommand; use Migrations\Command\MigrationsCommand; @@ -28,6 +32,7 @@ use Migrations\Command\MigrationsRollbackCommand; use Migrations\Command\MigrationsSeedCommand; use Migrations\Command\MigrationsStatusCommand; +use Migrations\Command\StatusCommand; /** * Plugin class for migrations @@ -84,25 +89,51 @@ public function bootstrap(PluginApplicationInterface $app): void */ public function console(CommandCollection $commands): CommandCollection { - if (class_exists(SimpleBakeCommand::class)) { - $found = $commands->discoverPlugin($this->getName()); + if (Configure::read('Migrations.backend') == 'builtin') { + $classes = [ + StatusCommand::class, + ]; + if (class_exists(SimpleBakeCommand::class)) { + $classes[] = BakeMigrationCommand::class; + $classes[] = BakeMigrationDiffCommand::class; + $classes[] = BakeMigrationSnapshotCommand::class; + $classes[] = BakeSeedCommand::class; + } + $found = []; + foreach ($classes as $class) { + $name = $class::defaultName(); + // If the short name has been used, use the full name. + // This allows app commands to have name preference. + // and app commands to overwrite migration commands. + if (!$commands->has($name)) { + $found[$name] = $class; + } + $found['migrations.' . $name] = $class; + } + $commands->addMany($found); - return $commands->addMany($found); - } - $found = []; - // Convert to a method and use config to toggle command names. - foreach ($this->migrationCommandsList as $class) { - $name = $class::defaultName(); - // If the short name has been used, use the full name. - // This allows app commands to have name preference. - // and app commands to overwrite migration commands. - if (!$commands->has($name)) { - $found[$name] = $class; + return $commands; + } else { + if (class_exists(SimpleBakeCommand::class)) { + $found = $commands->discoverPlugin($this->getName()); + + return $commands->addMany($found); + } + $found = []; + // Convert to a method and use config to toggle command names. + foreach ($this->migrationCommandsList as $class) { + $name = $class::defaultName(); + // If the short name has been used, use the full name. + // This allows app commands to have name preference. + // and app commands to overwrite migration commands. + if (!$commands->has($name)) { + $found[$name] = $class; + } + // full name + $found['migrations.' . $name] = $class; } - // full name - $found['migrations.' . $name] = $class; - } - return $commands->addMany($found); + return $commands->addMany($found); + } } } diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php new file mode 100644 index 00000000..3e5529db --- /dev/null +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -0,0 +1,37 @@ +exec('migrations status --help'); + $this->assertExitSuccess(); + $this->assertOutputContains('command prints a list of all migrations'); + $this->assertOutputContains('migrations status -c secondary'); + } + + public function testExecuteNoMigrations(): void + { + $this->exec('migrations status -c test'); + $this->assertExitSuccess(); + // Check for headers + $this->assertOutputContains('Status'); + $this->assertOutputContains('Migration ID'); + $this->assertOutputContains('Migration Name'); + } +} From 7d306ead42c4043be465d026f40ab33dc035c1cb Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 9 Feb 2024 00:53:40 -0500 Subject: [PATCH 2/4] Add incomplete tests. --- tests/TestCase/Command/StatusCommandTest.php | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php index 3e5529db..c157e6c6 100644 --- a/tests/TestCase/Command/StatusCommandTest.php +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -25,7 +25,7 @@ public function testHelp(): void $this->assertOutputContains('migrations status -c secondary'); } - public function testExecuteNoMigrations(): void + public function testExecuteSimple(): void { $this->exec('migrations status -c test'); $this->assertExitSuccess(); @@ -34,4 +34,24 @@ public function testExecuteNoMigrations(): void $this->assertOutputContains('Migration ID'); $this->assertOutputContains('Migration Name'); } + + public function testExecuteSimpleJson(): void + { + $this->markTestIncomplete(); + } + + public function testExecutePlugin(): void + { + $this->markTestIncomplete(); + } + + public function testExecutePluginDoesNotExist(): void + { + $this->markTestIncomplete(); + } + + public function testExecuteConnectionDoesNotExist(): void + { + $this->markTestIncomplete(); + } } From de3d8f0af958c44f7ae0a78943ab9b3bc248c463 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 9 Feb 2024 22:38:36 -0500 Subject: [PATCH 3/4] Fix baseline file for new deprecations --- psalm-baseline.xml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 0f869b2b..e3f6459d 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -10,15 +10,46 @@ + + ConfigurationTrait + $phinxName + + + ConfigurationTrait + + + + + ConfigurationTrait + + + + + ConfigurationTrait + + + + + ConfigurationTrait + + + + ConfigurationTrait + setInput + + + ConfigurationTrait + + getEnvironments @@ -125,6 +156,11 @@ $executedVersion + + + ConfigurationTrait + + $split[0] From 8254c7104dd0541ffb4be371eed2feeb3a6e980a Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sat, 10 Feb 2024 23:35:17 -0500 Subject: [PATCH 4/4] Expand tests. --- tests/TestCase/Command/StatusCommandTest.php | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php index c157e6c6..f06c6034 100644 --- a/tests/TestCase/Command/StatusCommandTest.php +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -5,6 +5,7 @@ use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Configure; +use Cake\Core\Exception\MissingPluginException; use Cake\TestSuite\TestCase; class StatusCommandTest extends TestCase @@ -37,21 +38,36 @@ public function testExecuteSimple(): void public function testExecuteSimpleJson(): void { - $this->markTestIncomplete(); + $this->exec('migrations status -c test --format json'); + $this->assertExitSuccess(); + + assert(isset($this->_out)); + $output = $this->_out->messages(); + $parsed = json_decode($output[0], true); + $this->assertTrue(is_array($parsed)); + $this->assertCount(1, $parsed); + $this->assertArrayHasKey('id', $parsed[0]); + $this->assertArrayHasKey('status', $parsed[0]); + $this->assertArrayHasKey('name', $parsed[0]); } public function testExecutePlugin(): void { - $this->markTestIncomplete(); + $this->loadPlugins(['Migrator']); + $this->exec('migrations status -c test -p Migrator'); + $this->assertExitSuccess(); + $this->assertOutputRegExp("/\|.*?down.*\|.*?Migrator.*?\|/"); } public function testExecutePluginDoesNotExist(): void { - $this->markTestIncomplete(); + $this->expectException(MissingPluginException::class); + $this->exec('migrations status -c test -p LolNope'); } public function testExecuteConnectionDoesNotExist(): void { - $this->markTestIncomplete(); + $this->exec('migrations status -c lolnope'); + $this->assertExitError(); } }