diff --git a/core/Command/Db/Migrations/StatusCommand.php b/core/Command/Db/Migrations/StatusCommand.php
index 6fc8c213cd679..5fd95f6fc6bfb 100644
--- a/core/Command/Db/Migrations/StatusCommand.php
+++ b/core/Command/Db/Migrations/StatusCommand.php
@@ -31,8 +31,33 @@ public function __construct(
protected function configure() {
$this
->setName('migrations:status')
- ->setDescription('View the status of a set of migrations.')
- ->addArgument('app', InputArgument::REQUIRED, 'Name of the app this migration command shall work on');
+ ->setDescription('Show the database migration status for an app')
+ ->addArgument(
+ 'app',
+ InputArgument::REQUIRED,
+ 'App ID to inspect, or "core" for server migrations',
+ )
+ ->setHelp(<<<'HELP'
+The %command.name% command shows the database migration status
+for core or an installed app.
+
+It reports:
+ - migration versions recorded as executed
+ - migration files available in the installed code
+ - migrations that have not yet been applied
+ - executed migrations that are missing from the installed code
+
+This command is read-only. It does not execute migrations or modify migration
+history.
+
+If executed migrations are missing from the installed code, verify that the
+installed app and server code match the intended version. Do not remove records
+from the migration history table manually.
+
+Example:
+
+ php occ migrations:status core
+HELP);
}
#[\Override]
@@ -41,19 +66,129 @@ public function execute(InputInterface $input, OutputInterface $output): int {
$ms = new MigrationService($appName, $this->connection, new ConsoleOutput($output));
$infos = $this->getMigrationsInfos($ms);
- foreach ($infos as $key => $value) {
- if (is_array($value)) {
- $output->writeln(" >> $key:");
- foreach ($value as $subKey => $subValue) {
- $output->writeln(" >> $subKey: " . str_repeat(' ', 46 - strlen($subKey)) . $subValue);
+ $title = sprintf('Database migration status for "%s"', $infos['App']);
+ $output->writeln($title);
+ $output->writeln(str_repeat('=', strlen($title)));
+ $output->writeln('');
+
+ if ($infos['Missing from Installed Code'] > 0) {
+ $output->writeln('Status: Warning — migration history requires attention');
+ } elseif ($infos['Unapplied'] > 0) {
+ $output->writeln(sprintf(
+ 'Status: %d unapplied migration%s',
+ $infos['Unapplied'],
+ $infos['Unapplied'] === 1 ? '' : 's',
+ ));
+ } else {
+ $output->writeln('Status: Up to date');
+ }
+ $output->writeln('');
+
+ $sections = [
+ 'Migration configuration' => [
+ 'App',
+ 'History Table',
+ 'Migration Namespace',
+ 'Migration Directory',
+ ],
+ 'Version status' => [
+ 'Previous Available',
+ 'Last Recorded as Executed',
+ 'Next Available',
+ 'Latest Available',
+ ],
+ 'Migration counts' => [
+ 'Recorded as Executed',
+ 'Missing from Installed Code',
+ 'Available in Installed Code',
+ 'Unapplied',
+ ],
+ ];
+
+ foreach ($sections as $section => $keys) {
+ $output->writeln($section);
+ $output->writeln(str_repeat('-', strlen($section)));
+
+ $values = [];
+ foreach ($keys as $key) {
+ $values[$key] = $infos[$key];
+ }
+ $this->writeKeyValueRows($output, $values);
+
+ $output->writeln('');
+ }
+
+ $missingMigrationVersions = $infos['Missing Migration Versions'];
+ if ($missingMigrationVersions !== []) {
+ $output->writeln('Warnings');
+ $output->writeln('--------');
+ $output->writeln(sprintf(
+ '%d migration%s recorded as executed %s not present in the installed code:',
+ count($missingMigrationVersions),
+ count($missingMigrationVersions) === 1 ? '' : 's',
+ count($missingMigrationVersions) === 1 ? 'is' : 'are',
+ ));
+
+ foreach ($missingMigrationVersions as $version) {
+ $output->writeln(' - ' . $version);
+ }
+
+ $output->writeln('');
+ $output->writeln(
+ 'If this is unexpected, verify that the installed app and server code match the intended version.',
+ );
+ $output->writeln(
+ 'Do not remove records from the migration history table manually.',
+ );
+ $output->writeln('');
+ }
+
+ $output->writeln('Unapplied migrations');
+ $output->writeln('--------------------');
+
+ $unappliedMigrations = $infos['Unapplied Migrations'];
+ if ($unappliedMigrations === []) {
+ $output->writeln('None');
+ } else {
+ $first = true;
+ foreach ($unappliedMigrations as $version => $migration) {
+ if (!$first) {
+ $output->writeln('');
}
- } else {
- $output->writeln(" >> $key: " . str_repeat(' ', 50 - strlen($key)) . $value);
+
+ $output->writeln($version);
+ $this->writeKeyValueRows($output, $migration, 2);
+ $first = false;
}
}
+
return 0;
}
+ /**
+ * @param array $values
+ */
+ private function writeKeyValueRows(
+ OutputInterface $output,
+ array $values,
+ int $indent = 0,
+ ): void {
+ $labelWidth = max(array_map(
+ static fn (string $label): int => strlen($label) + 1,
+ array_keys($values),
+ ));
+ $prefix = str_repeat(' ', $indent);
+
+ foreach ($values as $label => $value) {
+ $output->writeln(sprintf(
+ '%s%-' . $labelWidth . 's %s',
+ $prefix,
+ $label . ':',
+ $value,
+ ));
+ }
+ }
+
/**
* @param string $optionName
* @param CompletionContext $context
@@ -85,49 +220,91 @@ public function completeArgumentValues($argumentName, CompletionContext $context
public function getMigrationsInfos(MigrationService $ms) {
$executedMigrations = $ms->getMigratedVersions();
$availableMigrations = $ms->getAvailableVersions();
- $executedUnavailableMigrations = array_diff($executedMigrations, array_keys($availableMigrations));
+ $executedUnavailableMigrations = array_diff($executedMigrations, $availableMigrations);
+ $unappliedMigrationVersions = array_diff($availableMigrations, $executedMigrations);
$numExecutedUnavailableMigrations = count($executedUnavailableMigrations);
- $numNewMigrations = count(array_diff(array_keys($availableMigrations), $executedMigrations));
+ $numNewMigrations = count($unappliedMigrationVersions);
+ $currentMigration = $executedMigrations === []
+ ? null
+ : end($executedMigrations);
+
$pending = $ms->describeMigrationStep();
+ $unappliedMigrations = [];
+ foreach ($unappliedMigrationVersions as $version) {
+ $migration = $ms->createInstance($version);
+ $unappliedMigrations[$version] = [
+ 'Name' => $migration->name() ?: 'Not provided',
+ 'Description' => $migration->description() ?: 'Not provided',
+ ];
+ }
$infos = [
'App' => $ms->getApp(),
- 'Version Table Name' => $ms->getMigrationsTableName(),
- 'Migrations Namespace' => $ms->getMigrationsNamespace(),
- 'Migrations Directory' => $ms->getMigrationsDirectory(),
- 'Previous Version' => $this->getFormattedVersionAlias($ms, 'prev'),
- 'Current Version' => $this->getFormattedVersionAlias($ms, 'current'),
- 'Next Version' => $this->getFormattedVersionAlias($ms, 'next'),
- 'Latest Version' => $this->getFormattedVersionAlias($ms, 'latest'),
- 'Executed Migrations' => count($executedMigrations),
- 'Executed Unavailable Migrations' => $numExecutedUnavailableMigrations,
- 'Available Migrations' => count($availableMigrations),
- 'New Migrations' => $numNewMigrations,
- 'Pending Migrations' => count($pending) ? $pending : 'None'
+ 'History Table' => $ms->getMigrationsTableName(),
+ 'Migration Namespace' => $ms->getMigrationsNamespace(),
+ 'Migration Directory' => $ms->getMigrationsDirectory(),
+ 'Previous Available' => $this->getFormattedRelativeVersion(
+ $availableMigrations,
+ $currentMigration,
+ -1,
+ ),
+ 'Last Recorded as Executed' => $currentMigration
+ ?? 'None (no migrations recorded as executed)',
+ 'Next Available' => $this->getFormattedRelativeVersion(
+ $availableMigrations,
+ $currentMigration,
+ 1,
+ ),
+ 'Latest Available' => $availableMigrations === []
+ ? 'None (no migration files found)'
+ : end($availableMigrations),
+ 'Recorded as Executed' => count($executedMigrations),
+ 'Missing from Installed Code' => $numExecutedUnavailableMigrations,
+ 'Missing Migration Versions' => array_values($executedUnavailableMigrations),
+ 'Available in Installed Code' => count($availableMigrations),
+ 'Unapplied' => $numNewMigrations,
+ 'Unapplied Migrations' => $unappliedMigrations,
];
return $infos;
}
/**
- * @param MigrationService $migrationService
- * @param string $alias
- * @return mixed|null|string
+ * @param list $availableMigrations
*/
- private function getFormattedVersionAlias(MigrationService $migrationService, $alias) {
- $migration = $migrationService->getMigration($alias);
- //No version found
- if ($migration === null) {
- if ($alias === 'next') {
- return 'Already at latest migration step';
+ private function getFormattedRelativeVersion(
+ array $availableMigrations,
+ ?string $currentMigration,
+ int $offset,
+ ): string {
+ if ($currentMigration === null) {
+ if ($offset < 0) {
+ return 'None (no migrations recorded as executed)';
}
- if ($alias === 'prev') {
- return 'Already at first migration step';
- }
+ return $availableMigrations === []
+ ? 'None (no migration files found)'
+ : $availableMigrations[0];
+ }
+
+ $currentIndex = array_search(
+ $currentMigration,
+ $availableMigrations,
+ true,
+ );
+
+ if ($currentIndex === false) {
+ return 'Unknown (last executed migration is missing from code)';
+ }
+
+ $relativeIndex = $currentIndex + $offset;
+ if (!isset($availableMigrations[$relativeIndex])) {
+ return $offset < 0
+ ? 'None (at first available migration)'
+ : 'None (at latest available migration)';
}
- return $migration;
+ return $availableMigrations[$relativeIndex];
}
}
diff --git a/tests/Core/Command/Db/Migrations/StatusCommandTest.php b/tests/Core/Command/Db/Migrations/StatusCommandTest.php
new file mode 100644
index 0000000000000..0b24c815ecdcf
--- /dev/null
+++ b/tests/Core/Command/Db/Migrations/StatusCommandTest.php
@@ -0,0 +1,214 @@
+command = new StatusCommand(
+ $this->createMock(Connection::class),
+ $this->createMock(IAppManager::class),
+ );
+
+ $this->migrationService = $this->createMock(MigrationService::class);
+ $this->migrationService->method('getApp')->willReturn('test');
+ $this->migrationService->method('getMigrationsTableName')->willReturn('oc_migrations');
+ $this->migrationService->method('getMigrationsNamespace')->willReturn('OCA\Test\Migration');
+ $this->migrationService->method('getMigrationsDirectory')->willReturn('/tmp/test/lib/Migration');
+ $this->migrationService->method('describeMigrationStep')->willReturn([]);
+ }
+
+ /**
+ * @param list $executedMigrations
+ * @param list $availableMigrations
+ * @param array $expectedStatuses
+ */
+ #[DataProvider('versionStatusProvider')]
+ public function testVersionStatuses(
+ array $executedMigrations,
+ array $availableMigrations,
+ array $expectedStatuses,
+ ): void {
+ $this->migrationService
+ ->method('getMigratedVersions')
+ ->willReturn($executedMigrations);
+ $this->migrationService
+ ->method('getAvailableVersions')
+ ->willReturn($availableMigrations);
+
+ $infos = $this->command->getMigrationsInfos($this->migrationService);
+
+ foreach ($expectedStatuses as $label => $expectedStatus) {
+ $this->assertSame($expectedStatus, $infos[$label], $label);
+ }
+ }
+
+ public static function versionStatusProvider(): array {
+ $availableMigrations = [
+ self::VERSION_1,
+ self::VERSION_2,
+ self::VERSION_3,
+ ];
+
+ return [
+ 'no migrations recorded as executed' => [
+ [],
+ $availableMigrations,
+ [
+ 'Previous Available' => 'None (no migrations recorded as executed)',
+ 'Last Recorded as Executed' => 'None (no migrations recorded as executed)',
+ 'Next Available' => self::VERSION_1,
+ 'Latest Available' => self::VERSION_3,
+ ],
+ ],
+ 'at first available migration' => [
+ [self::VERSION_1],
+ $availableMigrations,
+ [
+ 'Previous Available' => 'None (at first available migration)',
+ 'Last Recorded as Executed' => self::VERSION_1,
+ 'Next Available' => self::VERSION_2,
+ 'Latest Available' => self::VERSION_3,
+ ],
+ ],
+ 'at intermediate migration' => [
+ [self::VERSION_1, self::VERSION_2],
+ $availableMigrations,
+ [
+ 'Previous Available' => self::VERSION_1,
+ 'Last Recorded as Executed' => self::VERSION_2,
+ 'Next Available' => self::VERSION_3,
+ 'Latest Available' => self::VERSION_3,
+ ],
+ ],
+ 'at latest available migration' => [
+ $availableMigrations,
+ $availableMigrations,
+ [
+ 'Previous Available' => self::VERSION_2,
+ 'Last Recorded as Executed' => self::VERSION_3,
+ 'Next Available' => 'None (at latest available migration)',
+ 'Latest Available' => self::VERSION_3,
+ ],
+ ],
+ 'last executed migration is missing from code' => [
+ [self::VERSION_1, self::MISSING_VERSION],
+ $availableMigrations,
+ [
+ 'Previous Available' => 'Unknown (last executed migration is missing from code)',
+ 'Last Recorded as Executed' => self::MISSING_VERSION,
+ 'Next Available' => 'Unknown (last executed migration is missing from code)',
+ 'Latest Available' => self::VERSION_3,
+ ],
+ ],
+ 'no migration files found' => [
+ [],
+ [],
+ [
+ 'Previous Available' => 'None (no migrations recorded as executed)',
+ 'Last Recorded as Executed' => 'None (no migrations recorded as executed)',
+ 'Next Available' => 'None (no migration files found)',
+ 'Latest Available' => 'None (no migration files found)',
+ ],
+ ],
+ ];
+ }
+
+ public function testListsEveryUnappliedMigrationByVersion(): void {
+ $migration1 = $this->createMock(IMigrationStep::class);
+ $migration1->method('name')->willReturn('Update database schema');
+ $migration1->method('description')->willReturn('Adds the first schema change.');
+
+ $migration2 = $this->createMock(IMigrationStep::class);
+ $migration2->method('name')->willReturn('Update database schema');
+ $migration2->method('description')->willReturn('Adds the second schema change.');
+
+ $unnamedMigration = $this->createMock(IMigrationStep::class);
+ $unnamedMigration->method('name')->willReturn('');
+ $unnamedMigration->method('description')->willReturn('');
+
+ $this->migrationService
+ ->method('getMigratedVersions')
+ ->willReturn([self::VERSION_1]);
+ $this->migrationService
+ ->method('getAvailableVersions')
+ ->willReturn([
+ self::VERSION_1,
+ self::VERSION_2,
+ self::VERSION_3,
+ self::VERSION_4,
+ ]);
+ $this->migrationService
+ ->method('createInstance')
+ ->willReturnMap([
+ [self::VERSION_2, $migration1],
+ [self::VERSION_3, $migration2],
+ [self::VERSION_4, $unnamedMigration],
+ ]);
+
+ $infos = $this->command->getMigrationsInfos($this->migrationService);
+
+ $this->assertSame(3, $infos['Unapplied']);
+ $this->assertSame([
+ self::VERSION_2 => [
+ 'Name' => 'Update database schema',
+ 'Description' => 'Adds the first schema change.',
+ ],
+ self::VERSION_3 => [
+ 'Name' => 'Update database schema',
+ 'Description' => 'Adds the second schema change.',
+ ],
+ self::VERSION_4 => [
+ 'Name' => 'Not provided',
+ 'Description' => 'Not provided',
+ ],
+ ], $infos['Unapplied Migrations']);
+ }
+
+ public function testWriteKeyValueRowsAlignsLabelsDynamically(): void {
+ $output = new BufferedOutput();
+
+ self::invokePrivate($this->command, 'writeKeyValueRows', [
+ $output,
+ [
+ 'Short' => 'first',
+ 'Longer Label' => 'second',
+ ],
+ 2,
+ ]);
+
+ $this->assertSame(
+ " Short: first\n"
+ . " Longer Label: second\n",
+ $output->fetch(),
+ );
+ }
+}