Skip to content

Commit 7f7fdcb

Browse files
authored
Merge pull request #63701 from nextcloud/fix/63351/schema-checker-disabled
fix(SchemaChecker): replay migrations for disabled apps in 'expected' schema
2 parents b670b43 + 6b92857 commit 7f7fdcb

3 files changed

Lines changed: 135 additions & 8 deletions

File tree

core/Command/Db/CheckSchema.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,41 @@ protected function configure(): void {
3636
protected function execute(InputInterface $input, OutputInterface $output): int {
3737
$onlyTable = $input->getArgument('table');
3838
$findings = $this->schemaChecker->getFindings($onlyTable);
39+
['blocking' => $blocking, 'byDisabledApp' => $byDisabledApp] = $this->schemaChecker->partitionFindings($findings);
3940

4041
if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) {
4142
if ($findings === []) {
4243
$output->writeln('<info>The live database schema matches the expected schema.</info>');
4344
} else {
44-
foreach ($findings as $finding) {
45+
foreach ($blocking as $finding) {
4546
$output->writeln('<comment>' . $this->schemaChecker->formatFinding($finding) . '</comment>');
4647
}
48+
if ($output->isVerbose()) {
49+
$this->printDisabledAppFindings($byDisabledApp, $output);
50+
}
4751
}
4852
} else {
4953
$this->writeArrayInOutputFormat($input, $output, $findings);
5054
}
5155

52-
return $findings === [] ? 0 : 1;
56+
return $blocking === [] ? 0 : 1;
57+
}
58+
59+
/**
60+
* @param array<string, list<array{table: string, type: string, name?: string, changes?: list<string>, app: ?string, enabled: bool}>> $byDisabledApp
61+
*/
62+
private function printDisabledAppFindings(array $byDisabledApp, OutputInterface $output): void {
63+
if ($byDisabledApp === []) {
64+
return;
65+
}
66+
67+
$output->writeln('Disabled apps (not affecting exit code):');
68+
$output->writeln('If the schema for a disabled app differs from what is expected, this might indicate the app was updated since it was disabled. Missing migrations will be applied once the app is enabled again.');
69+
foreach ($byDisabledApp as $app => $appFindings) {
70+
$output->writeln(" {$app}:");
71+
foreach ($appFindings as $finding) {
72+
$output->writeln(' - <comment>' . $this->schemaChecker->formatFinding($finding) . '</comment>');
73+
}
74+
}
5375
}
5476
}

core/Command/Upgrade.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,15 @@ private function checkSchema(OutputInterface $output): void {
252252
return;
253253
}
254254

255+
['blocking' => $blocking] = $this->schemaChecker->partitionFindings($findings);
256+
if ($blocking === []) {
257+
return;
258+
}
259+
255260
$output->writeln('<comment>The database schema does not match what is expected for the installed version:</comment>');
256-
foreach ($findings as $finding) {
261+
foreach ($blocking as $finding) {
257262
$output->writeln(' - ' . $this->schemaChecker->formatFinding($finding));
258263
}
259-
$output->writeln('<comment>Run "occ db:schema:check" for details.</comment>');
264+
$output->writeln('<comment>Run "occ db:schema:check -v" for details.</comment>');
260265
}
261266
}

lib/private/DB/SchemaChecker.php

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
use Doctrine\DBAL\Schema\TableDiff;
1515
use Doctrine\DBAL\Types\Types;
1616
use OC\Migration\NullOutput;
17+
use OCP\App\AppPathNotFoundException;
1718
use OCP\App\IAppManager;
19+
use OCP\IAppConfig;
1820

