Skip to content

Commit 870e92e

Browse files
dereuromarkclaude
andcommitted
Add schema drift detection feature
Adds Prisma-style drift detection to compare actual database schema against what migrations define. Uses the `test` database as shadow. - Add getStructuredSchema() for schema introspection via CakePHP ORM - Add compareSchemas() to diff expected vs actual schema - Add driftCheck action using test DB as shadow (no temp DB needed) - Support MySQL and PostgreSQL databases - Support connection selection (all except test) - Safety check: test DB must be different from selected connection - Ignore plugin phinxlog tables (*_phinxlog) - Report extra/missing tables, columns, indexes, and constraints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent edd963a commit 870e92e

File tree

6 files changed

+816
-5
lines changed

6 files changed

+816
-5
lines changed

signal-desktop-keyring.gpg

2.17 KB
Binary file not shown.

src/Controller/Component/MigrationsComponent.php

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@
33
namespace TestHelper\Controller\Component;
44

55
use Cake\Controller\Component;
6+
use Cake\Core\Configure;
7+
use Cake\Database\Connection;
68
use Cake\Datasource\ConnectionManager;
79

810
class 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

Comments
 (0)