1414use Doctrine \DBAL \Schema \TableDiff ;
1515use Doctrine \DBAL \Types \Types ;
1616use OC \Migration \NullOutput ;
17+ use OCP \App \AppPathNotFoundException ;
1718use OCP \App \IAppManager ;
19+ use OCP \IAppConfig ;
1820
1921/**
2022 * Compares the live database schema against the schema expected for the
2426class 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