Skip to content

Commit 410b1d9

Browse files
nicospclaude
andcommitted
Add an explicit way to validate migration files
Migration classes are now only loaded when they are executed, so a file that cannot be loaded is no longer reported by status checks until the migration runs. Adds Manager::validateMigrations(), which loads every migration class and returns the error messages indexed by version, and exposes it as `bin/cake migrations status --validate`. The command exits with 1 and prints the offending versions when a migration cannot be loaded, so CI can validate every migration while status checks and the middleware stay fast. The option can be combined with --all to cover the app and every loaded plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6be34ba commit 410b1d9

6 files changed

Lines changed: 157 additions & 1 deletion

File tree

‎docs/en/getting-started/running-and-managing-migrations.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ in CI.
9090
e.g. `{"app": [...], "PluginName": [...]}`. `--all` cannot be combined with
9191
`--plugin` or `--cleanup`.
9292

93+
### Validating Migration Files
94+
95+
Migration classes are only loaded when the migration they contain is executed.
96+
A migration file that cannot be loaded, for example one still extending the
97+
removed `Migrations\AbstractMigration` class, will therefore not fail `status`
98+
or the `PendingMigrationsMiddleware`. The `--validate` option loads every
99+
migration class and reports the ones that cannot be loaded:
100+
101+
```bash
102+
bin/cake migrations status --validate
103+
```
104+
105+
When any migration cannot be loaded, the offending versions are printed to
106+
stderr and the command exits with `1`, which makes it a useful CI check.
107+
Otherwise the regular status output follows. The option can be combined with
108+
`--all` to validate the app and every loaded plugin in one call.
109+
110+
The same check is available programmatically through
111+
`Manager::validateMigrations()`, which returns the error messages indexed by
112+
migration version.
113+
93114
### Cleaning Up Missing Migrations
94115

95116
Sometimes migration files may be deleted from the filesystem but still exist in

‎src/Command/StatusCommand.php‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Cake\Core\Plugin;
2121
use Migrations\Config\ConfigInterface;
2222
use Migrations\Db\Adapter\UnifiedMigrationsTableStorage;
23+
use Migrations\Migration\Manager;
2324
use Migrations\Migration\ManagerFactory;
2425

2526
/**
@@ -78,6 +79,8 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption
7879
'Add <info>-v</info> to also print the per-section migration tables.',
7980
'<info>migrations status --cleanup</info>',
8081
'Remove *MISSING* migrations from the migration tracking table',
82+
'<info>migrations status --validate</info>',
83+
'Load every migration class and fail if any of them cannot be loaded.',
8184
])->addOption('plugin', [
8285
'short' => 'p',
8386
'help' => 'The plugin to run migrations for',
@@ -104,6 +107,11 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption
104107
'help' => 'Remove MISSING migrations from the migration tracking table',
105108
'boolean' => true,
106109
'default' => false,
110+
])->addOption('validate', [
111+
'help' => 'Load every migration class and fail if any of them cannot be loaded. '
112+
. 'Migration classes are otherwise only loaded when they are executed.',
113+
'boolean' => true,
114+
'default' => false,
107115
]);
108116

109117
return $parser;
@@ -146,6 +154,20 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
146154
]);
147155
$manager = $factory->createManager($io);
148156

157+
if ($args->getOption('validate')) {
158+
/** @var string|null $plugin */
159+
$plugin = $args->getOption('plugin');
160+
if (!$this->validateMigrations($manager, $io, $plugin ?? 'app')) {
161+
return Command::CODE_ERROR;
162+
}
163+
if ($format !== 'json') {
164+
$io->out(sprintf(
165+
'<success>All %d migrations can be loaded.</success>',
166+
count($manager->getMigrationVersions()),
167+
));
168+
}
169+
}
170+
149171
if ($clean) {
150172
$removed = $manager->cleanupMissingMigrations();
151173
if ($removed === 0) {
@@ -200,6 +222,8 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format):
200222
}
201223

202224
$verbose = (bool)$args->getOption('verbose');
225+
$validate = (bool)$args->getOption('validate');
226+
$validationFailed = false;
203227
$jsonResults = [];
204228
$summary = [];
205229
$exitCode = Command::CODE_SUCCESS;
@@ -212,6 +236,11 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format):
212236
'dry-run' => $args->getOption('dry-run'),
213237
]);
214238
$manager = $factory->createManager($io);
239+
240+
if ($validate && !$this->validateMigrations($manager, $io, $label)) {
241+
$validationFailed = true;
242+
}
243+
215244
$migrations = $manager->printStatus($format);
216245

217246
$sectionExit = $this->statusExitCode($migrations);
@@ -241,6 +270,10 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format):
241270
$this->display($migrations, $io, $manager->getSchemaTableName());
242271
}
243272

273+
if ($validationFailed) {
274+
return Command::CODE_ERROR;
275+
}
276+
244277
if ($format === 'json') {
245278
$flags = 0;
246279
if ($verbose) {
@@ -256,6 +289,33 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format):
256289
return $exitCode;
257290
}
258291

