33namespace TestHelper \Controller \Component ;
44
55use Cake \Controller \Component ;
6+ use Cake \Core \Configure ;
7+ use Cake \Database \Connection ;
68use Cake \Datasource \ConnectionManager ;
79
810class MigrationsComponent extends Component {
911
12+ /**
13+ * Tables to ignore when comparing schemas.
14+ *
15+ * @var array<string>
16+ */
17+ protected array $ ignoredTables = ['phinxlog ' , 'cake_migrations ' , 'cake_seeds ' ];
18+
1019 /**
1120 * @param string $database
1221 * @return string
@@ -24,4 +33,353 @@ public function getSchema(string $database): string {
2433 return $ content ;
2534 }
2635
36+ /**
37+ * Get structured schema information from a database connection.
38+ *
39+ * @param \Cake\Database\Connection $connection
40+ * @return array<string, array<string, mixed>>
41+ */
42+ public function getStructuredSchema (Connection $ connection ): array {
43+ $ schema = [];
44+
45+ /** @var \Cake\Database\Schema\Collection $schemaCollection */
46+ $ schemaCollection = $ connection ->getSchemaCollection ();
47+ $ tables = $ schemaCollection ->listTables ();
48+
49+ foreach ($ tables as $ tableName ) {
50+ if ($ this ->isIgnoredTable ($ tableName )) {
51+ continue ;
52+ }
53+
54+ $ tableSchema = $ schemaCollection ->describe ($ tableName );
55+
56+ $ columns = [];
57+ foreach ($ tableSchema ->columns () as $ columnName ) {
58+ $ columnData = $ tableSchema ->getColumn ($ columnName );
59+ $ columns [$ columnName ] = $ this ->normalizeColumn ($ columnData );
60+ }
61+
62+ $ indexes = [];
63+ foreach ($ tableSchema ->indexes () as $ indexName ) {
64+ $ indexes [$ indexName ] = $ tableSchema ->getIndex ($ indexName );
65+ }
66+
67+ $ constraints = [];
68+ foreach ($ tableSchema ->constraints () as $ constraintName ) {
69+ $ constraints [$ constraintName ] = $ tableSchema ->getConstraint ($ constraintName );
70+ }
71+
72+ $ schema [$ tableName ] = [
73+ 'columns ' => $ columns ,
74+ 'indexes ' => $ indexes ,
75+ 'constraints ' => $ constraints ,
76+ ];
77+ }
78+
79+ ksort ($ schema );
80+
81+ return $ schema ;
82+ }
83+
84+ /**
85+ * Check if a table should be ignored in schema comparison.
86+ *
87+ * @param string $tableName
88+ * @return bool
89+ */
90+ protected function isIgnoredTable (string $ tableName ): bool {
91+ if (in_array ($ tableName , $ this ->ignoredTables , true )) {
92+ return true ;
93+ }
94+
95+ // Ignore plugin phinxlog tables (e.g., blog_phinxlog, users_phinxlog)
96+ if (str_ends_with ($ tableName , '_phinxlog ' )) {
97+ return true ;
98+ }
99+
100+ return false ;
101+ }
102+
103+ /**
104+ * Normalize column data for comparison (remove volatile attributes).
105+ *
106+ * @param array<string, mixed>|null $columnData
107+ * @return array<string, mixed>
108+ */
109+ protected function normalizeColumn (?array $ columnData ): array {
110+ if ($ columnData === null ) {
111+ return [];
112+ }
113+
114+ // Remove attributes that may vary but don't affect functionality
115+ unset($ columnData ['comment ' ]);
116+
117+ return $ columnData ;
118+ }
119+
120+ /**
121+ * Compare two schemas and return drift information.
122+ *
123+ * @param array<string, array<string, mixed>> $expected Schema from migrations (shadow DB)
124+ * @param array<string, array<string, mixed>> $actual Schema from actual database
125+ * @return array<string, array<int, array<string, mixed>>>
126+ */
127+ public function compareSchemas (array $ expected , array $ actual ): array {
128+ $ drift = [
129+ 'extra_tables ' => [],
130+ 'missing_tables ' => [],
131+ 'column_diffs ' => [],
132+ 'index_diffs ' => [],
133+ 'constraint_diffs ' => [],
134+ ];
135+
136+ $ expectedTables = array_keys ($ expected );
137+ $ actualTables = array_keys ($ actual );
138+
139+ // Tables in actual but not in expected (extra)
140+ $ extraTables = array_diff ($ actualTables , $ expectedTables );
141+ foreach ($ extraTables as $ table ) {
142+ $ drift ['extra_tables ' ][] = [
143+ 'table ' => $ table ,
144+ 'columns ' => array_keys ($ actual [$ table ]['columns ' ]),
145+ ];
146+ }
147+
148+ // Tables in expected but not in actual (missing)
149+ $ missingTables = array_diff ($ expectedTables , $ actualTables );
150+ foreach ($ missingTables as $ table ) {
151+ $ drift ['missing_tables ' ][] = [
152+ 'table ' => $ table ,
153+ 'columns ' => array_keys ($ expected [$ table ]['columns ' ]),
154+ ];
155+ }
156+
157+ // Compare tables that exist in both
158+ $ commonTables = array_intersect ($ expectedTables , $ actualTables );
159+ foreach ($ commonTables as $ table ) {
160+ $ this ->compareTableColumns ($ table , $ expected [$ table ], $ actual [$ table ], $ drift );
161+ $ this ->compareTableIndexes ($ table , $ expected [$ table ], $ actual [$ table ], $ drift );
162+ $ this ->compareTableConstraints ($ table , $ expected [$ table ], $ actual [$ table ], $ drift );
163+ }
164+
165+ return $ drift ;
166+ }
167+
168+ /**
169+ * Compare columns between expected and actual table schemas.
170+ *
171+ * @param string $table
172+ * @param array<string, mixed> $expected
173+ * @param array<string, mixed> $actual
174+ * @param array<string, array<int, array<string, mixed>>> $drift
175+ * @return void
176+ */
177+ protected function compareTableColumns (string $ table , array $ expected , array $ actual , array &$ drift ): void {
178+ $ expectedColumns = $ expected ['columns ' ];
179+ $ actualColumns = $ actual ['columns ' ];
180+
181+ $ expectedColumnNames = array_keys ($ expectedColumns );
182+ $ actualColumnNames = array_keys ($ actualColumns );
183+
184+ // Extra columns (in actual but not expected)
185+ $ extraColumns = array_diff ($ actualColumnNames , $ expectedColumnNames );
186+ foreach ($ extraColumns as $ column ) {
187+ $ drift ['column_diffs ' ][] = [
188+ 'type ' => 'extra ' ,
189+ 'table ' => $ table ,
190+ 'column ' => $ column ,
191+ 'actual ' => $ actualColumns [$ column ],
192+ ];
193+ }
194+
195+ // Missing columns (in expected but not actual)
196+ $ missingColumns = array_diff ($ expectedColumnNames , $ actualColumnNames );
197+ foreach ($ missingColumns as $ column ) {
198+ $ drift ['column_diffs ' ][] = [
199+ 'type ' => 'missing ' ,
200+ 'table ' => $ table ,
201+ 'column ' => $ column ,
202+ 'expected ' => $ expectedColumns [$ column ],
203+ ];
204+ }
205+
206+ // Type mismatches for columns that exist in both
207+ $ commonColumns = array_intersect ($ expectedColumnNames , $ actualColumnNames );
208+ foreach ($ commonColumns as $ column ) {
209+ $ expectedCol = $ expectedColumns [$ column ];
210+ $ actualCol = $ actualColumns [$ column ];
211+
212+ $ differences = $ this ->getColumnDifferences ($ expectedCol , $ actualCol );
213+ if ($ differences ) {
214+ $ drift ['column_diffs ' ][] = [
215+ 'type ' => 'mismatch ' ,
216+ 'table ' => $ table ,
217+ 'column ' => $ column ,
218+ 'expected ' => $ expectedCol ,
219+ 'actual ' => $ actualCol ,
220+ 'differences ' => $ differences ,
221+ ];
222+ }
223+ }
224+ }
225+
226+ /**
227+ * Get differences between two column definitions.
228+ *
229+ * @param array<string, mixed> $expected
230+ * @param array<string, mixed> $actual
231+ * @return array<string, array<string, mixed>>
232+ */
233+ protected function getColumnDifferences (array $ expected , array $ actual ): array {
234+ $ differences = [];
235+ $ keysToCompare = ['type ' , 'length ' , 'precision ' , 'scale ' , 'null ' , 'default ' , 'unsigned ' , 'autoIncrement ' ];
236+
237+ foreach ($ keysToCompare as $ key ) {
238+ $ expectedVal = $ expected [$ key ] ?? null ;
239+ $ actualVal = $ actual [$ key ] ?? null ;
240+
241+ if ($ expectedVal !== $ actualVal ) {
242+ $ differences [$ key ] = [
243+ 'expected ' => $ expectedVal ,
244+ 'actual ' => $ actualVal ,
245+ ];
246+ }
247+ }
248+
249+ return $ differences ;
250+ }
251+
252+ /**
253+ * Compare indexes between expected and actual table schemas.
254+ *
255+ * @param string $table
256+ * @param array<string, mixed> $expected
257+ * @param array<string, mixed> $actual
258+ * @param array<string, array<int, array<string, mixed>>> $drift
259+ * @return void
260+ */
261+ protected function compareTableIndexes (string $ table , array $ expected , array $ actual , array &$ drift ): void {
262+ $ expectedIndexes = $ expected ['indexes ' ];
263+ $ actualIndexes = $ actual ['indexes ' ];
264+
265+ $ expectedIndexNames = array_keys ($ expectedIndexes );
266+ $ actualIndexNames = array_keys ($ actualIndexes );
267+
268+ // Extra indexes
269+ $ extraIndexes = array_diff ($ actualIndexNames , $ expectedIndexNames );
270+ foreach ($ extraIndexes as $ index ) {
271+ $ drift ['index_diffs ' ][] = [
272+ 'type ' => 'extra ' ,
273+ 'table ' => $ table ,
274+ 'index ' => $ index ,
275+ 'actual ' => $ actualIndexes [$ index ],
276+ ];
277+ }
278+
279+ // Missing indexes
280+ $ missingIndexes = array_diff ($ expectedIndexNames , $ actualIndexNames );
281+ foreach ($ missingIndexes as $ index ) {
282+ $ drift ['index_diffs ' ][] = [
283+ 'type ' => 'missing ' ,
284+ 'table ' => $ table ,
285+ 'index ' => $ index ,
286+ 'expected ' => $ expectedIndexes [$ index ],
287+ ];
288+ }
289+
290+ // Index definition mismatches
291+ $ commonIndexes = array_intersect ($ expectedIndexNames , $ actualIndexNames );
292+ foreach ($ commonIndexes as $ index ) {
293+ if ($ expectedIndexes [$ index ] !== $ actualIndexes [$ index ]) {
294+ $ drift ['index_diffs ' ][] = [
295+ 'type ' => 'mismatch ' ,
296+ 'table ' => $ table ,
297+ 'index ' => $ index ,
298+ 'expected ' => $ expectedIndexes [$ index ],
299+ 'actual ' => $ actualIndexes [$ index ],
300+ ];
301+ }
302+ }
303+ }
304+
305+ /**
306+ * Compare constraints between expected and actual table schemas.
307+ *
308+ * @param string $table
309+ * @param array<string, mixed> $expected
310+ * @param array<string, mixed> $actual
311+ * @param array<string, array<int, array<string, mixed>>> $drift
312+ * @return void
313+ */
314+ protected function compareTableConstraints (string $ table , array $ expected , array $ actual , array &$ drift ): void {
315+ $ expectedConstraints = $ expected ['constraints ' ];
316+ $ actualConstraints = $ actual ['constraints ' ];
317+
318+ $ expectedConstraintNames = array_keys ($ expectedConstraints );
319+ $ actualConstraintNames = array_keys ($ actualConstraints );
320+
321+ // Extra constraints
322+ $ extraConstraints = array_diff ($ actualConstraintNames , $ expectedConstraintNames );
323+ foreach ($ extraConstraints as $ constraint ) {
324+ $ drift ['constraint_diffs ' ][] = [
325+ 'type ' => 'extra ' ,
326+ 'table ' => $ table ,
327+ 'constraint ' => $ constraint ,
328+ 'actual ' => $ actualConstraints [$ constraint ],
329+ ];
330+ }
331+
332+ // Missing constraints
333+ $ missingConstraints = array_diff ($ expectedConstraintNames , $ actualConstraintNames );
334+ foreach ($ missingConstraints as $ constraint ) {
335+ $ drift ['constraint_diffs ' ][] = [
336+ 'type ' => 'missing ' ,
337+ 'table ' => $ table ,
338+ 'constraint ' => $ constraint ,
339+ 'expected ' => $ expectedConstraints [$ constraint ],
340+ ];
341+ }
342+
343+ // Constraint definition mismatches
344+ $ commonConstraints = array_intersect ($ expectedConstraintNames , $ actualConstraintNames );
345+ foreach ($ commonConstraints as $ constraint ) {
346+ if ($ expectedConstraints [$ constraint ] !== $ actualConstraints [$ constraint ]) {
347+ $ drift ['constraint_diffs ' ][] = [
348+ 'type ' => 'mismatch ' ,
349+ 'table ' => $ table ,
350+ 'constraint ' => $ constraint ,
351+ 'expected ' => $ expectedConstraints [$ constraint ],
352+ 'actual ' => $ actualConstraints [$ constraint ],
353+ ];
354+ }
355+ }
356+ }
357+
358+ /**
359+ * Check if there is any drift.
360+ *
361+ * @param array<string, array<int, array<string, mixed>>> $drift
362+ * @return bool
363+ */
364+ public function hasDrift (array $ drift ): bool {
365+ foreach ($ drift as $ driftType ) {
366+ if ($ driftType ) {
367+ return true ;
368+ }
369+ }
370+
371+ return false ;
372+ }
373+
374+ /**
375+ * Get the migration table name based on configuration.
376+ *
377+ * @return string
378+ */
379+ public function getMigrationTableName (): string {
380+ $ useLegacy = (bool )Configure::read ('Migrations.legacyTables ' );
381+
382+ return $ useLegacy ? 'phinxlog ' : 'cake_migrations ' ;
383+ }
384+
27385}
0 commit comments