1921
/**
2022
* Compares the live database schema against the schema expected for the
@@ -24,19 +26,33 @@
2426
class SchemaChecker {
2527
public function __construct(
2628
private readonly Connection $connection,
29+
private readonly IAppConfig $appConfig,
2730
private readonly IAppManager $appManager,
2831
) {
2932
}
3033

3134
/**
32-
* @return list<array{table: string, type: string, name?: string, changes?: list<string>}>
35+
* @return list<array{table: string, type: string, name?: string, changes?: list<string>, app: ?string, enabled: bool}>
3336
*/
3437
public function getFindings(?string $onlyTable = null): array {
3538
$expectedSchema = new Schema();
39+
$enabledApps = array_flip($this->appManager->getEnabledApps());
40+
3641
$this->applyMigrations('core', $expectedSchema);
37-
foreach ($this->appManager->getEnabledApps() as $app) {
42+
43+
// Enabled apps are already autoloaded at boot, no extra class loading needed.
44+
foreach (array_keys($enabledApps) as $app) {
3845
$this->applyMigrations($app, $expectedSchema);
3946
}
47+
48+
// Disabled apps keep their tables, so replay their migrations too.
49+
$disabledApps = array_diff(array_keys($this->appConfig->getAppInstalledVersions()), array_keys($enabledApps));
50+
// Table name => owning disabled app, so its findings can be marked non-blocking below.
51+
$disabledAppTableOwners = [];
52+
foreach ($disabledApps as $app) {
53+
$this->applyDisabledMigrations($app, $expectedSchema, $disabledAppTableOwners);
54+
}
55+
4056
$this->addMigrationsTable($expectedSchema);
4157
$this->materializeUniqueConstraints($expectedSchema);
4258

@@ -50,11 +66,17 @@ public function getFindings(?string $onlyTable = null): array {
5066
$comparator = $this->connection->createSchemaManager()->createComparator();
5167
$diff = $comparator->compareSchemas($liveSchema, $expectedSchema);
5268

53-
return $this->buildFindings($diff);
69+
return array_map(function (array $finding) use ($disabledAppTableOwners, $enabledApps): array {
70+
$app = $disabledAppTableOwners[$finding['table']] ?? null;
71+
$finding['app'] = $app;
72+
// Only tables owned by a disabled app are non-blocking.
73+
$finding['enabled'] = $app === null || $app === 'core' || isset($enabledApps[$app]);
74+
return $finding;
75+
}, $this->buildFindings($diff));
5476
}
5577

5678
/**
57-
* @param array{table: string, type: string, name?: string, changes?: list<string>} $finding
79+
* @param array{table: string, type: string, name?: string, changes?: list<string>, app?: ?string, enabled?: bool} $finding
5880
*/
5981
public function formatFinding(array $finding): string {
6082
return match ($finding['type']) {
@@ -69,6 +91,26 @@ public function formatFinding(array $finding): string {
6991
};
7092
}
7193

94+
/**
95+
* Splits findings into blocking ones (from core or an enabled app) and
96+
* non-blocking ones, grouped by the disabled app that owns them.
97+
*
98+
* @param list<array{table: string, type: string, name?: string, changes?: list<string>, app: ?string, enabled: bool}> $findings
99+
* @return array{blocking: list<array{table: string, type: string, name?: string, changes?: list<string>, app: ?string, enabled: bool}>, byDisabledApp: array<string, list<array{table: string, type: string, name?: string, changes?: list<string>, app: ?string, enabled: bool}>>}
100+
*/
101+
public function partitionFindings(array $findings): array {
102+
$blocking = [];
103+
$byDisabledApp = [];
104+
foreach ($findings as $finding) {
105+
if ($finding['enabled']) {
106+
$blocking[] = $finding;
107+
} else {
108+
$byDisabledApp[$finding['app']][] = $finding;
109+
}
110+
}
111+
return ['blocking' => $blocking, 'byDisabledApp' => $byDisabledApp];
112+
}
113+
72114
private function applyMigrations(string $app, Schema $schema): void {
73115
$output = new NullOutput();
74116
$ms = new MigrationService($app, $this->connection, $output);
@@ -80,6 +122,64 @@ private function applyMigrations(string $app, Schema $schema): void {
80122
}
81123
}
82124

125+
/**
126+
* @param array<string, string> $disabledAppTableOwners table name => owning app id, updated in place
127+
*/
128+
private function applyDisabledMigrations(string $app, Schema $schema, array &$disabledAppTableOwners): void {
129+
try {
130+
$appPath = $this->appManager->getAppPath($app);
131+
} catch (AppPathNotFoundException) {
132+
// Installed, but code is gone: no migrations to replay.
133+
return;
134+
}
135+
136+
$existingTables = [];
137+
foreach ($schema->getTables() as $table) {
138+
$existingTables[$table->getName()] = true;
139+
}
140+
141+
try {
142+
// Disabled apps are not autoloaded on boot. Load only the migration
143+
// classes themselves directly from disk, rather than registering
144+
// the whole app for PSR-4 autoloading.
145+
foreach ($this->findMigrationFiles($appPath . '/lib/Migration') as $file) {
146+
require_once $file;
147+
}
148+
149+
$this->applyMigrations($app, $schema);
150+
} catch (\Throwable) {
151+
return;
152+
}
153+
154+
foreach ($schema->getTables() as $table) {
155+
if (!isset($existingTables[$table->getName()])) {
156+
$disabledAppTableOwners[$table->getName()] = $app;
157+
}
158+
}
159+
}
160+
161+
/**
162+
* Copied from MigrationService::findMigrations(), minus the class-name mapping.
163+
*
164+
* @return list<string>
165+
*/
166+
private function findMigrationFiles(string $directory): array {
167+
$directory = realpath($directory);
168+
if ($directory === false || !file_exists($directory) || !is_dir($directory)) {
169+
return [];
170+
}
171+
172+
$iterator = new \RegexIterator(
173+
new \RecursiveIteratorIterator(
174+
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
175+
\RecursiveIteratorIterator::LEAVES_ONLY
176+
),
177+
'#^.+\\/Version[^\\/]{1,255}\\.php$#i',
178+
\RegexIterator::GET_MATCH);
179+
180+
return array_keys(iterator_to_array($iterator));
181+
}
182+
83183
/**
84184
* The migrations bookkeeping table is created directly by MigrationService
85185
* outside of any app's changeSchema(), so replaying migrations never

0 commit comments

Comments
 (0)