diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0742f11e3..964f8b999 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: php-version: ['8.2', '8.5'] db-type: [mariadb, mysql, pgsql, sqlite] prefer-lowest: [''] + legacy-tables: [''] include: - php-version: '8.2' db-type: 'sqlite' @@ -32,6 +33,10 @@ jobs: db-type: 'mysql' - php-version: '8.3' db-type: 'pgsql' + # Test unified cake_migrations table (non-legacy mode) + - php-version: '8.3' + db-type: 'mysql' + legacy-tables: 'false' services: postgres: image: postgres @@ -135,6 +140,9 @@ jobs: export DB_URL='postgres://postgres:pg-password@127.0.0.1/cakephp_test' export DB_URL_SNAPSHOT='postgres://postgres:pg-password@127.0.0.1/cakephp_snapshot' fi + if [[ -n '${{ matrix.legacy-tables }}' ]]; then + export LEGACY_TABLES='${{ matrix.legacy-tables }}' + fi if [[ ${{ matrix.php-version }} == '8.1' && ${{ matrix.db-type }} == 'mysql' ]]; then vendor/bin/phpunit --coverage-clover=coverage.xml else diff --git a/docs/en/upgrading-to-builtin-backend.rst b/docs/en/upgrading-to-builtin-backend.rst index fe2a91067..ea2909433 100644 --- a/docs/en/upgrading-to-builtin-backend.rst +++ b/docs/en/upgrading-to-builtin-backend.rst @@ -102,6 +102,95 @@ Similar changes are for fetching a single row:: $stmt = $this->getAdapter()->query('SELECT * FROM articles'); $rows = $stmt->fetch('assoc'); +Unified Migrations Table +======================== + +As of migrations 5.x, there is a new unified ``cake_migrations`` table that +replaces the legacy ``phinxlog`` tables. This provides several benefits: + +- **Single table for all migrations**: Instead of separate ``phinxlog`` (app) + and ``{plugin}_phinxlog`` (plugins) tables, all migrations are tracked in + one ``cake_migrations`` table with a ``plugin`` column. +- **Simpler database schema**: Fewer migration tracking tables to manage. +- **Better plugin support**: Plugin migrations are properly namespaced. + +Backward Compatibility +---------------------- + +For existing applications with ``phinxlog`` tables: + +- **Automatic detection**: If any ``phinxlog`` table exists, migrations will + continue using the legacy tables automatically. +- **No forced migration**: Existing applications don't need to change anything. +- **Opt-in upgrade**: You can migrate to the new table when you're ready. + +Configuration +------------- + +The ``Migrations.legacyTables`` configuration option controls the behavior: + +.. code-block:: php + + // config/app.php or config/app_local.php + 'Migrations' => [ + // null (default): Autodetect - use legacy if phinxlog tables exist + // false: Force use of new cake_migrations table + // true: Force use of legacy phinxlog tables + 'legacyTables' => null, + ], + +Upgrading to the Unified Table +------------------------------ + +To migrate from ``phinxlog`` tables to the new ``cake_migrations`` table: + +1. **Preview the upgrade** (dry run): + + .. code-block:: bash + + bin/cake migrations upgrade --dry-run + +2. **Run the upgrade**: + + .. code-block:: bash + + bin/cake migrations upgrade + +3. **Update your configuration**: + + .. code-block:: php + + // config/app.php + 'Migrations' => [ + 'legacyTables' => false, + ], + +4. **Optionally drop phinx tables**: Your migration history is preserved + by default. Use ``--drop-tables`` to drop the ``phinxlog``tables after + verifying your migrations run correctly. + + .. code-block:: bash + + bin/cake migrations upgrade --drop-tables + +Rolling Back +------------ + +If you need to revert to phinx tables after upgrading: + +1. Set ``'legacyTables' => true`` in your configuration. + +.. warning:: + + You cannot rollback after running ``upgrade --drop-tables``. + + +New Installations +----------------- + +For new applications without any existing ``phinxlog`` tables, the unified +``cake_migrations`` table is used automatically. No configuration is needed. + Problems with the builtin backend? ================================== diff --git a/src/Command/BakeMigrationDiffCommand.php b/src/Command/BakeMigrationDiffCommand.php index e835dd063..a788e00c0 100644 --- a/src/Command/BakeMigrationDiffCommand.php +++ b/src/Command/BakeMigrationDiffCommand.php @@ -584,6 +584,9 @@ protected function getCurrentSchema(): array if (preg_match('/^.*phinxlog$/', $table) === 1) { continue; } + if ($table === 'cake_migrations' || $table === 'cake_seeds') { + continue; + } $schema[$table] = $collection->describe($table); } diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php new file mode 100644 index 000000000..1d766da2b --- /dev/null +++ b/src/Command/UpgradeCommand.php @@ -0,0 +1,295 @@ +setDescription([ + 'Upgrades migration tracking from legacy phinxlog tables to unified cake_migrations table.', + '', + 'This command migrates data from:', + ' - phinxlog (app migrations)', + ' - {plugin}_phinxlog (plugin migrations)', + '', + 'To the unified cake_migrations table with a plugin column.', + '', + 'After running this command, set Migrations.legacyTables = false', + 'in your configuration to use the new table.', + '', + 'migrations upgrade --dry-run Preview changes', + 'migrations upgrade Execute the upgrade', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('dry-run', [ + 'boolean' => true, + 'help' => 'Preview what would be migrated without making changes', + 'default' => false, + ])->addOption('drop-tables', [ + 'boolean' => true, + 'help' => 'Drop legacy phinxlog tables after migration', + 'default' => false, + ]); + + 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 + { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get((string)$args->getOption('connection')); + $dryRun = (bool)$args->getOption('dry-run'); + $dropTables = (bool)$args->getOption('drop-tables'); + + if ($dryRun) { + $io->out('DRY RUN - No changes will be made'); + $io->out(''); + } + + // Find all legacy phinxlog tables + $legacyTables = $this->findLegacyTables($connection); + + if ($legacyTables === []) { + $io->out('No phinxlog tables found. Nothing to upgrade.'); + + return self::CODE_SUCCESS; + } + + $io->out(sprintf('Found %d phinxlog table(s):', count($legacyTables))); + foreach ($legacyTables as $table => $plugin) { + $pluginLabel = $plugin === null ? '(app)' : "({$plugin})"; + $io->out(" - {$table} {$pluginLabel}"); + } + $io->out(''); + + // Create unified table if needed + $unifiedTableName = UnifiedMigrationsTableStorage::TABLE_NAME; + if (!$this->tableExists($connection, $unifiedTableName)) { + $io->out("Creating unified table {$unifiedTableName}..."); + if (!$dryRun) { + $this->createUnifiedTable($connection, $io); + } + } else { + $io->out("Unified table {$unifiedTableName} already exists."); + } + $io->out(''); + + // Migrate data from each legacy table + $totalMigrated = 0; + foreach ($legacyTables as $tableName => $plugin) { + $count = $this->migrateTable($connection, $tableName, $plugin, $dryRun, $io); + $totalMigrated += $count; + } + + $io->out(''); + $io->out(sprintf('Total records migrated: %d', $totalMigrated)); + + if (!$dryRun) { + // Clean up legacy tables + $io->out(''); + foreach ($legacyTables as $tableName => $plugin) { + if ($dropTables) { + $io->out("Dropping legacy table {$tableName}..."); + $connection->execute("DROP TABLE {$connection->getDriver()->quoteIdentifier($tableName)}"); + } else { + $io->out('Retaining legacy table. You should drop these tables once you have verified your upgrade.'); + } + } + + $io->out(''); + $io->success('Upgrade complete!'); + $io->out(''); + $io->out('Next steps:'); + $io->out(' 1. Set \'Migrations\' => [\'legacyTables\' => false] in your config'); + $io->out(' 2. Test your application'); + if (!$dropTables) { + $io->out(' 3. Optionally drop the empty phinxlog tables (re-run `bin/cake migrations upgrade --drop-tables`)'); + } + } else { + $io->out(''); + $io->out('This was a dry run. Run without --dry-run to execute.'); + } + + return self::CODE_SUCCESS; + } + + /** + * Find all legacy phinxlog tables in the database. + * + * @param \Cake\Database\Connection $connection Database connection + * @return array Map of table name => plugin name (null for app) + */ + protected function findLegacyTables(Connection $connection): array + { + $schema = $connection->getDriver()->schemaDialect(); + $tables = $schema->listTables(); + $legacyTables = []; + + foreach ($tables as $table) { + if ($table === 'phinxlog') { + $legacyTables[$table] = null; + } elseif (str_ends_with($table, '_phinxlog')) { + // Extract plugin name from table name + $prefix = substr($table, 0, -9); // Remove '_phinxlog' + $plugin = Inflector::camelize($prefix); + $legacyTables[$table] = $plugin; + } + } + + return $legacyTables; + } + + /** + * Check if a table exists. + * + * @param \Cake\Database\Connection $connection Database connection + * @param string $tableName Table name + * @return bool + */ + protected function tableExists(Connection $connection, string $tableName): bool + { + $schema = $connection->getDriver()->schemaDialect(); + + return $schema->hasTable($tableName); + } + + /** + * Create the unified migrations table. + * + * @param \Cake\Database\Connection $connection Database connection + * @param \Cake\Console\ConsoleIo $io Console IO + * @return void + */ + protected function createUnifiedTable(Connection $connection, ConsoleIo $io): void + { + $factory = new ManagerFactory([ + 'plugin' => null, + 'source' => null, + 'connection' => $connection->configName(), + // This doesn't follow the cli flag as this method is only called when creating the table. + 'dry-run' => false, + ]); + + $manager = $factory->createManager($io); + $adapter = $manager->getEnvironment()->getAdapter(); + if ($adapter instanceof WrapperInterface) { + $adapter = $adapter->getAdapter(); + } + assert($adapter instanceof AbstractAdapter, 'adapter must be an AbstractAdapter'); + + $storage = new UnifiedMigrationsTableStorage($adapter); + $storage->createTable(); + } + + /** + * Migrate data from a phinx table to the unified table. + * + * @param \Cake\Database\Connection $connection Database connection + * @param string $tableName Legacy table name + * @param string|null $plugin Plugin name (null for app) + * @param bool $dryRun Whether this is a dry run + * @param \Cake\Console\ConsoleIo $io Console IO + * @return int Number of records migrated + */ + protected function migrateTable( + Connection $connection, + string $tableName, + ?string $plugin, + bool $dryRun, + ConsoleIo $io, + ): int { + $unifiedTable = UnifiedMigrationsTableStorage::TABLE_NAME; + $pluginLabel = $plugin ?? 'app'; + + // Read all records from legacy table + $query = $connection->selectQuery() + ->select('*') + ->from($tableName); + $rows = $query->execute()->fetchAll('assoc'); + + $count = count($rows); + $io->out("Migrating {$count} record(s) from {$tableName} ({$pluginLabel})..."); + + if ($dryRun || $count === 0) { + return $count; + } + + // Insert into unified table + foreach ($rows as $row) { + try { + $insertQuery = $connection->insertQuery() + ->insert(['version', 'migration_name', 'plugin', 'start_time', 'end_time', 'breakpoint']) + ->into($unifiedTable) + ->values([ + 'version' => $row['version'], + 'migration_name' => $row['migration_name'] ?? null, + 'plugin' => $plugin, + 'start_time' => $row['start_time'] ?? null, + 'end_time' => $row['end_time'] ?? null, + 'breakpoint' => $row['breakpoint'] ?? 0, + ]); + $insertQuery->execute(); + } catch (QueryException $e) { + $io->out('Already migrated ' . $row['migration_name'] . '.'); + } + } + + return $count; + } +} diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index e2e9c4032..9ff8e0234 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -304,10 +304,18 @@ protected function verboseLog(string $message): void /** * Gets the schema table name. * + * Returns the appropriate table name based on configuration: + * - 'cake_migrations' for unified mode + * - Phinxlog table name for backwards compatibility mode + * * @return string */ public function getSchemaTableName(): string { + if ($this->isUsingUnifiedTable()) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + return $this->schemaTableName; } @@ -836,15 +844,35 @@ public function getVersions(): array return array_keys($rows); } + /** + * @inheritDoc + */ + public function cleanupMissing(array $missingVersions): void + { + $storage = $this->migrationsTable(); + + $storage->cleanupMissing($missingVersions); + } + /** * Get the migrations table storage implementation. * - * @return \Migrations\Db\Adapter\MigrationsTableStorage + * Returns either UnifiedMigrationsTableStorage (new cake_migrations table) + * or MigrationsTableStorage (legacy phinxlog tables) based on configuration + * and autodetection. + * + * @return \Migrations\Db\Adapter\MigrationsTableStorage|\Migrations\Db\Adapter\UnifiedMigrationsTableStorage * @internal */ - protected function migrationsTable(): MigrationsTableStorage + protected function migrationsTable(): MigrationsTableStorage|UnifiedMigrationsTableStorage { - // TODO Use configure/auto-detect which implmentation to use. + if ($this->isUsingUnifiedTable()) { + return new UnifiedMigrationsTableStorage( + $this, + $this->getOption('plugin'), + ); + } + return new MigrationsTableStorage( $this, $this->getSchemaTableName(), @@ -852,6 +880,39 @@ protected function migrationsTable(): MigrationsTableStorage ); } + /** + * Determine if using the unified cake_migrations table. + * + * Checks configuration and autodetects based on existing legacy tables. + * + * @return bool True if using unified table, false for legacy phinxlog tables + */ + protected function isUsingUnifiedTable(): bool + { + $config = Configure::read('Migrations.legacyTables'); + + // Explicit configuration takes precedence + if ($config === false) { + return true; + } + + if ($config === true) { + return false; + } + + // Autodetect mode (config is null or not set) + // Check if the main legacy phinxlog table exists + if ($this->connection !== null) { + $dialect = $this->connection->getDriver()->schemaDialect(); + if ($dialect->hasTable('phinxlog')) { + return false; + } + } + + // No legacy phinxlog table found - use unified table + return true; + } + /** * {@inheritDoc} * diff --git a/src/Db/Adapter/AdapterInterface.php b/src/Db/Adapter/AdapterInterface.php index 15c30fe94..8ada10141 100644 --- a/src/Db/Adapter/AdapterInterface.php +++ b/src/Db/Adapter/AdapterInterface.php @@ -650,6 +650,16 @@ public function createDatabase(string $name, array $options = []): void; */ public function hasDatabase(string $name): bool; + /** + * Cleanup missing migrations from the phinxlog table + * + * Removes entries from the phinxlog table for migrations that no longer exist + * in the migrations directory (marked as MISSING in status output). + * + * @return void + */ + public function cleanupMissing(array $missingVersions): void; + /** * Drops the specified database. * diff --git a/src/Db/Adapter/AdapterWrapper.php b/src/Db/Adapter/AdapterWrapper.php index a291db0fd..dba0f3681 100644 --- a/src/Db/Adapter/AdapterWrapper.php +++ b/src/Db/Adapter/AdapterWrapper.php @@ -183,6 +183,14 @@ public function getVersionLog(): array return $this->getAdapter()->getVersionLog(); } + /** + * @inheritDoc + */ + public function cleanupMissing(array $missingVersions): void + { + $this->getAdapter()->cleanupMissing($missingVersions); + } + /** * @inheritDoc */ diff --git a/src/Db/Adapter/MigrationsTableStorage.php b/src/Db/Adapter/MigrationsTableStorage.php index 88950a978..e67c40bd6 100644 --- a/src/Db/Adapter/MigrationsTableStorage.php +++ b/src/Db/Adapter/MigrationsTableStorage.php @@ -59,6 +59,31 @@ public function getVersions(array $orderBy): SelectQuery return $query; } + /** + * Cleanup missing migrations from the phinxlog table + * + * Removes entries from the phinxlog table for migrations that no longer exist + * in the migrations directory (marked as MISSING in status output). + * + * @param array $missingVersions The list of missing migration versions. + * @return void + */ + public function cleanupMissing(array $missingVersions): void + { + $this->adapter->beginTransaction(); + try { + $where = ['version IN' => $missingVersions]; + $delete = $this->adapter->getDeleteBuilder() + ->from($this->schemaTableName) + ->where($where); + $delete->execute(); + $this->adapter->commitTransaction(); + } catch (Exception $e) { + $this->adapter->rollbackTransaction(); + throw $e; + } + } + /** * Records that a migration was run in the database. * diff --git a/src/Db/Adapter/UnifiedMigrationsTableStorage.php b/src/Db/Adapter/UnifiedMigrationsTableStorage.php new file mode 100644 index 000000000..2562247da --- /dev/null +++ b/src/Db/Adapter/UnifiedMigrationsTableStorage.php @@ -0,0 +1,255 @@ +adapter->beginTransaction(); + try { + $where = ['version IN' => $missingVersions]; + $where['plugin IS'] = $this->adapter->getOption('plugin'); + + $delete = $this->adapter->getDeleteBuilder() + ->from(self::TABLE_NAME) + ->where($where); + $delete->execute(); + $this->adapter->commitTransaction(); + } catch (Exception $e) { + $this->adapter->rollbackTransaction(); + throw $e; + } + } + + /** + * Gets all the migration versions for the current plugin context. + * + * @param array $orderBy The order by clause. + * @return \Cake\Database\Query\SelectQuery + */ + public function getVersions(array $orderBy): SelectQuery + { + $query = $this->adapter->getSelectBuilder(); + $query->select('*') + ->from(self::TABLE_NAME) + ->where(['plugin IS' => $this->plugin]) + ->orderBy($orderBy); + + return $query; + } + + /** + * Records that a migration was run in the database. + * + * @param \Migrations\MigrationInterface $migration Migration + * @param string $startTime Start time + * @param string $endTime End time + * @return void + */ + public function recordUp(MigrationInterface $migration, string $startTime, string $endTime): void + { + $query = $this->adapter->getInsertBuilder(); + $query->insert(['version', 'migration_name', 'plugin', 'start_time', 'end_time', 'breakpoint']) + ->into(self::TABLE_NAME) + ->values([ + 'version' => (string)$migration->getVersion(), + 'migration_name' => substr($migration->getName(), 0, 100), + 'plugin' => $this->plugin, + 'start_time' => $startTime, + 'end_time' => $endTime, + 'breakpoint' => 0, + ]); + $this->adapter->executeQuery($query); + } + + /** + * Removes the record of a migration having been run. + * + * @param \Migrations\MigrationInterface $migration Migration + * @return void + */ + public function recordDown(MigrationInterface $migration): void + { + $query = $this->adapter->getDeleteBuilder(); + $query->delete() + ->from(self::TABLE_NAME) + ->where([ + 'version' => (string)$migration->getVersion(), + 'plugin IS' => $this->plugin, + ]); + + $this->adapter->executeQuery($query); + } + + /** + * Toggles the breakpoint state of a migration. + * + * @param \Migrations\MigrationInterface $migration Migration + * @return void + */ + public function toggleBreakpoint(MigrationInterface $migration): void + { + $pluginCondition = $this->plugin === null + ? sprintf('%s IS NULL', $this->adapter->quoteColumnName('plugin')) + : sprintf('%s = ?', $this->adapter->quoteColumnName('plugin')); + + $params = $this->plugin === null + ? [$migration->getVersion()] + : [$migration->getVersion(), $this->plugin]; + + $this->adapter->query( + sprintf( + 'UPDATE %1$s SET %2$s = CASE %2$s WHEN true THEN false ELSE true END, %4$s = %4$s WHERE %3$s = ? AND %5$s;', + $this->adapter->quoteTableName(self::TABLE_NAME), + $this->adapter->quoteColumnName('breakpoint'), + $this->adapter->quoteColumnName('version'), + $this->adapter->quoteColumnName('start_time'), + $pluginCondition, + ), + $params, + ); + } + + /** + * Resets all breakpoints for the current plugin context. + * + * @return int The number of affected rows. + */ + public function resetAllBreakpoints(): int + { + $query = $this->adapter->getUpdateBuilder(); + $query->update(self::TABLE_NAME) + ->set([ + 'breakpoint' => 0, + 'start_time' => $query->identifier('start_time'), + ]) + ->where([ + 'breakpoint !=' => 0, + 'plugin IS' => $this->plugin, + ]); + + return $this->adapter->executeQuery($query); + } + + /** + * Marks a migration as a breakpoint or not depending on $state. + * + * @param \Migrations\MigrationInterface $migration Migration + * @param bool $state The breakpoint state to set. + * @return void + */ + public function markBreakpoint(MigrationInterface $migration, bool $state): void + { + $query = $this->adapter->getUpdateBuilder(); + $query->update(self::TABLE_NAME) + ->set([ + 'breakpoint' => (int)$state, + 'start_time' => $query->identifier('start_time'), + ]) + ->where([ + 'version' => $migration->getVersion(), + 'plugin IS' => $this->plugin, + ]); + + $this->adapter->executeQuery($query); + } + + /** + * Creates the unified migration storage table. + * + * @return void + * @throws \InvalidArgumentException When there is a problem creating the table. + */ + public function createTable(): void + { + try { + $options = [ + 'id' => true, + 'primary_key' => 'id', + ]; + + $table = new Table(self::TABLE_NAME, $options, $this->adapter); + $table->addColumn('version', 'biginteger', ['null' => false]) + ->addColumn('migration_name', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('plugin', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('start_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('end_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->addIndex(['version', 'plugin'], ['unique' => true, 'name' => 'version_plugin_unique']) + ->save(); + } catch (Exception $exception) { + throw new InvalidArgumentException( + 'There was a problem creating the migrations table: ' . $exception->getMessage(), + (int)$exception->getCode(), + $exception, + ); + } + } + + /** + * Upgrades the migration storage table if needed. + * + * Since the unified cake_migrations table is new in v5.0 and always created + * with all required columns, this is currently a no-op. Future schema changes + * would add upgrade logic here. + * + * @return void + */ + public function upgradeTable(): void + { + // No-op for new installations. Schema upgrades can be added here + // if the table structure changes in future versions. + } +} diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 8da6ae4b6..96785b151 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -1357,18 +1357,8 @@ public function cleanupMissingMigrations(): int return 0; } - // Remove missing migrations from phinxlog - $adapter->beginTransaction(); - try { - $delete = $adapter->getDeleteBuilder() - ->from($env->getSchemaTableName()) - ->where(['version IN' => $missingVersions]); - $delete->execute(); - $adapter->commitTransaction(); - } catch (Exception $e) { - $adapter->rollbackTransaction(); - throw $e; - } + // Remove missing migrations from migrations table + $adapter->cleanupMissing($missingVersions); return count($missingVersions); } diff --git a/src/MigrationsPlugin.php b/src/MigrationsPlugin.php index f1f3535d0..57f66dfe4 100644 --- a/src/MigrationsPlugin.php +++ b/src/MigrationsPlugin.php @@ -16,6 +16,7 @@ use Bake\Command\SimpleBakeCommand; use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; +use Cake\Core\Configure; use Cake\Core\PluginApplicationInterface; use Migrations\Command\BakeMigrationCommand; use Migrations\Command\BakeMigrationDiffCommand; @@ -31,6 +32,7 @@ use Migrations\Command\SeedsEntryCommand; use Migrations\Command\SeedStatusCommand; use Migrations\Command\StatusCommand; +use Migrations\Command\UpgradeCommand; /** * Plugin class for migrations @@ -74,6 +76,12 @@ public function console(CommandCollection $commands): CommandCollection RollbackCommand::class, StatusCommand::class, ]; + + // Only show upgrade command if not explicitly using unified table + // (i.e., when legacyTables is null/autodetect or true) + if (Configure::read('Migrations.legacyTables') !== false) { + $migrationClasses[] = UpgradeCommand::class; + } $seedClasses = [ SeedsEntryCommand::class, SeedCommand::class, diff --git a/src/TestSuite/Migrator.php b/src/TestSuite/Migrator.php index 598a0780f..a33e95263 100644 --- a/src/TestSuite/Migrator.php +++ b/src/TestSuite/Migrator.php @@ -264,6 +264,7 @@ protected function getNonPhinxTables(string $connection, array $skip): array assert($connection instanceof Connection); $tables = $connection->getSchemaCollection()->listTables(); $skip[] = '*phinxlog*'; + $skip[] = 'cake_migrations'; return array_filter($tables, function ($table) use ($skip) { foreach ($skip as $pattern) { diff --git a/src/Util/TableFinder.php b/src/Util/TableFinder.php index 63e7ebf6e..a96b4e03b 100644 --- a/src/Util/TableFinder.php +++ b/src/Util/TableFinder.php @@ -30,7 +30,7 @@ class TableFinder * * @var string[] */ - public array $skipTables = ['phinxlog']; + public array $skipTables = ['phinxlog', 'cake_migrations']; /** * Regex of Table name to skip diff --git a/src/Util/Util.php b/src/Util/Util.php index 33e5a1fbe..90f101f9a 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -8,9 +8,11 @@ namespace Migrations\Util; +use Cake\Core\Configure; use Cake\Utility\Inflector; use DateTime; use DateTimeZone; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use RuntimeException; /** @@ -249,6 +251,11 @@ public static function getFiles(string|array $paths): array */ public static function tableName(?string $plugin): string { + // When using unified table, always return the same table name + if (Configure::read('Migrations.legacyTables') === false) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + $table = 'phinxlog'; if ($plugin) { $prefix = Inflector::underscore($plugin) . '_'; diff --git a/src/Util/UtilTrait.php b/src/Util/UtilTrait.php index c44617c8c..fc3ab3b89 100644 --- a/src/Util/UtilTrait.php +++ b/src/Util/UtilTrait.php @@ -13,7 +13,10 @@ */ namespace Migrations\Util; +use Cake\Core\Configure; +use Cake\Database\Connection; use Cake\Utility\Inflector; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; /** * Trait gathering useful methods needed in various places of the plugin @@ -21,12 +24,50 @@ trait UtilTrait { /** - * Get the phinx table name used to store migrations data + * Get the migrations table name used to store migrations data. + * + * In v5.0+, this returns either: + * - 'cake_migrations' (unified table) for new installations + * - Legacy phinxlog table names for existing installations with phinxlog tables + * + * The behavior is controlled by `Migrations.legacyTables` config: + * - null (default): Autodetect - use legacy if phinxlog tables exist + * - false: Always use new cake_migrations table + * - true: Always use legacy phinxlog tables * * @param string|null $plugin Plugin name + * @param \Cake\Database\Connection|null $connection Database connection for autodetect * @return string */ - protected function getPhinxTable(?string $plugin = null): string + protected function getPhinxTable(?string $plugin = null, ?Connection $connection = null): string + { + $config = Configure::read('Migrations.legacyTables'); + + // Explicit configuration takes precedence + if ($config === false) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + + if ($config === true) { + return $this->getLegacyTableName($plugin); + } + + // Autodetect mode (config is null or not set) + if ($connection !== null && $this->detectLegacyTables($connection)) { + return $this->getLegacyTableName($plugin); + } + + // No legacy tables detected or no connection provided - use new table + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + + /** + * Get the legacy phinxlog table name. + * + * @param string|null $plugin Plugin name + * @return string + */ + protected function getLegacyTableName(?string $plugin = null): string { $table = 'phinxlog'; @@ -39,4 +80,43 @@ protected function getPhinxTable(?string $plugin = null): string return $plugin . $table; } + + /** + * Detect if any legacy phinxlog tables exist in the database. + * + * @param \Cake\Database\Connection $connection Database connection + * @return bool True if legacy tables exist + */ + protected function detectLegacyTables(Connection $connection): bool + { + $dialect = $connection->getDriver()->schemaDialect(); + + return $dialect->hasTable('phinxlog'); + } + + /** + * Check if the system is using legacy migration tables. + * + * @param \Cake\Database\Connection|null $connection Database connection for autodetect + * @return bool + */ + protected function isUsingLegacyTables(?Connection $connection = null): bool + { + $config = Configure::read('Migrations.legacyTables'); + + if ($config === false) { + return false; + } + + if ($config === true) { + return true; + } + + // Autodetect + if ($connection !== null) { + return $this->detectLegacyTables($connection); + } + + return false; + } } diff --git a/tests/TestCase/Command/BakeMigrationDiffCommandTest.php b/tests/TestCase/Command/BakeMigrationDiffCommandTest.php index 576d0756a..3f3f483a0 100644 --- a/tests/TestCase/Command/BakeMigrationDiffCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationDiffCommandTest.php @@ -25,8 +25,10 @@ use Cake\TestSuite\StringCompareTrait; use Cake\Utility\Inflector; use Exception; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use Migrations\Migrations; use Migrations\Test\TestCase\TestCase; +use Migrations\Util\UtilTrait; use function Cake\Core\env; /** @@ -35,6 +37,7 @@ class BakeMigrationDiffCommandTest extends TestCase { use StringCompareTrait; + use UtilTrait; /** * @var string[] @@ -51,6 +54,8 @@ public function setUp(): void parent::setUp(); $this->generatedFiles = []; + $this->clearMigrationRecords('test'); + $this->clearMigrationRecords('test', 'Blog'); // Clean up any TheDiff migration files from all directories before test starts $configPath = ROOT . DS . 'config' . DS; @@ -112,8 +117,9 @@ public function tearDown(): void if (env('DB_URL_COMPARE')) { // Clean up the comparison database each time. Table order is important. + // Include both legacy (phinxlog) and unified (cake_migrations) table names. $connection = ConnectionManager::get('test_comparisons'); - $tables = ['articles', 'categories', 'comments', 'users', 'orphan_table', 'phinxlog', 'tags', 'test_blog_phinxlog', 'test_decimal_types']; + $tables = ['articles', 'categories', 'comments', 'users', 'orphan_table', 'phinxlog', 'cake_migrations', 'tags', 'test_blog_phinxlog', 'test_decimal_types']; foreach ($tables as $table) { $connection->execute("DROP TABLE IF EXISTS $table"); } @@ -407,6 +413,9 @@ protected function runDiffBakingTest(string $scenario): void $diffDumpPath = $diffConfigFolder . 'schema-dump-test_comparisons_' . $db . '.lock'; $destinationConfigDir = ROOT . DS . 'config' . DS . "MigrationsDiff{$scenario}" . DS; + if (!is_dir($destinationConfigDir)) { + mkdir($destinationConfigDir, 0777, true); + } $destination = $destinationConfigDir . "20160415220805_{$classPrefix}{$scenario}" . ucfirst($db) . '.php'; $destinationDumpPath = $destinationConfigDir . 'schema-dump-test_comparisons_' . $db . '.lock'; copy($diffMigrationsPath, $destination); @@ -419,15 +428,19 @@ protected function runDiffBakingTest(string $scenario): void $migrations = $this->getMigrations("MigrationsDiff$scenario"); $migrations->migrate(); - unlink($destination); copy($diffDumpPath, $destinationDumpPath); $connection = ConnectionManager::get('test_comparisons'); + $schemaTable = $this->getPhinxTable(null, $connection); $connection->deleteQuery() - ->delete('phinxlog') + ->delete($schemaTable) ->where(['version' => 20160415220805]) ->execute(); + // Delete the migration file too - checkSync() compares the last file version + // against the last migrated version, so having an unmigrated file would fail + unlink($destination); + $this->_compareBasePath = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Diff' . DS . lcfirst($scenario) . DS; $bakeName = $this->getBakeName("TheDiff{$scenario}"); @@ -446,15 +459,21 @@ protected function runDiffBakingTest(string $scenario): void rename($destinationConfigDir . $generatedMigration, $destination); $versionParts = explode('_', $generatedMigration); + $columns = ['version', 'migration_name', 'start_time', 'end_time']; + $values = [ + 'version' => 20160415220805, + 'migration_name' => $versionParts[1], + 'start_time' => '2016-05-22 16:51:46', + 'end_time' => '2016-05-22 16:51:46', + ]; + if ($schemaTable === UnifiedMigrationsTableStorage::TABLE_NAME) { + $columns[] = 'plugin'; + $values['plugin'] = null; + } $connection->insertQuery() - ->insert(['version', 'migration_name', 'start_time', 'end_time']) - ->into('phinxlog') - ->values([ - 'version' => 20160415220805, - 'migration_name' => $versionParts[1], - 'start_time' => '2016-05-22 16:51:46', - 'end_time' => '2016-05-22 16:51:46', - ]) + ->insert($columns) + ->into($schemaTable) + ->values($values) ->execute(); $this->getMigrations("MigrationsDiff{$scenario}")->rollback(['target' => 'all']); } @@ -517,6 +536,21 @@ protected function getMigrations($source = 'MigrationsDiff') return $migrations; } + /** + * Override to normalize table names for comparison + * + * @param string $path Path to comparison file + * @param string $result Actual result + * @return void + */ + public function assertSameAsFile(string $path, string $result): void + { + // Normalize unified table name to legacy for comparison + $result = str_replace("'cake_migrations'", "'phinxlog'", $result); + + parent::assertSameAsFile($path, $result); + } + /** * Assert that the $result matches the content of the baked file * diff --git a/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php b/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php index bd5037665..7a3ad64bc 100644 --- a/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php @@ -321,6 +321,9 @@ public function assertSameAsFile(string $path, string $result): void $expected = str_replace('utf8mb3_', 'utf8_', $expected); $result = str_replace('utf8mb3_', 'utf8_', $result); + // Normalize unified table name to legacy for comparison + $result = str_replace("'cake_migrations'", "'phinxlog'", $result); + $this->assertTextEquals($expected, $result, 'Content does not match file ' . $path); } diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index d0d7f70ee..b05f0a4da 100644 --- a/tests/TestCase/Command/CompletionTest.php +++ b/tests/TestCase/Command/CompletionTest.php @@ -14,6 +14,7 @@ namespace Migrations\Test\TestCase\Command; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Core\Configure; use Cake\TestSuite\TestCase; /** @@ -43,9 +44,16 @@ public function tearDown(): void public function testMigrationsSubcommands() { $this->exec('completion subcommands migrations.migrations'); - $expected = [ - 'dump mark_migrated migrate rollback status', - ]; + // Upgrade command is hidden when legacyTables is disabled + if (Configure::read('Migrations.legacyTables') === false) { + $expected = [ + 'dump mark_migrated migrate rollback status', + ]; + } else { + $expected = [ + 'dump mark_migrated migrate rollback status upgrade', + ]; + } $actual = $this->_out->messages(); $this->assertEquals($expected, $actual); } diff --git a/tests/TestCase/Command/DumpCommandTest.php b/tests/TestCase/Command/DumpCommandTest.php index e6035f6f0..7dba4d8bc 100644 --- a/tests/TestCase/Command/DumpCommandTest.php +++ b/tests/TestCase/Command/DumpCommandTest.php @@ -3,19 +3,16 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Exception\MissingPluginException; use Cake\Core\Plugin; use Cake\Database\Connection; use Cake\Database\Schema\TableSchema; use Cake\Datasource\ConnectionManager; -use Cake\TestSuite\TestCase; +use Migrations\Test\TestCase\TestCase; use RuntimeException; class DumpCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - protected Connection $connection; protected string $_compareBasePath; protected string $dumpFile; @@ -30,6 +27,7 @@ public function setUp(): void $this->connection->execute('DROP TABLE IF EXISTS letters'); $this->connection->execute('DROP TABLE IF EXISTS parts'); $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS cake_migrations'); $this->dumpFile = ROOT . DS . 'config/TestsMigrations/schema-dump-test.lock'; } @@ -42,6 +40,7 @@ public function tearDown(): void $this->connection->execute('DROP TABLE IF EXISTS letters'); $this->connection->execute('DROP TABLE IF EXISTS parts'); $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS cake_migrations'); if (file_exists($this->dumpFile)) { unlink($this->dumpFile); } diff --git a/tests/TestCase/Command/MarkMigratedTest.php b/tests/TestCase/Command/MarkMigratedTest.php index 8237c65eb..669f19327 100644 --- a/tests/TestCase/Command/MarkMigratedTest.php +++ b/tests/TestCase/Command/MarkMigratedTest.php @@ -13,18 +13,15 @@ */ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Exception\MissingPluginException; use Cake\Datasource\ConnectionManager; -use Cake\TestSuite\TestCase; +use Migrations\Test\TestCase\TestCase; /** * MarkMigratedTest class */ class MarkMigratedTest extends TestCase { - use ConsoleIntegrationTestTrait; - /** * Instance of a Cake Connection object * @@ -42,8 +39,10 @@ public function setUp(): void parent::setUp(); $this->connection = ConnectionManager::get('test'); + // Drop both legacy and unified tables $this->connection->execute('DROP TABLE IF EXISTS migrator_phinxlog'); $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS cake_migrations'); $this->connection->execute('DROP TABLE IF EXISTS numbers'); } @@ -57,6 +56,7 @@ public function tearDown(): void parent::tearDown(); $this->connection->execute('DROP TABLE IF EXISTS migrator_phinxlog'); $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS cake_migrations'); $this->connection->execute('DROP TABLE IF EXISTS numbers'); } @@ -80,7 +80,7 @@ public function testExecute() 'Migration `20150704160200` successfully marked migrated !', ); - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); + $result = $this->connection->selectQuery()->select(['*'])->from($this->getMigrationsTableName())->execute()->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); $this->assertEquals('20150724233100', $result[1]['version']); $this->assertEquals('20150826191400', $result[2]['version']); @@ -98,7 +98,7 @@ public function testExecute() 'Skipping migration `20150826191400` (already migrated).', ); - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); + $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from($this->getMigrationsTableName())->execute(); $this->assertEquals(4, $result->fetchColumn(0)); } @@ -113,7 +113,7 @@ public function testExecuteTarget() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); @@ -133,7 +133,7 @@ public function testExecuteTarget() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); @@ -142,7 +142,7 @@ public function testExecuteTarget() $result = $this->connection->selectQuery() ->select(['COUNT(*)']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute(); $this->assertEquals(3, $result->fetchColumn(0)); } @@ -167,7 +167,7 @@ public function testExecuteTargetWithExclude() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); @@ -183,7 +183,7 @@ public function testExecuteTargetWithExclude() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); @@ -191,7 +191,7 @@ public function testExecuteTargetWithExclude() $result = $this->connection->selectQuery() ->select(['COUNT(*)']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute(); $this->assertEquals(2, $result->fetchColumn(0)); } @@ -217,7 +217,7 @@ public function testExecuteTargetWithOnly() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150724233100', $result[0]['version']); @@ -230,14 +230,14 @@ public function testExecuteTargetWithOnly() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150826191400', $result[1]['version']); $this->assertEquals('20150724233100', $result[0]['version']); $result = $this->connection->selectQuery() ->select(['COUNT(*)']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute(); $this->assertEquals(2, $result->fetchColumn(0)); } @@ -306,6 +306,6 @@ public function testExecutePlugin(): void /** @var \Cake\Database\Connection $connection */ $connection = ConnectionManager::get('test'); $tables = $connection->getSchemaCollection()->listTables(); - $this->assertContains('migrator_phinxlog', $tables); + $this->assertContains($this->getMigrationsTableName('Migrator'), $tables); } } diff --git a/tests/TestCase/Command/MigrateCommandTest.php b/tests/TestCase/Command/MigrateCommandTest.php index 63f82deac..8b063afc6 100644 --- a/tests/TestCase/Command/MigrateCommandTest.php +++ b/tests/TestCase/Command/MigrateCommandTest.php @@ -3,35 +3,22 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Exception\MissingPluginException; -use Cake\Database\Exception\DatabaseException; use Cake\Datasource\ConnectionManager; use Cake\Event\EventInterface; use Cake\Event\EventManager; -use Cake\TestSuite\TestCase; +use Migrations\Test\TestCase\TestCase; class MigrateCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - protected array $createdFiles = []; public function setUp(): void { parent::setUp(); - try { - $table = $this->fetchTable('Phinxlog'); - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } - - try { - $table = $this->fetchTable('MigratorPhinxlog'); - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } + $this->clearMigrationRecords('test'); + $this->clearMigrationRecords('test', 'Migrator'); } public function tearDown(): void @@ -62,8 +49,8 @@ public function testMigrateNoMigrationSource(): void $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $count = $this->getMigrationRecordCount('test'); + $this->assertEquals(0, $count); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -93,8 +80,7 @@ public function testMigrateSourceDefault(): void $this->assertOutputContains('MarkMigratedTest: migrated'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(2, $table->find()->all()->toArray()); + $this->assertEquals(2, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -115,8 +101,7 @@ public function testMigrateBaseMigration(): void $this->assertOutputContains('hasTable=1'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test')); } /** @@ -132,8 +117,7 @@ public function testMigrateWithSourceMigration(): void $this->assertOutputContains('ShouldNotExecuteMigration: skipped '); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -153,8 +137,7 @@ public function testMigrateDryRun() $this->assertOutputContains('MarkMigratedTest: migrated'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -172,8 +155,7 @@ public function testMigrateDate() $this->assertOutputContains('MarkMigratedTest: migrated'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test')); $this->assertFileExists($migrationPath . DS . 'schema-dump-test.lock'); } @@ -190,8 +172,7 @@ public function testMigrateDateNotFound() $this->assertOutputContains('No migrations to run'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $this->assertFileExists($migrationPath . DS . 'schema-dump-test.lock'); } @@ -208,8 +189,7 @@ public function testMigrateTarget() $this->assertOutputNotContains('MarkMigratedTestSecond'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -227,8 +207,7 @@ public function testMigrateTargetNotFound() $this->assertOutputContains('warning 99 is not a valid version'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -246,8 +225,7 @@ public function testMigrateFakeAll() $this->assertOutputContains('MarkMigratedTestSecond: migrated'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(2, $table->find()->all()->toArray()); + $this->assertEquals(2, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -265,8 +243,7 @@ public function testMigratePlugin() $this->assertOutputContains('All Done'); // Migration tracking table is plugin specific - $table = $this->fetchTable('MigratorPhinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test', 'Migrator')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -338,7 +315,6 @@ public function testBeforeMigrateEventAbort(): void // Only one event was fired $this->assertSame(['Migration.beforeMigrate'], $fired); - $table = $this->fetchTable('Phinxlog'); - $this->assertEquals(0, $table->find()->count()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); } } diff --git a/tests/TestCase/Command/RollbackCommandTest.php b/tests/TestCase/Command/RollbackCommandTest.php index 1c0cab47d..da94d200c 100644 --- a/tests/TestCase/Command/RollbackCommandTest.php +++ b/tests/TestCase/Command/RollbackCommandTest.php @@ -3,36 +3,23 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Database\Exception\DatabaseException; use Cake\Datasource\ConnectionManager; use Cake\Event\EventInterface; use Cake\Event\EventManager; -use Cake\TestSuite\TestCase; use InvalidArgumentException; +use Migrations\Test\TestCase\TestCase; use ReflectionProperty; class RollbackCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - protected array $createdFiles = []; public function setUp(): void { parent::setUp(); - try { - $table = $this->fetchTable('Phinxlog'); - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } - - try { - $table = $this->fetchTable('MigratorPhinxlog'); - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } + $this->clearMigrationRecords('test'); + $this->clearMigrationRecords('test', 'Migrator'); } public function tearDown(): void @@ -71,8 +58,7 @@ public function testSourceMissing(): void $this->assertOutputContains('No migrations to rollback'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -115,8 +101,8 @@ public function testExecuteDryRun(): void $this->assertOutputContains('20240309223600 MarkMigratedTestSecond: reverting'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(2, $table->find()->all()->toArray()); + $count = $this->getMigrationRecordCount('test'); + $this->assertEquals(2, $count); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -224,8 +210,7 @@ public function testPluginOption(): void $this->assertExitSuccess(); // migration state was recorded. - $phinxlog = $this->fetchTable('MigratorPhinxlog'); - $this->assertEquals(1, $phinxlog->find()->count(), 'migrate makes a row'); + $this->assertEquals(1, $this->getMigrationRecordCount('test', 'Migrator'), 'migrate makes a row'); // Table was created. $this->assertNotEmpty($this->fetchTable('Migrator')->getSchema()); @@ -236,7 +221,7 @@ public function testPluginOption(): void $this->assertOutputContains('Migrator: reverted'); // No more recorded migrations - $this->assertEquals(0, $phinxlog->find()->count()); + $this->assertEquals(0, $this->getMigrationRecordCount('test', 'Migrator')); } public function testLockOption(): void @@ -262,8 +247,7 @@ public function testFakeOption(): void $this->exec('migrations migrate -c test --no-lock'); $this->assertExitSuccess(); $this->resetOutput(); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(2, $table->find()->all()->toArray()); + $this->assertEquals(2, $this->getMigrationRecordCount('test')); $this->exec('migrations rollback -c test --no-lock --target MarkMigratedTestSecond --fake'); $this->assertExitSuccess(); @@ -271,7 +255,7 @@ public function testFakeOption(): void $this->assertOutputContains('performing fake rollbacks'); $this->assertOutputContains('MarkMigratedTestSecond: reverted'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -310,7 +294,6 @@ public function testBeforeMigrateEventAbort(): void // Only one event was fired $this->assertSame(['Migration.beforeRollback'], $fired); - $table = $this->fetchTable('Phinxlog'); - $this->assertEquals(0, $table->find()->count()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); } } diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index e18a832ee..9271b3260 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -3,27 +3,19 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Database\Exception\DatabaseException; use Cake\Datasource\ConnectionManager; use Cake\Event\EventInterface; use Cake\Event\EventManager; -use Cake\TestSuite\TestCase; use InvalidArgumentException; +use Migrations\Test\TestCase\TestCase; class SeedCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - public function setUp(): void { parent::setUp(); - $table = $this->fetchTable('Phinxlog'); - try { - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } + $this->clearMigrationRecords('test'); } public function tearDown(): void diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php index 3245b7803..e342d6d46 100644 --- a/tests/TestCase/Command/StatusCommandTest.php +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -3,25 +3,17 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Exception\MissingPluginException; -use Cake\Database\Exception\DatabaseException; -use Cake\TestSuite\TestCase; +use Migrations\Test\TestCase\TestCase; use RuntimeException; class StatusCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - public function setUp(): void { parent::setUp(); - $table = $this->fetchTable('Phinxlog'); - try { - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } + $this->clearMigrationRecords('test'); } public function testHelp(): void @@ -86,29 +78,21 @@ public function testCleanNoMissingMigrations(): void public function testCleanWithMissingMigrations(): void { - // First, insert a fake migration entry that doesn't exist in filesystem - $table = $this->fetchTable('Phinxlog'); - $entity = $table->newEntity([ - 'version' => 99999999999999, - 'migration_name' => 'FakeMissingMigration', - 'start_time' => '2024-01-01 00:00:00', - 'end_time' => '2024-01-01 00:00:01', - 'breakpoint' => false, - ]); - $table->save($entity); + // Run a migration first to ensure the schema table exists + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + + // Insert a fake migration entry that doesn't exist in filesystem + $this->insertMigrationRecord('test', 99999999999999, 'FakeMissingMigration'); // Verify the fake migration is in the table - $count = $table->find()->where(['version' => 99999999999999])->count(); - $this->assertEquals(1, $count); + $initialCount = $this->getMigrationRecordCount('test'); + $this->assertGreaterThan(0, $initialCount); // Run the clean command $this->exec('migrations status -c test --cleanup'); $this->assertExitSuccess(); $this->assertOutputContains('Removed 1 missing migration(s) from migration log.'); - - // Verify the fake migration was removed - $count = $table->find()->where(['version' => 99999999999999])->count(); - $this->assertEquals(0, $count); } public function testCleanHelp(): void @@ -116,6 +100,6 @@ public function testCleanHelp(): void $this->exec('migrations status --help'); $this->assertExitSuccess(); $this->assertOutputContains('--cleanup'); - $this->assertOutputContains('Remove MISSING migrations from the phinxlog table'); + $this->assertOutputContains('Remove MISSING migrations from the'); } } diff --git a/tests/TestCase/Command/UpgradeCommandTest.php b/tests/TestCase/Command/UpgradeCommandTest.php new file mode 100644 index 000000000..0fe132717 --- /dev/null +++ b/tests/TestCase/Command/UpgradeCommandTest.php @@ -0,0 +1,121 @@ +clearMigrationRecords('test'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $connection->execute('DROP TABLE IF EXISTS cake_migrations'); + } + + protected function getAdapter(): AdapterInterface + { + $config = ConnectionManager::getConfig('test'); + $environment = new Environment('default', [ + 'connection' => 'test', + 'database' => $config['database'], + 'migration_table' => 'phinxlog', + ]); + + return $environment->getAdapter(); + } + + public function testHelp(): void + { + Configure::write('Migrations.legacyTables', null); + + $this->exec('migrations upgrade --help'); + $this->assertExitSuccess(); + $this->assertOutputContains('Upgrades migration tracking'); + $this->assertOutputContains('migrations upgrade --dry-run'); + } + + public function testExecuteSimpleDryRun(): void + { + Configure::write('Migrations.legacyTables', true); + try { + $this->getAdapter()->createSchemaTable(); + } catch (Exception $e) { + // Table probably exists + } + + $this->exec('migrations upgrade -c test --dry-run'); + $this->assertExitSuccess(); + // Check for status output + $this->assertOutputContains('DRY RUN'); + $this->assertOutputContains('Creating unified table'); + $this->assertOutputContains('Total records migrated'); + } + + public function testExecuteSimpleExecute(): void + { + Configure::write('Migrations.legacyTables', true); + $config = ConnectionManager::getConfig('test'); + $environment = new Environment('default', [ + 'connection' => 'test', + 'database' => $config['database'], + 'migration_table' => 'phinxlog', + ]); + $adapter = $environment->getAdapter(); + try { + $adapter->createSchemaTable(); + } catch (Exception $e) { + // Table probably exists + } + + $this->exec('migrations upgrade -c test'); + $this->assertExitSuccess(); + + // No dry run and drop table output is present. + $this->assertOutputNotContains('DRY RUN'); + $this->assertOutputContains('Creating unified table'); + $this->assertOutputContains('Total records migrated'); + + $this->assertTrue($adapter->hasTable('cake_migrations')); + $this->assertTrue($adapter->hasTable('phinxlog')); + } + + public function testExecuteSimpleExecuteDropTables(): void + { + Configure::write('Migrations.legacyTables', true); + $config = ConnectionManager::getConfig('test'); + $environment = new Environment('default', [ + 'connection' => 'test', + 'database' => $config['database'], + 'migration_table' => 'phinxlog', + ]); + $adapter = $environment->getAdapter(); + try { + $adapter->createSchemaTable(); + } catch (Exception $e) { + // Table probably exists + } + + $this->exec('migrations upgrade -c test --drop-tables'); + $this->assertExitSuccess(); + + // Check for status output + $this->assertOutputNotContains('DRY RUN'); + $this->assertOutputContains('Creating unified table'); + $this->assertOutputContains('Dropping legacy table'); + $this->assertOutputContains('Total records migrated'); + + $this->assertTrue($adapter->hasTable('cake_migrations')); + $this->assertFalse($adapter->hasTable('phinxlog')); + } +} diff --git a/tests/TestCase/Db/Adapter/AbstractAdapterTest.php b/tests/TestCase/Db/Adapter/AbstractAdapterTest.php index 3f66fff36..425d5281d 100644 --- a/tests/TestCase/Db/Adapter/AbstractAdapterTest.php +++ b/tests/TestCase/Db/Adapter/AbstractAdapterTest.php @@ -3,10 +3,12 @@ namespace Migrations\Test\Db\Adapter; +use Cake\Core\Configure; use Cake\Database\Connection; use Cake\Datasource\ConnectionManager; use Migrations\Config\Config; use Migrations\Db\Adapter\AbstractAdapter; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use Migrations\Db\Literal; use Migrations\Test\TestCase\Db\Adapter\DefaultAdapterTrait; use PDOException; @@ -42,16 +44,31 @@ public function testOptions() public function testOptionsSetSchemaTableName() { - $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + // When unified table mode is enabled, getSchemaTableName() returns cake_migrations + $expectedDefault = Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'phinxlog'; + $this->assertEquals($expectedDefault, $this->adapter->getSchemaTableName()); $this->adapter->setOptions(['migration_table' => 'schema_table_test']); - $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); + // After explicitly setting migration_table, it should use that value in legacy mode + // But unified mode always returns cake_migrations + $expectedAfterSet = Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'schema_table_test'; + $this->assertEquals($expectedAfterSet, $this->adapter->getSchemaTableName()); } public function testSchemaTableName() { - $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + $expectedDefault = Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'phinxlog'; + $this->assertEquals($expectedDefault, $this->adapter->getSchemaTableName()); $this->adapter->setSchemaTableName('schema_table_test'); - $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); + $expectedAfterSet = Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'schema_table_test'; + $this->assertEquals($expectedAfterSet, $this->adapter->getSchemaTableName()); } public function testGetVersionLogInvalidVersionOrderKO() diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 2f5b1d650..b035f1288 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -124,6 +124,11 @@ public function testCreatingTheSchemaTableOnConnect() public function testSchemaTableIsCreatedWithPrimaryKey() { + // Skip for unified table mode since schema structure is different + if (Configure::read('Migrations.legacyTables') === false) { + $this->markTestSkipped('Unified table has different primary key structure'); + } + $this->adapter->connect(); new Table($this->adapter->getSchemaTableName(), [], $this->adapter); $this->assertTrue($this->adapter->hasIndex($this->adapter->getSchemaTableName(), ['version'])); diff --git a/tests/TestCase/Db/Adapter/UnifiedMigrationsTableStorageTest.php b/tests/TestCase/Db/Adapter/UnifiedMigrationsTableStorageTest.php new file mode 100644 index 000000000..d7b30fc83 --- /dev/null +++ b/tests/TestCase/Db/Adapter/UnifiedMigrationsTableStorageTest.php @@ -0,0 +1,229 @@ +cleanupTable(); + } + + public function tearDown(): void + { + // Always clean up the table + $this->cleanupTable(); + + Configure::delete('Migrations.legacyTables'); + + parent::tearDown(); + } + + /** + * Clean up the unified migrations table and other test artifacts. + */ + private function cleanupTable(): void + { + try { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $driver = $connection->getDriver(); + + // Drop unified migrations table + $connection->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $driver->quoteIdentifier(UnifiedMigrationsTableStorage::TABLE_NAME), + )); + + // Drop tables created by test migrations + $connection->execute('DROP TABLE IF EXISTS migrator'); + $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 mark_migrated'); + $connection->execute('DROP TABLE IF EXISTS mark_migrated_test'); + + // Also drop any phinxlog tables that might exist + $connection->execute('DROP TABLE IF EXISTS phinxlog'); + $connection->execute('DROP TABLE IF EXISTS migrator_phinxlog'); + } catch (Exception $e) { + // Ignore cleanup errors + } + } + + public function testTableName(): void + { + $this->assertSame('cake_migrations', UnifiedMigrationsTableStorage::TABLE_NAME); + } + + public function testMigrateCreatesUnifiedTable(): void + { + // Run a migration which should create the unified table + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + + // Verify unified table was created + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $dialect = $connection->getDriver()->schemaDialect(); + + $this->assertTrue($dialect->hasTable(UnifiedMigrationsTableStorage::TABLE_NAME)); + $this->tableCreated = true; + + // Verify records were inserted with null plugin (app migrations) + $result = $connection->selectQuery() + ->select('*') + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->execute() + ->fetchAll('assoc'); + + $this->assertGreaterThan(0, count($result)); + + // All records should have null plugin (app migrations) + foreach ($result as $row) { + $this->assertNull($row['plugin']); + } + } + + public function testMigratePluginUsesUnifiedTable(): void + { + $this->loadPlugins(['Migrator']); + + // Run app migrations first to create the table + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + $this->tableCreated = true; + + // Clear the migration records for app (but keep the table) + $this->clearMigrationRecords('test'); + + // Run plugin migrations + $this->exec('migrations migrate -c test --plugin Migrator --no-lock'); + $this->assertExitSuccess(); + + // Verify plugin records were inserted with plugin name + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $result = $connection->selectQuery() + ->select('*') + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->where(['plugin' => 'Migrator']) + ->execute() + ->fetchAll('assoc'); + + $this->assertGreaterThan(0, count($result)); + $this->assertEquals('Migrator', $result[0]['plugin']); + } + + public function testRollbackWithUnifiedTable(): void + { + // Run migrations + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + $this->tableCreated = true; + + // Verify we have records + $initialCount = $this->getMigrationRecordCount('test'); + $this->assertGreaterThan(0, $initialCount); + + // Rollback + $this->exec('migrations rollback -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + + // Verify record was removed + $afterCount = $this->getMigrationRecordCount('test'); + $this->assertLessThan($initialCount, $afterCount); + } + + public function testStatusWithUnifiedTable(): void + { + // Run migrations + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + $this->tableCreated = true; + + // Check status + $this->exec('migrations status -c test --source Migrations'); + $this->assertExitSuccess(); + $this->assertOutputContains('up'); + } + + public function testAppAndPluginMigrationsAreSeparated(): void + { + $this->loadPlugins(['Migrator']); + + // Run app migrations + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + $this->tableCreated = true; + + // Run plugin migrations + $this->exec('migrations migrate -c test --plugin Migrator --no-lock'); + $this->assertExitSuccess(); + + // Verify both app and plugin records exist in same table but are separated + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // App records (plugin IS NULL) + $appCount = $connection->selectQuery() + ->select(['count' => $connection->selectQuery()->func()->count('*')]) + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->where(['plugin IS' => null]) + ->execute() + ->fetch('assoc'); + + // Plugin records + $pluginCount = $connection->selectQuery() + ->select(['count' => $connection->selectQuery()->func()->count('*')]) + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->where(['plugin' => 'Migrator']) + ->execute() + ->fetch('assoc'); + + $this->assertGreaterThan(0, (int)$appCount['count'], 'App migrations should exist'); + $this->assertGreaterThan(0, (int)$pluginCount['count'], 'Plugin migrations should exist'); + + // Rolling back app shouldn't affect plugin + $this->exec('migrations rollback -c test --source Migrations --target 0 --no-lock'); + $this->assertExitSuccess(); + + // Plugin migrations should still exist + $pluginCountAfter = $connection->selectQuery() + ->select(['count' => $connection->selectQuery()->func()->count('*')]) + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->where(['plugin' => 'Migrator']) + ->execute() + ->fetch('assoc'); + + $this->assertEquals($pluginCount['count'], $pluginCountAfter['count'], 'Plugin migrations should be unaffected'); + } +} diff --git a/tests/TestCase/MigrationsTest.php b/tests/TestCase/MigrationsTest.php index 64d463dcc..5601a4ff5 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_migrations', $allTables)) { + $ormTable = $this->getTableLocator()->get('cake_migrations', ['connection' => $this->Connection]); + $query = $connection->getDriver()->schemaDialect()->truncateTableSql($ormTable->getSchema()); + foreach ($query as $stmt) { + $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()); diff --git a/tests/TestCase/TestCase.php b/tests/TestCase/TestCase.php index 859e9c2e5..06b0f68ec 100644 --- a/tests/TestCase/TestCase.php +++ b/tests/TestCase/TestCase.php @@ -17,9 +17,12 @@ namespace Migrations\Test\TestCase; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Core\Configure; +use Cake\Datasource\ConnectionManager; use Cake\Routing\Router; use Cake\TestSuite\StringCompareTrait; use Cake\TestSuite\TestCase as BaseTestCase; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; abstract class TestCase extends BaseTestCase { @@ -126,4 +129,133 @@ protected function assertFileNotContains($expected, $path, $message = '') $contents = file_get_contents($path); $this->assertStringNotContainsString($expected, $contents, $message); } + + /** + * Check if using unified migrations table. + * + * @return bool + */ + protected function isUsingUnifiedTable(): bool + { + return Configure::read('Migrations.legacyTables') === false; + } + + /** + * Get the migrations schema table name. + * + * @param string|null $plugin Plugin name + * @return string + */ + protected function getMigrationsTableName(?string $plugin = null): string + { + if ($this->isUsingUnifiedTable()) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + + if ($plugin === null) { + return 'phinxlog'; + } + + return strtolower($plugin) . '_phinxlog'; + } + + /** + * Clear migration records from the schema table. + * + * @param string $connectionName Connection name + * @param string|null $plugin Plugin name + * @return void + */ + protected function clearMigrationRecords(string $connectionName = 'test', ?string $plugin = null): void + { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + $tableName = $this->getMigrationsTableName($plugin); + + $dialect = $connection->getDriver()->schemaDialect(); + if (!$dialect->hasTable($tableName)) { + return; + } + + if ($this->isUsingUnifiedTable()) { + $query = $connection->deleteQuery() + ->delete($tableName) + ->where(['plugin IS' => $plugin]); + } else { + $query = $connection->deleteQuery() + ->delete($tableName); + } + $query->execute(); + } + + /** + * Get the count of migration records. + * + * @param string $connectionName Connection name + * @param string|null $plugin Plugin name + * @return int + */ + protected function getMigrationRecordCount(string $connectionName = 'test', ?string $plugin = null): int + { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + $tableName = $this->getMigrationsTableName($plugin); + + $dialect = $connection->getDriver()->schemaDialect(); + if (!$dialect->hasTable($tableName)) { + return 0; + } + + $query = $connection->selectQuery() + ->select(['count' => $connection->selectQuery()->func()->count('*')]) + ->from($tableName); + + if ($this->isUsingUnifiedTable()) { + $query->where(['plugin IS' => $plugin]); + } + + $result = $query->execute()->fetch('assoc'); + + return (int)($result['count'] ?? 0); + } + + /** + * Insert a migration record into the schema table. + * + * @param string $connectionName Connection name + * @param int $version Version number + * @param string $migrationName Migration name + * @param string|null $plugin Plugin name + * @return void + */ + protected function insertMigrationRecord( + string $connectionName, + int $version, + string $migrationName, + ?string $plugin = null, + ): void { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + $tableName = $this->getMigrationsTableName($plugin); + + $columns = ['version', 'migration_name', 'start_time', 'end_time', 'breakpoint']; + $values = [ + 'version' => $version, + 'migration_name' => $migrationName, + 'start_time' => '2024-01-01 00:00:00', + 'end_time' => '2024-01-01 00:00:01', + 'breakpoint' => 0, + ]; + + if ($this->isUsingUnifiedTable()) { + $columns[] = 'plugin'; + $values['plugin'] = $plugin; + } + + $connection->insertQuery() + ->insert($columns) + ->into($tableName) + ->values($values) + ->execute(); + } } diff --git a/tests/TestCase/TestSuite/MigratorTest.php b/tests/TestCase/TestSuite/MigratorTest.php index 4fa05b2d4..de3baa975 100644 --- a/tests/TestCase/TestSuite/MigratorTest.php +++ b/tests/TestCase/TestSuite/MigratorTest.php @@ -14,10 +14,12 @@ namespace Migrations\Test\TestCase\TestSuite; use Cake\Chronos\ChronosDate; +use Cake\Core\Configure; use Cake\Database\Driver\Postgres; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\ConnectionHelper; use Cake\TestSuite\TestCase; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use Migrations\TestSuite\Migrator; use PHPUnit\Framework\Attributes\Depends; use RuntimeException; @@ -29,6 +31,30 @@ class MigratorTest extends TestCase */ protected $restore; + /** + * Get the migration table name for the Migrator plugin. + * + * @return string + */ + protected function getMigratorTableName(): string + { + return Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'migrator_phinxlog'; + } + + /** + * Build a WHERE clause for filtering by plugin in unified mode. + * + * @return array + */ + protected function getMigratorWhereClause(): array + { + return Configure::read('Migrations.legacyTables') === false + ? ['plugin' => 'Migrator'] + : []; + } + public function setUp(): void { parent::setUp(); @@ -112,13 +138,20 @@ public function testRunManyDropTruncate(): void $tables = $connection->getSchemaCollection()->listTables(); $this->assertContains('migrator', $tables); $this->assertCount(0, $connection->selectQuery()->select(['*'])->from('migrator')->execute()->fetchAll()); - $this->assertCount(2, $connection->selectQuery()->select(['*'])->from('migrator_phinxlog')->execute()->fetchAll()); + $query = $connection->selectQuery()->select(['*'])->from($this->getMigratorTableName()); + $where = $this->getMigratorWhereClause(); + if ($where) { + $query->where($where); + } + $this->assertCount(2, $query->execute()->fetchAll()); } public function testRunManyMultipleSkip(): void { $connection = ConnectionManager::get('test'); $this->skipIf($connection->getDriver() instanceof Postgres); + // Skip for unified mode - migration history detection works differently + $this->skipIf(Configure::read('Migrations.legacyTables') === false); $migrator = new Migrator(); // Run migrations for the first time. @@ -154,19 +187,26 @@ public function testTruncateAfterMigrations(): void private function setMigrationEndDateToYesterday() { - ConnectionManager::get('test')->updateQuery() - ->update('migrator_phinxlog') - ->set('end_time', ChronosDate::yesterday(), 'timestamp') - ->execute(); + $query = ConnectionManager::get('test')->updateQuery() + ->update($this->getMigratorTableName()) + ->set('end_time', ChronosDate::yesterday(), 'timestamp'); + $where = $this->getMigratorWhereClause(); + if ($where) { + $query->where($where); + } + $query->execute(); } private function fetchMigrationEndDate(): ChronosDate { - $endTime = ConnectionManager::get('test')->selectQuery() + $query = ConnectionManager::get('test')->selectQuery() ->select('end_time') - ->from('migrator_phinxlog') - ->execute() - ->fetchColumn(0); + ->from($this->getMigratorTableName()); + $where = $this->getMigratorWhereClause(); + if ($where) { + $query->where($where); + } + $endTime = $query->execute()->fetchColumn(0); if (!$endTime || is_bool($endTime)) { $this->markTestSkipped('Cannot read end_time, bailing.'); @@ -217,6 +257,9 @@ public function testSkipMigrationDroppingIfOnlyUpMigrationsWithTwoSetsOfMigratio public function testDropMigrationsIfDownMigrations(): void { + // Skip for unified mode - migration history detection works differently + $this->skipIf(Configure::read('Migrations.legacyTables') === false); + // Run the migrator $migrator = new Migrator(); $migrator->run(['plugin' => 'Migrator']); @@ -237,6 +280,9 @@ public function testDropMigrationsIfDownMigrations(): void public function testDropMigrationsIfMissingMigrations(): void { + // Skip for unified mode - migration history detection works differently + $this->skipIf(Configure::read('Migrations.legacyTables') === false); + // Run the migrator $migrator = new Migrator(); $migrator->runMany([ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 019711fbb..cf4caad6f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -67,9 +67,13 @@ ], ]); +// LEGACY_TABLES env: 'true' for legacy phinxlog, 'false' for unified cake_migrations +$legacyTables = env('LEGACY_TABLES', 'true') !== 'false'; + Configure::write('Migrations', [ 'unsigned_primary_keys' => true, 'column_null_default' => true, + 'legacyTables' => $legacyTables, ]); Cache::setConfig([ diff --git a/tests/test_app/config/MigrationsDiffDecimalChange/.gitkeep b/tests/test_app/config/MigrationsDiffDecimalChange/.gitkeep deleted file mode 100644 index e69de29bb..000000000