diff --git a/docs/en/getting-started/running-and-managing-migrations.md b/docs/en/getting-started/running-and-managing-migrations.md index 7c51a4f9..c26ca7cb 100644 --- a/docs/en/getting-started/running-and-managing-migrations.md +++ b/docs/en/getting-started/running-and-managing-migrations.md @@ -90,6 +90,27 @@ in CI. e.g. `{"app": [...], "PluginName": [...]}`. `--all` cannot be combined with `--plugin` or `--cleanup`. +### Validating Migration Files + +Migration classes are only loaded when the migration they contain is executed. +A migration file that cannot be loaded, for example one still extending the +removed `Migrations\AbstractMigration` class, will therefore not fail `status` +or the `PendingMigrationsMiddleware`. The `--validate` option loads every +migration class and reports the ones that cannot be loaded: + +```bash +bin/cake migrations status --validate +``` + +When any migration cannot be loaded, the offending versions are printed to +stderr and the command exits with `1`, which makes it a useful CI check. +Otherwise the regular status output follows. The option can be combined with +`--all` to validate the app and every loaded plugin in one call. + +The same check is available programmatically through +`Manager::validateMigrations()`, which returns the error messages indexed by +migration version. + ### Cleaning Up Missing Migrations Sometimes migration files may be deleted from the filesystem but still exist in diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 4161d64f..87a587fb 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -20,6 +20,7 @@ use Cake\Core\Plugin; use Migrations\Config\ConfigInterface; use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; +use Migrations\Migration\Manager; use Migrations\Migration\ManagerFactory; /** @@ -78,6 +79,8 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'Add -v to also print the per-section migration tables.', 'migrations status --cleanup', 'Remove *MISSING* migrations from the migration tracking table', + 'migrations status --validate', + 'Load every migration class and fail if any of them cannot be loaded.', ])->addOption('plugin', [ 'short' => 'p', 'help' => 'The plugin to run migrations for', @@ -104,6 +107,11 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'help' => 'Remove MISSING migrations from the migration tracking table', 'boolean' => true, 'default' => false, + ])->addOption('validate', [ + 'help' => 'Load every migration class and fail if any of them cannot be loaded. ' + . 'Migration classes are otherwise only loaded when they are executed.', + 'boolean' => true, + 'default' => false, ]); return $parser; @@ -146,6 +154,20 @@ public function execute(Arguments $args, ConsoleIo $io): ?int ]); $manager = $factory->createManager($io); + if ($args->getOption('validate')) { + /** @var string|null $plugin */ + $plugin = $args->getOption('plugin'); + if (!$this->validateMigrations($manager, $io, $plugin ?? 'app')) { + return Command::CODE_ERROR; + } + if ($format !== 'json') { + $io->out(sprintf( + 'All %d migrations can be loaded.', + count($manager->getMigrationVersions()), + )); + } + } + if ($clean) { $removed = $manager->cleanupMissingMigrations(); if ($removed === 0) { @@ -200,6 +222,8 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): } $verbose = (bool)$args->getOption('verbose'); + $validate = (bool)$args->getOption('validate'); + $validationFailed = false; $jsonResults = []; $summary = []; $exitCode = Command::CODE_SUCCESS; @@ -212,6 +236,11 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): 'dry-run' => $args->getOption('dry-run'), ]); $manager = $factory->createManager($io); + + if ($validate && !$this->validateMigrations($manager, $io, $label)) { + $validationFailed = true; + } + $migrations = $manager->printStatus($format); $sectionExit = $this->statusExitCode($migrations); @@ -241,6 +270,10 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): $this->display($migrations, $io, $manager->getSchemaTableName()); } + if ($validationFailed) { + return Command::CODE_ERROR; + } + if ($format === 'json') { $flags = 0; if ($verbose) { @@ -256,6 +289,33 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): return $exitCode; } + /** + * Load every migration class and print the ones that could not be loaded. + * + * @param \Migrations\Migration\Manager $manager The manager to load migrations with. + * @param \Cake\Console\ConsoleIo $io The console io. + * @param string $label The section the migrations belong to. + * @return bool True when every migration class could be loaded. + */ + protected function validateMigrations(Manager $manager, ConsoleIo $io, string $label): bool + { + $errors = $manager->validateMigrations(); + if (!$errors) { + return true; + } + + $io->err(sprintf( + '%s: %d migration(s) could not be loaded:', + $label === 'app' ? 'APP' : $label, + count($errors), + )); + foreach ($errors as $version => $message) { + $io->err(sprintf(' - %d: %s', $version, $message)); + } + + return false; + } + /** * Count actionable items (down + missing) in a section's migrations array. * diff --git a/src/Middleware/PendingMigrationsMiddleware.php b/src/Middleware/PendingMigrationsMiddleware.php index 8c2ba542..da9ca621 100644 --- a/src/Middleware/PendingMigrationsMiddleware.php +++ b/src/Middleware/PendingMigrationsMiddleware.php @@ -111,9 +111,8 @@ protected function checkAppMigrations(): bool $manager = $this->getManager($this->_config); - $migrations = $manager->getMigrations(); - foreach ($migrations as $migration) { - if (!$manager->isMigrated($migration->getVersion())) { + foreach ($manager->getMigrationVersions() as $version) { + if (!$manager->isMigrated($version)) { return false; } } @@ -146,9 +145,8 @@ protected function checkPluginMigrations(string $plugin): bool $config['environment']['migration_table'] = $table; $manager = $this->getManager($config); - $migrations = $manager->getMigrations(); - foreach ($migrations as $migration) { - if (!$manager->isMigrated($migration->getVersion())) { + foreach ($manager->getMigrationVersions() as $version) { + if (!$manager->isMigrated($version)) { return false; } } diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 65ef382f..fb8cead7 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -19,6 +19,7 @@ use Migrations\Util\Util; use Psr\Container\ContainerInterface; use RuntimeException; +use Throwable; class Manager { @@ -39,6 +40,20 @@ class Manager */ protected ?array $migrations = null; + /** + * Migration file paths indexed by version and sorted in ascending order. + * + * @var array|null + */ + protected ?array $migrationPaths = null; + + /** + * Migrations that have been loaded from their files, indexed by version. + * + * @var array + */ + protected array $loadedMigrations = []; + /** * @var \Migrations\SeedInterface[]|null */ @@ -67,27 +82,24 @@ public function printStatus(?string $format = null): array { $migrations = []; $isJson = $format === 'json'; - $defaultMigrations = $this->getMigrations(); - if ($defaultMigrations) { + $defaultVersions = $this->getMigrationVersions(); + if ($defaultVersions) { $env = $this->getEnvironment(); $versions = $env->getVersionLog(); - foreach ($defaultMigrations as $migration) { - if (array_key_exists($migration->getVersion(), $versions)) { + foreach ($defaultVersions as $version) { + if (array_key_exists($version, $versions)) { $status = 'up'; - unset($versions[$migration->getVersion()]); + unset($versions[$version]); } else { $status = 'down'; } - $version = $migration->getVersion(); - $migrationParams = [ + $migrations[$version] = [ 'status' => $status, - 'id' => $migration->getVersion(), - 'name' => $migration->getName(), + 'id' => $version, + 'name' => $this->getMigrationName($version), ]; - - $migrations[$version] = $migrationParams; } foreach ($versions as $missing) { @@ -123,8 +135,7 @@ public function printStatus(?string $format = null): array */ public function migrateToDateTime(DateTime $dateTime, bool $fake = false): void { - /** @var array $versions */ - $versions = array_keys($this->getMigrations()); + $versions = $this->getMigrationVersions(); $dateString = $dateTime->format('Ymdhis'); $versionToMigrate = null; foreach ($versions as $version) { @@ -344,8 +355,7 @@ protected function getMigrationClassName(string $path): string */ public function getVersionsToMark(Arguments $args): array { - $migrations = $this->getMigrations(); - $versions = array_keys($migrations); + $versions = $this->getMigrationVersions(); $versionArg = null; if ($args->hasArgument('version')) { @@ -434,19 +444,19 @@ public function markVersionsAsMigrated(string $path, array $versions): array */ public function migrate(?int $version = null, bool $fake = false, ?int $count = null): void { - $migrations = $this->getMigrations(); + $migrationVersions = $this->getMigrationVersions(); $env = $this->getEnvironment(); $versions = $env->getVersions(); $current = $env->getCurrentVersion(); - if (!$versions && !$migrations) { + if (!$versions && !$migrationVersions) { return; } if ($version === null) { - $candidates = [...$versions, ...array_keys($migrations)]; + $candidates = [...$versions, ...$migrationVersions]; $version = $candidates ? max($candidates) : 0; - } elseif ($version !== 0 && !isset($migrations[$version])) { + } elseif ($version !== 0 && !in_array($version, $migrationVersions, true)) { $this->getIo()->out(sprintf( 'warning %s is not a valid version', $version, @@ -460,27 +470,25 @@ public function migrate(?int $version = null, bool $fake = false, ?int $count = if ($direction === MigrationInterface::DOWN) { // run downs first - krsort($migrations); - foreach ($migrations as $migration) { - if ($migration->getVersion() <= $version) { + foreach (array_reverse($migrationVersions) as $migrationVersion) { + if ($migrationVersion <= $version) { break; } - if (in_array($migration->getVersion(), $versions)) { - $this->executeMigration($migration, MigrationInterface::DOWN, $fake); + if (in_array($migrationVersion, $versions)) { + $this->executeMigration($this->getMigration($migrationVersion), MigrationInterface::DOWN, $fake); } } } - ksort($migrations); $done = 0; - foreach ($migrations as $migration) { - if ($migration->getVersion() > $version || ($count && $done >= $count)) { + foreach ($migrationVersions as $migrationVersion) { + if ($migrationVersion > $version || ($count && $done >= $count)) { break; } - if (!in_array($migration->getVersion(), $versions)) { - $this->executeMigration($migration, MigrationInterface::UP, $fake); + if (!in_array($migrationVersion, $versions)) { + $this->executeMigration($this->getMigration($migrationVersion), MigrationInterface::UP, $fake); $done++; } } @@ -690,14 +698,14 @@ public function rollbackByCount(int $count, bool $force = false, bool $fake = fa */ public function rollback(int|string|null $target = null, bool $force = false, bool $targetMustMatchVersion = true, bool $fake = false): void { - // note that the migrations are indexed by name (aka creation time) in ascending order - $migrations = $this->getMigrations(); + // note that the migration versions (aka creation time) are sorted in ascending order + $migrationVersions = array_flip($this->getMigrationVersions()); // note that the version log are also indexed by name with the proper ascending order according to the version order $executedVersions = $this->getEnvironment()->getVersionLog(); - // get a list of migrations sorted in the opposite way of the executed versions - $sortedMigrations = []; + // get a list of migration versions sorted in the opposite way of the executed versions + $sortedVersions = []; $io = $this->getIo(); foreach ($executedVersions as $versionCreationTime => &$executedVersion) { @@ -709,8 +717,8 @@ public function rollback(int|string|null $target = null, bool $force = false, bo $executedVersion['start_time'] = $dateTime->format('YmdHis'); } - if (isset($migrations[$versionCreationTime])) { - array_unshift($sortedMigrations, $migrations[$versionCreationTime]); + if (isset($migrationVersions[$versionCreationTime])) { + array_unshift($sortedVersions, $versionCreationTime); } else { // this means the version is missing so we unset it so that we don't consider it when rolling back // migrations (or choosing the last up version as target) @@ -753,7 +761,7 @@ public function rollback(int|string|null $target = null, bool $force = false, bo } // If the target must match a version, check the target version exists - if ($targetMustMatchVersion && $target !== 0 && !isset($migrations[$target])) { + if ($targetMustMatchVersion && $target !== 0 && !isset($migrationVersions[$target])) { $io->out(sprintf('Target version (%s) not found', $target)); return; @@ -762,13 +770,13 @@ public function rollback(int|string|null $target = null, bool $force = false, bo // Rollback all versions until we find the wanted rollback target $rollbacked = false; - foreach ($sortedMigrations as $migration) { - if ($targetMustMatchVersion && $migration->getVersion() == $target) { + foreach ($sortedVersions as $migrationVersion) { + if ($targetMustMatchVersion && $migrationVersion == $target) { break; } - if (in_array($migration->getVersion(), $executedVersionCreationTimes)) { - $executedArray = $executedVersions[$migration->getVersion()]; + if (in_array($migrationVersion, $executedVersionCreationTimes)) { + $executedArray = $executedVersions[$migrationVersion]; if (!$targetMustMatchVersion && ($this->getConfig()->isVersionOrderCreationTime() && $executedArray['version'] <= $target || !$this->getConfig()->isVersionOrderCreationTime() && $executedArray['start_time'] <= $target)) { break; @@ -778,7 +786,7 @@ public function rollback(int|string|null $target = null, bool $force = false, bo $io->out('Breakpoint reached. Further rollbacks inhibited.'); break; } - $this->executeMigration($migration, MigrationInterface::DOWN, $fake); + $this->executeMigration($this->getMigration((int)$migrationVersion), MigrationInterface::DOWN, $fake); $rollbacked = true; } } @@ -908,105 +916,228 @@ public function setMigrations(array $migrations) * Gets an array of the database migrations, indexed by migration name (aka creation time) and sorted in ascending * order * + * This loads every migration class. Prefer getMigrationVersions() when the migration instances are not needed. + * * @throws \InvalidArgumentException * @return \Migrations\MigrationInterface[] */ public function getMigrations(): array { - if ($this->migrations === null) { - $phpFiles = $this->getMigrationFiles(); + if ($this->migrations !== null) { + return $this->migrations; + } - $io = $this->getIo(); - $io->verbose('Migration file'); - $io->verbose( - array_map( - function (string $phpFile): string { - return sprintf(' %s', $phpFile); - }, - $phpFiles, - ), - ); + $migrations = []; + foreach ($this->getMigrationVersions() as $version) { + $migrations[$version] = $this->getMigration($version); + } - // filter the files to only get the ones that match our naming scheme - $fileNames = []; - /** @var \Migrations\MigrationInterface[] $versions */ - $versions = []; + return $migrations; + } - $io = $this->getIo(); - foreach ($phpFiles as $filePath) { - if (Util::isValidMigrationFileName(basename($filePath))) { - $io->verbose(sprintf('Valid migration file %s.', $filePath)); + /** + * Gets the versions of the database migrations sorted in ascending order, without loading the migration classes. + * + * @throws \InvalidArgumentException + * @return list + */ + public function getMigrationVersions(): array + { + if ($this->migrations !== null) { + $versions = array_keys($this->migrations); + sort($versions); - $version = Util::getVersionFromFileName(basename($filePath)); + return $versions; + } - if (isset($versions[$version])) { - throw new InvalidArgumentException(sprintf('Duplicate migration - "%s" has the same version as "%s"', $filePath, $versions[$version]->getVersion())); - } + return array_keys($this->getMigrationPaths()); + } - // convert the filename to a class name - $class = Util::mapFileNameToClassName(basename($filePath)); + /** + * Gets a single database migration, loading its class if it has not been loaded yet. + * + * @param int $version Version of the migration + * @throws \InvalidArgumentException + * @return \Migrations\MigrationInterface + */ + public function getMigration(int $version): MigrationInterface + { + if ($this->migrations !== null) { + if (!isset($this->migrations[$version])) { + throw new InvalidArgumentException(sprintf('Migration `%d` was not found', $version)); + } - if (isset($fileNames[$class])) { - throw new InvalidArgumentException(sprintf( - 'Migration "%s" has the same name as "%s"', - basename($filePath), - $fileNames[$class], - )); - } + return $this->migrations[$version]; + } - $fileNames[$class] = basename($filePath); + if (!isset($this->loadedMigrations[$version])) { + $paths = $this->getMigrationPaths(); + if (!isset($paths[$version])) { + throw new InvalidArgumentException(sprintf('Migration `%d` was not found', $version)); + } + + $this->loadedMigrations[$version] = $this->loadMigration($version, $paths[$version]); + } - $io->verbose(sprintf('Loading class %s from %s.', $class, $filePath)); + return $this->loadedMigrations[$version]; + } - $this->checkMigrationClass($filePath); + /** + * Loads every migration class and collects the errors that prevent them from being loaded. + * + * Migration classes are loaded when the migration they contain is executed, so a broken + * migration file is only reported when that migration runs. This loads all of them upfront + * so that the migration files can be validated explicitly, for example in CI. + * + * @throws \InvalidArgumentException When two migrations share a version or a name + * @return array Error messages indexed by migration version. + */ + public function validateMigrations(): array + { + $errors = []; + foreach ($this->getMigrationVersions() as $version) { + try { + $this->getMigration($version); + } catch (Throwable $e) { + $errors[$version] = $e->getMessage(); + } + } - $orig_display_errors_setting = ini_get('display_errors'); - ini_set('display_errors', 'On'); + return $errors; + } - // For anonymous classes, we need to use require instead of require_once - // to get the returned instance - $migrationInstance = null; - if (!class_exists($class)) { - $migrationInstance = require $filePath; - } else { - require_once $filePath; - } + /** + * Gets the name of a database migration without loading its class. + * + * @param int $version Version of the migration + * @return string + */ + protected function getMigrationName(int $version): string + { + if ($this->migrations !== null) { + return $this->getMigration($version)->getName(); + } - ini_set('display_errors', $orig_display_errors_setting); + return Util::mapFileNameToClassName(basename($this->getMigrationPaths()[$version])); + } - // Check if the file returns an anonymous class instance - if ($migrationInstance instanceof MigrationInterface) { - $io->verbose(sprintf('Using anonymous class from %s.', $filePath)); - $migration = $migrationInstance; - $migration->setVersion($version); - } elseif (class_exists($class)) { - // Fall back to traditional class-based migration - $io->verbose(sprintf('Constructing %s.', $class)); - $migration = new $class($version); - } else { - throw new InvalidArgumentException(sprintf( - 'Could not find class `%s` in file `%s` and file did not return a migration instance', - $class, - $filePath, - )); - } + /** + * Gets the paths of the migration files indexed by version and sorted in ascending order + * + * @throws \InvalidArgumentException + * @return array + */ + protected function getMigrationPaths(): array + { + if ($this->migrationPaths !== null) { + return $this->migrationPaths; + } - /** @var \Migrations\MigrationInterface $migration */ - $config = $this->getConfig(); - $migration->setConfig($config); - $migration->setIo($io); + $phpFiles = $this->getMigrationFiles(); - $versions[$version] = $migration; - } else { - $io->verbose(sprintf('Invalid migration file %s.', $filePath)); - } + $io = $this->getIo(); + $io->verbose('Migration file'); + $io->verbose( + array_map( + function (string $phpFile): string { + return sprintf(' %s', $phpFile); + }, + $phpFiles, + ), + ); + + // filter the files to only get the ones that match our naming scheme + $fileNames = []; + $paths = []; + + foreach ($phpFiles as $filePath) { + if (!Util::isValidMigrationFileName(basename($filePath))) { + $io->verbose(sprintf('Invalid migration file %s.', $filePath)); + continue; + } + + $io->verbose(sprintf('Valid migration file %s.', $filePath)); + + $version = Util::getVersionFromFileName(basename($filePath)); + + if (isset($paths[$version])) { + throw new InvalidArgumentException(sprintf('Duplicate migration - "%s" has the same version as "%s"', $filePath, $version)); } - ksort($versions); - $this->setMigrations($versions); + // convert the filename to a class name + $class = Util::mapFileNameToClassName(basename($filePath)); + + if (isset($fileNames[$class])) { + throw new InvalidArgumentException(sprintf( + 'Migration "%s" has the same name as "%s"', + basename($filePath), + $fileNames[$class], + )); + } + + $fileNames[$class] = basename($filePath); + $paths[$version] = $filePath; + } + + ksort($paths); + $this->migrationPaths = $paths; + + return $paths; + } + + /** + * Loads a migration class from its file and creates the migration instance. + * + * @param int $version Version of the migration + * @param string $filePath Path to the migration file + * @throws \InvalidArgumentException + * @return \Migrations\MigrationInterface + */ + protected function loadMigration(int $version, string $filePath): MigrationInterface + { + $io = $this->getIo(); + $class = Util::mapFileNameToClassName(basename($filePath)); + + $io->verbose(sprintf('Loading class %s from %s.', $class, $filePath)); + + $this->checkMigrationClass($filePath); + + $orig_display_errors_setting = ini_get('display_errors'); + ini_set('display_errors', 'On'); + + // For anonymous classes, we need to use require instead of require_once + // to get the returned instance + $migrationInstance = null; + if (!class_exists($class)) { + $migrationInstance = require $filePath; + } else { + require_once $filePath; + } + + ini_set('display_errors', $orig_display_errors_setting); + + // Check if the file returns an anonymous class instance + if ($migrationInstance instanceof MigrationInterface) { + $io->verbose(sprintf('Using anonymous class from %s.', $filePath)); + $migration = $migrationInstance; + $migration->setVersion($version); + } elseif (class_exists($class)) { + // Fall back to traditional class-based migration + $io->verbose(sprintf('Constructing %s.', $class)); + $migration = new $class($version); + } else { + throw new InvalidArgumentException(sprintf( + 'Could not find class `%s` in file `%s` and file did not return a migration instance', + $class, + $filePath, + )); } - return (array)$this->migrations; + /** @var \Migrations\MigrationInterface $migration */ + $migration->setConfig($this->getConfig()); + $migration->setIo($io); + + return $migration; } /** @@ -1277,11 +1408,11 @@ public function toggleBreakpoint(?int $version): void */ protected function markBreakpoint(?int $version, int $mark): void { - $migrations = $this->getMigrations(); + $migrationVersions = array_flip($this->getMigrationVersions()); $env = $this->getEnvironment(); $versions = $env->getVersionLog(); - if (!$versions || !$migrations) { + if (!$versions || !$migrationVersions) { return; } @@ -1291,7 +1422,7 @@ protected function markBreakpoint(?int $version, int $mark): void } $io = $this->getIo(); - if ($version !== 0 && (!isset($versions[$version]) || !isset($migrations[$version]))) { + if ($version !== 0 && (!isset($versions[$version]) || !isset($migrationVersions[$version]))) { $io->out(sprintf( 'warning %s is not a valid version', $version, @@ -1300,18 +1431,20 @@ protected function markBreakpoint(?int $version, int $mark): void return; } + $migration = $this->getMigration((int)$version); + switch ($mark) { case self::BREAKPOINT_TOGGLE: - $env->getAdapter()->toggleBreakpoint($migrations[$version]); + $env->getAdapter()->toggleBreakpoint($migration); break; case self::BREAKPOINT_SET: if ((int)$versions[$version]['breakpoint'] === 0) { - $env->getAdapter()->setBreakpoint($migrations[$version]); + $env->getAdapter()->setBreakpoint($migration); } break; case self::BREAKPOINT_UNSET: if ((int)$versions[$version]['breakpoint'] === 1) { - $env->getAdapter()->unsetBreakpoint($migrations[$version]); + $env->getAdapter()->unsetBreakpoint($migration); } break; } @@ -1321,7 +1454,7 @@ protected function markBreakpoint(?int $version, int $mark): void $io->out( ' Breakpoint ' . ($versions[$version]['breakpoint'] ? 'set' : 'cleared') . ' for ' . $version . '' . - ' ' . $migrations[$version]->getName() . '', + ' ' . $migration->getName() . '', ); } @@ -1368,6 +1501,8 @@ public function unsetBreakpoint(?int $version): void public function resetMigrations(): void { $this->migrations = null; + $this->migrationPaths = null; + $this->loadedMigrations = []; } /** @@ -1404,7 +1539,7 @@ public function getSchemaTableName(): string */ public function cleanupMissingMigrations(): int { - $defaultMigrations = $this->getMigrations(); + $migrationVersions = array_flip($this->getMigrationVersions()); $env = $this->getEnvironment(); $versions = $env->getVersionLog(); $adapter = $env->getAdapter(); @@ -1412,7 +1547,7 @@ public function cleanupMissingMigrations(): int // Find missing migrations (those in migration table but not in filesystem) $missingVersions = []; foreach (array_keys($versions) as $versionId) { - if (!isset($defaultMigrations[$versionId])) { + if (!isset($migrationVersions[$versionId])) { $missingVersions[] = $versionId; } } diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index d61686dc..729a8b68 100644 --- a/tests/TestCase/Command/CompletionTest.php +++ b/tests/TestCase/Command/CompletionTest.php @@ -134,7 +134,8 @@ public function testMigrationsOptionsStatus(): void $this->exec('completion options migrations.migrations status'); $this->assertCount(1, $this->_out->messages()); $output = $this->_out->messages()[0]; - $expected = '--all --cleanup --connection -c --format -f --help -h --plugin -p --quiet -q --source -s --verbose -v'; + $expected = '--all --cleanup --connection -c --format -f --help -h --plugin -p --quiet -q --source -s'; + $expected .= ' --validate --verbose -v'; $outputExplode = explode(' ', trim($output)); sort($outputExplode); $expectedExplode = explode(' ', $expected); diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php index 929b0707..8dafa101 100644 --- a/tests/TestCase/Command/StatusCommandTest.php +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -182,4 +182,36 @@ public function testAllRejectsCleanupOption(): void $this->assertExitError(); $this->assertErrorContains('cannot be combined with --cleanup'); } + + public function testValidateHelp(): void + { + $this->exec('migrations status --help'); + $this->assertExitSuccess(); + $this->assertOutputContains('--validate'); + $this->assertOutputContains('Load every migration class'); + } + + public function testValidateWithValidMigrations(): void + { + $this->exec('migrations status -c test --validate'); + $this->assertExitSuccess(); + $this->assertOutputContains('migrations can be loaded'); + // The status table is still printed. + $this->assertOutputContains('Migration ID'); + } + + public function testValidateWithMigrationThatCannotBeLoaded(): void + { + $this->exec('migrations status -c test -s LegacyAbstractMigration --validate'); + $this->assertExitError(); + $this->assertErrorContains('could not be loaded'); + $this->assertErrorContains('20260327000000'); + $this->assertOutputNotContains('Migration ID'); + } + + public function testAllValidatesEverySection(): void + { + $this->exec('migrations status -c test --all --validate'); + $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); + } } diff --git a/tests/TestCase/Migration/ManagerTest.php b/tests/TestCase/Migration/ManagerTest.php index 8d88b575..afe07af9 100644 --- a/tests/TestCase/Migration/ManagerTest.php +++ b/tests/TestCase/Migration/ManagerTest.php @@ -665,6 +665,76 @@ public function testGetMigrationsWithAnonymousClass(): void $this->assertEquals(20241208150000, $migration->getVersion()); } + public function testGetMigrationVersionsDoesNotLoadMigrations(): void + { + // Loading this migration throws, so the versions must come from the file names only. + $config = new Config(['paths' => ['migrations' => ROOT . '/config/LegacyAbstractMigration']]); + $manager = new Manager($config, $this->io); + + $this->assertSame([20260327000000], $manager->getMigrationVersions()); + } + + public function testPrintStatusDoesNotLoadMigrations(): void + { + $config = new Config(['paths' => ['migrations' => ROOT . '/config/LegacyAbstractMigration']]); + $manager = new Manager($config, $this->io); + + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->willReturn([]); + $manager->setEnvironment($envStub); + + $expected = [ + [ + 'status' => 'down', + 'id' => 20260327000000, + 'name' => 'LegacyAbstractMigration', + ], + ]; + $this->assertEquals($expected, $manager->printStatus()); + } + + public function testMigrateDoesNotLoadExecutedMigrations(): void + { + $config = new Config(['paths' => ['migrations' => ROOT . '/config/LegacyAbstractMigration']]); + $manager = new Manager($config, $this->io); + + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersions') + ->willReturn([20260327000000]); + $envStub->expects($this->any()) + ->method('getCurrentVersion') + ->willReturn(20260327000000); + $envStub->expects($this->never()) + ->method('executeMigration'); + $manager->setEnvironment($envStub); + + $manager->migrate(); + } + + public function testValidateMigrationsReportsMigrationsThatCannotBeLoaded(): void + { + $config = new Config(['paths' => ['migrations' => ROOT . '/config/LegacyAbstractMigration']]); + $manager = new Manager($config, $this->io); + + $errors = $manager->validateMigrations(); + + $this->assertCount(1, $errors); + $this->assertArrayHasKey(20260327000000, $errors); + $this->assertStringContainsString('uses the legacy', $errors[20260327000000]); + } + + public function testValidateMigrationsWithValidMigrations(): void + { + $this->assertSame([], $this->manager->validateMigrations()); + } + public function testGettingAValidEnvironment(): void { $this->assertInstanceOf(