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(), + ); + } +}