292+
/**
293+
* Load every migration class and print the ones that could not be loaded.
294+
*
295+
* @param \Migrations\Migration\Manager $manager The manager to load migrations with.
296+
* @param \Cake\Console\ConsoleIo $io The console io.
297+
* @param string $label The section the migrations belong to.
298+
* @return bool True when every migration class could be loaded.
299+
*/
300+
protected function validateMigrations(Manager $manager, ConsoleIo $io, string $label): bool
301+
{
302+
$errors = $manager->validateMigrations();
303+
if (!$errors) {
304+
return true;
305+
}
306+
307+
$io->err(sprintf(
308+
'<error>%s: %d migration(s) could not be loaded:</error>',
309+
$label === 'app' ? 'APP' : $label,
310+
count($errors),
311+
));
312+
foreach ($errors as $version => $message) {
313+
$io->err(sprintf(' - %d: %s', $version, $message));
314+
}
315+
316+
return false;
317+
}
318+
259319
/**
260320
* Count actionable items (down + missing) in a section's migrations array.
261321
*

‎src/Migration/Manager.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Migrations\Util\Util;
2020
use Psr\Container\ContainerInterface;
2121
use RuntimeException;
22+
use Throwable;
2223

2324
class Manager
2425
{
@@ -981,6 +982,30 @@ public function getMigration(int $version): MigrationInterface
981982
return $this->loadedMigrations[$version];
982983
}
983984

985+
/**
986+
* Loads every migration class and collects the errors that prevent them from being loaded.
987+
*
988+
* Migration classes are loaded when the migration they contain is executed, so a broken
989+
* migration file is only reported when that migration runs. This loads all of them upfront
990+
* so that the migration files can be validated explicitly, for example in CI.
991+
*
992+
* @throws \InvalidArgumentException When two migrations share a version or a name
993+
* @return array<int, string> Error messages indexed by migration version.
994+
*/
995+
public function validateMigrations(): array
996+
{
997+
$errors = [];
998+
foreach ($this->getMigrationVersions() as $version) {
999+
try {
1000+
$this->getMigration($version);
1001+
} catch (Throwable $e) {
1002+
$errors[$version] = $e->getMessage();
1003+
}
1004+
}
1005+
1006+
return $errors;
1007+
}
1008+
9841009
/**
9851010
* Gets the name of a database migration without loading its class.
9861011
*

‎tests/TestCase/Command/CompletionTest.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ public function testMigrationsOptionsStatus(): void
134134
$this->exec('completion options migrations.migrations status');
135135
$this->assertCount(1, $this->_out->messages());
136136
$output = $this->_out->messages()[0];
137-
$expected = '--all --cleanup --connection -c --format -f --help -h --plugin -p --quiet -q --source -s --verbose -v';
137+
$expected = '--all --cleanup --connection -c --format -f --help -h --plugin -p --quiet -q --source -s';
138+
$expected .= ' --validate --verbose -v';
138139
$outputExplode = explode(' ', trim($output));
139140
sort($outputExplode);
140141
$expectedExplode = explode(' ', $expected);

‎tests/TestCase/Command/StatusCommandTest.php‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,36 @@ public function testAllRejectsCleanupOption(): void
182182
$this->assertExitError();
183183
$this->assertErrorContains('cannot be combined with --cleanup');
184184
}
185+
186+
public function testValidateHelp(): void
187+
{
188+
$this->exec('migrations status --help');
189+
$this->assertExitSuccess();
190+
$this->assertOutputContains('--validate');
191+
$this->assertOutputContains('Load every migration class');
192+
}
193+
194+
public function testValidateWithValidMigrations(): void
195+
{
196+
$this->exec('migrations status -c test --validate');
197+
$this->assertExitSuccess();
198+
$this->assertOutputContains('migrations can be loaded');
199+
// The status table is still printed.
200+
$this->assertOutputContains('Migration ID');
201+
}
202+
203+
public function testValidateWithMigrationThatCannotBeLoaded(): void
204+
{
205+
$this->exec('migrations status -c test -s LegacyAbstractMigration --validate');
206+
$this->assertExitError();
207+
$this->assertErrorContains('could not be loaded');
208+
$this->assertErrorContains('20260327000000');
209+
$this->assertOutputNotContains('Migration ID');
210+
}
211+
212+
public function testAllValidatesEverySection(): void
213+
{
214+
$this->exec('migrations status -c test --all --validate');
215+
$this->assertExitCode(StatusCommand::CODE_STATUS_DOWN);
216+
}
185217
}

‎tests/TestCase/Migration/ManagerTest.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,23 @@ public function testMigrateDoesNotLoadExecutedMigrations(): void
718718
$manager->migrate();
719719
}
720720

721+
public function testValidateMigrationsReportsMigrationsThatCannotBeLoaded(): void
722+
{
723+
$config = new Config(['paths' => ['migrations' => ROOT . '/config/LegacyAbstractMigration']]);
724+
$manager = new Manager($config, $this->io);
725+
726+
$errors = $manager->validateMigrations();
727+
728+
$this->assertCount(1, $errors);
729+
$this->assertArrayHasKey(20260327000000, $errors);
730+
$this->assertStringContainsString('uses the legacy', $errors[20260327000000]);
731+
}
732+
733+
public function testValidateMigrationsWithValidMigrations(): void
734+
{
735+
$this->assertSame([], $this->manager->validateMigrations());
736+
}
737+
721738
public function testGettingAValidEnvironment(): void
722739
{
723740
$this->assertInstanceOf(

0 commit comments

Comments
 (0)