diff --git a/composer.json b/composer.json index b277a25e..984e0948 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ }, "require-dev": { "phpunit/phpunit": "~8.5.0", - "cakephp/cakephp": "^4.0.5", + "cakephp/cakephp": "dev-4.next as 4.3.0", "cakephp/bake": "^2.1.0", "cakephp/cakephp-codesniffer": "~4.1.0" }, diff --git a/src/TestSuite/ConfigReader.php b/src/TestSuite/ConfigReader.php new file mode 100644 index 00000000..efe9bfdc --- /dev/null +++ b/src/TestSuite/ConfigReader.php @@ -0,0 +1,141 @@ +getActiveConnections() as $connectionName) { + $connection = ConnectionManager::getConfig($connectionName); + $config = []; + + if (isset($connection['migrations'])) { + if ($connection['migrations'] === true) { + $config = ['connection' => $connectionName ]; + $this->normalizeArray($config); + } elseif (is_array($connection['migrations'])) { + $config = $connection['migrations']; + $this->normalizeArray($config); + foreach ($config as $k => $v) { + $config[$k]['connection'] = $config[$k]['connection'] ?? $connectionName; + } + + } + $this->config = array_merge($this->config, $config); + } + } + + $this->processConfig(); + + return $this; + } + + /** + * @param string[]|array[] $config An array of migration configs + * @return $this + */ + public function readConfig(array $config = []): self + { + if (!empty($config)) { + $this->normalizeArray($config); + $this->config = $config; + } + + $this->processConfig(); + + return $this; + } + + public function processConfig(): void + { + foreach ($this->config as $k => $config) { + $this->config[$k]['connection'] = $this->config[$k]['connection'] ?? 'test'; + } + if (empty($this->config)) { + $this->config = [['connection' => 'test']]; + } + } + + /** + * Initialize all connections used by the manager + * + * @return array + */ + public function getActiveConnections(): array + { + $connections = ConnectionManager::configured(); + foreach ($connections as $i => $connectionName) { + if ($this->skipConnection($connectionName)) { + unset($connections[$i]); + } + } + + return $connections; + } + + /** + * @param string $connectionName Connection name + * + * @return bool + */ + public function skipConnection(string $connectionName): bool + { + // CakePHP 4 solves a DebugKit issue by creating an Sqlite connection + // in tests/bootstrap.php. This connection should be ignored. + if ($connectionName === 'test_debug_kit') { + return true; + } + + if ($connectionName === 'test' || strpos($connectionName, 'test_') === 0) { + return false; + } + + return true; + } + + /** + * Make array an array of arrays + * + * @param array $array + * @return void + */ + public function normalizeArray(array &$array): void + { + if (!empty($array) && !isset($array[0])) { + $array = [$array]; + } + } + + /** + * @return array + */ + public function getConfig(): array + { + return $this->config; + } +} diff --git a/src/TestSuite/Migrator.php b/src/TestSuite/Migrator.php new file mode 100644 index 00000000..83c77aaa --- /dev/null +++ b/src/TestSuite/Migrator.php @@ -0,0 +1,185 @@ +readMigrationsInDatasources(); + $configReader->readConfig($config); + $migrator->handleMigrationsStatus($configReader->getConfig()); + + return $migrator; + } + + /** + * Run migrations for all configured migrations. + * + * @param string[] $config Migration configuration. + * @return void + */ + protected function runMigrations(array $config): void + { + $migrations = new Migrations(); + $result = $migrations->migrate($config); + + $msg = 'Migrations for ' . $this->stringifyConfig($config); + + + if ($result === true) { + $this->io->success($msg . ' successfully run.'); + } else { + $this->io->error( $msg . ' failed.'); + } + } + + /** + * If a migration is missing or down, all tables of the considered connection are dropped. + * + * @param array $configs Array of migration configurations to handle. + * @return $this + * @throws \Exception + */ + protected function handleMigrationsStatus(array $configs): self + { + $connectionsToDrop = []; + foreach ($configs as &$config) { + $connectionName = $config['connection'] = $config['connection'] ?? 'test'; + $this->io->info("Reading migrations status for {$this->stringifyConfig($config)}..."); + $migrations = new Migrations($config); + if ($this->isStatusChanged($migrations)) { + if (!in_array($connectionName, $connectionsToDrop)) { + $connectionsToDrop[] = $connectionName; + } + } + } + + if (empty($connectionsToDrop)) { + $this->io->success("No migration changes detected."); + + return $this; + } + + $schemaCleaner = new SchemaCleaner($this->io); + foreach ($connectionsToDrop as $connectionName) { + $schemaCleaner->dropTables($connectionName); + } + + foreach ($configs as $migration) { + $this->runMigrations($migration); + } + + // Truncate all created tables, except migration tables + foreach ($connectionsToDrop as $connectionName) { + $schema = ConnectionManager::get($connectionName)->getSchemaCollection(); + $allTables = $schema->listTables(); + $tablesToTruncate = $this->unsetMigrationTables($allTables); + $schemaCleaner->truncateTables($connectionName, $tablesToTruncate); + } + + return $this; + } + + /** + * Unset the phinx migration tables from an array of tables. + * + * @param string[] $tables + * @return array + */ + protected function unsetMigrationTables(array $tables): array + { + $endsWithPhinxlog = function (string $string) { + $needle = 'phinxlog'; + return substr($string, -strlen($needle)) === $needle; + }; + + foreach ($tables as $i => $table) { + if ($endsWithPhinxlog($table)) { + unset($tables[$i]); + } + } + + return array_values($tables); + } + + /** + * Checks if any migrations are up but missing. + * + * @param Migrations $migrations + * @return bool + */ + protected function isStatusChanged(Migrations $migrations): bool + { + foreach ($migrations->status() as $migration) { + if ($migration['status'] === 'up' && ($migration['missing'] ?? false)) { + $this->io->info('Missing migration(s) detected.'); + return true; + } + if ($migration['status'] === 'down') { + $this->io->info('New migration(s) found.'); + return true; + } + } + + return false; + } + + /** + * Stringify the migration parameters. + * This is used to display readable messages + * on the command line. + * + * @param string[] $config Config array + * @return string + */ + protected function stringifyConfig(array $config): string + { + $options = []; + foreach (['connection', 'plugin', 'source', 'target'] as $option) { + if (isset($config[$option])) { + $options[] = $option . ' "'.$config[$option].'"'; + } + } + + return implode(', ', $options); + } +} diff --git a/tests/MigratorTestTrait.php b/tests/MigratorTestTrait.php new file mode 100644 index 00000000..a8f85803 --- /dev/null +++ b/tests/MigratorTestTrait.php @@ -0,0 +1,44 @@ + getenv('db_dsn')]; + $testConfig['migrations'] = [ + ['source' => 'FooSource'], + ['plugin' => 'FooPlugin'], + ]; + + $this->setConfigIfNotDefined('test_migrator', $testConfig); + + $testConfig['migrations'] = ['plugin' => 'BarPlugin']; + $this->setConfigIfNotDefined('test_migrator_2', $testConfig); + + $testConfig['migrations'] = true; + $this->setConfigIfNotDefined('test_migrator_3', $testConfig); + } + + public function setConfigIfNotDefined(string $name, array $config): void + { + if (ConnectionManager::getConfig($name) === null) { + ConnectionManager::setConfig($name, $config); + } + } +} diff --git a/tests/TestCase/TestSuite/ConfigReaderTest.php b/tests/TestCase/TestSuite/ConfigReaderTest.php new file mode 100644 index 00000000..b31395e9 --- /dev/null +++ b/tests/TestCase/TestSuite/ConfigReaderTest.php @@ -0,0 +1,125 @@ +ConfigReader = new ConfigReader(); + } + + public function tearDown(): void + { + unset($this->ConfigReader); + } + + public function testSetConfigFromInjection(): void + { + $config = [ + ['connection' => 'Foo', 'plugin' => 'Bar',], + ['plugin' => 'Bar',] + ]; + + $expect = [ + ['connection' => 'Foo', 'plugin' => 'Bar',], + ['plugin' => 'Bar', 'connection' => 'test',] + ]; + + $this->ConfigReader->readConfig($config); + + $this->assertSame($expect, $this->ConfigReader->getConfig()); + } + + public function testSetConfigFromEmptyInjection(): void + { + $expect = [ + ['connection' => 'test'] + ]; + + $this->ConfigReader->readConfig(); + + $this->assertSame($expect, $this->ConfigReader->getConfig()); + } + + public function testSetConfigWithConfigureAndInjection(): void + { + $config1 = [ + 'connection' => 'Foo1_testSetConfigWithConfigureAndInjection', + 'plugin' => 'Bar1_testSetConfigWithConfigureAndInjection' + ]; + + $this->ConfigReader->readConfig($config1); + $this->assertSame([$config1], $this->ConfigReader->getConfig()); + } + + public function testReadMigrationsInDatasource(): void + { + $this->setDummyConnections(); + $this->ConfigReader->readMigrationsInDatasources(); + // Read empty config will not overwrite Datasource config + $this->ConfigReader->readConfig(); + $act = $this->ConfigReader->getConfig(); + $expected = [ + ['source' => 'FooSource', 'connection' => 'test_migrator'], + ['plugin' => 'FooPlugin', 'connection' => 'test_migrator'], + ['plugin' => 'BarPlugin', 'connection' => 'test_migrator_2'], + ['connection' => 'test_migrator_3'], + ]; + $this->assertSame($expected, $act); + } + + public function testReadMigrationsInDatasourceAndInjection(): void + { + $this->ConfigReader->readMigrationsInDatasources(); + // Read non-empty config will overwrite Datasource config + $this->ConfigReader->readConfig(['source' => 'Foo']); + $act = $this->ConfigReader->getConfig(); + $expected = [ + ['source' => 'Foo', 'connection' => 'test'], + ]; + $this->assertSame($expected, $act); + } + + public function arrays(): array + { + return [ + [['a' => 'b'], [['a' => 'b']]], + [[['a' => 'b']], [['a' => 'b']]], + [[], []], + ]; + } + + /** + * @dataProvider arrays + * @param array $input + * @param array $expect + */ + public function testNormalizeArray(array $input, array $expect): void + { + $this->ConfigReader->normalizeArray($input); + $this->assertSame($expect, $input); + } +} diff --git a/tests/TestCase/TestSuite/MigratorTest.php b/tests/TestCase/TestSuite/MigratorTest.php new file mode 100644 index 00000000..5ab7a38f --- /dev/null +++ b/tests/TestCase/TestSuite/MigratorTest.php @@ -0,0 +1,77 @@ +setDummyConnections(); + } + + public function tearDown(): void + { + (new SchemaCleaner())->dropTables('test'); + } + + private function fetchMigrationsInDB(string $dbTable): array + { + return ConnectionManager::get('test') + ->newQuery() + ->select('migration_name') + ->from($dbTable) + ->execute() + ->fetch(); + } + + public function testMigrate(): void + { + Migrator::migrate(); + + $appMigrations = $this->fetchMigrationsInDB('phinxlog'); + $fooPluginMigrations = $this->fetchMigrationsInDB('foo_plugin_phinxlog'); + $barPluginMigrations = $this->fetchMigrationsInDB('bar_plugin_phinxlog'); + + $this->assertSame(['MarkMigratedTest'], $appMigrations); + $this->assertSame(['FooMigration'], $fooPluginMigrations); + $this->assertSame(['BarMigration'], $barPluginMigrations); + + $Letters = TableRegistry::getTableLocator()->get('Letters'); + $this->assertSame('test', $Letters->getConnection()->configName()); + } + + public function testDropTablesForMissingMigrations(): void + { + Migrator::migrate(); + + $connection = ConnectionManager::get('test'); + $connection->insert('phinxlog', ['version' => 1, 'migration_name' => 'foo',]); + + $count = $connection->newQuery()->select('version')->from('phinxlog')->execute()->count(); + $this->assertSame(2, $count); + + Migrator::migrate(); + $count = $connection->newQuery()->select('version')->from('phinxlog')->execute()->count(); + $this->assertSame(1, $count); + } +} diff --git a/tests/test_app/Plugin/BarPlugin/config/Migrations/20200208100000_bar_migration.php b/tests/test_app/Plugin/BarPlugin/config/Migrations/20200208100000_bar_migration.php new file mode 100644 index 00000000..a50dd154 --- /dev/null +++ b/tests/test_app/Plugin/BarPlugin/config/Migrations/20200208100000_bar_migration.php @@ -0,0 +1,26 @@ +