Lines 94.16% 565 / 600
Functions and Methods 59.09% 13 / 22
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
SchemaDiffer 94.16% 565 / 600 59.09% 13 / 22 185.36 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 diff 100.00% 87 / 87 100.00% 1 / 1 15
 orderCreatesByDependencies 100.00% 41 / 41 100.00% 1 / 1 14
 orderDropsByDependencies 100.00% 31 / 31 100.00% 1 / 1 15
 tieTableRenames 100.00% 55 / 55 100.00% 1 / 1 15
 diffTable 76.05% 54 / 71 0.00% 0 / 1 35.28
 columnSets 100.00% 15 / 15 100.00% 1 / 1 8
 driftDetail 100.00% 30 / 30 100.00% 1 / 1 7
 renderDefault 71.42% 5 / 7 0.00% 0 / 1 5.58
 renameChange 100.00% 14 / 14 100.00% 1 / 1 2
 addChange 100.00% 14 / 14 100.00% 1 / 1 2
 dropChange 100.00% 14 / 14 100.00% 1 / 1 2
 modifyChange 100.00% 19 / 19 100.00% 1 / 1 4
 defaultsMatch 85.71% 6 / 7 0.00% 0 / 1 5.07
 unquoteLiteral 78.94% 15 / 19 0.00% 0 / 1 10.93
 diffForeignKeys 98.11% 52 / 53 0.00% 0 / 1 12
 foreignKeyShapesMatch 81.81% 9 / 11 0.00% 0 / 1 5.15
 diffChecks 100.00% 52 / 52 100.00% 1 / 1 10
 checkExpressionsMatch 75.00% 3 / 4 0.00% 0 / 1 2.06
 enumCheckMatches 81.81% 9 / 11 0.00% 0 / 1 6.22
 enumValuesDetail 100.00% 5 / 5 100.00% 1 / 1 1
 diffIndexes 87.17% 34 / 39 0.00% 0 / 1 12.30
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Schema;
6
7use BlueprintAU\Radiant\Database\Schema\Enums\CastSafety;
8use BlueprintAU\Radiant\Database\Schema\Enums\ColumnType;
9use BlueprintAU\Radiant\Database\Schema\Enums\SchemaOperation;
10use BlueprintAU\Radiant\Database\Schema\Inspectors\LiveTable;
11use BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector;
12
13/**
14 * Desired state vs. live schema → ordered, classified changes.
15 *
16 * Pure computation — desired state in, classified {@see SchemaChange}s out.
17 * Creates come first, then alters, drops last.
18 */
19final class SchemaDiffer
20{
21    /**
22     * Create a differ over a live-schema inspector.
23     *
24     * @param  SchemaInspector  $inspector
25     */
26    public function __construct(
27        private readonly SchemaInspector $inspector,
28    ) {
29    }
30
31    /**
32     * Diff the desired state against the live schema.
33     *
34     * @param  list<Blueprint>  $desired
35     * @param  list<string>  $protected  Tables that must never be dropped or offered as a rename target.
36     * @param  bool  $dropTables  Whether undeclared live tables are emitted as DropTable changes.
37     * @return list<SchemaChange>
38     * @throws \LogicException
39     */
40    public function diff(array $desired, array $protected = [], bool $dropTables = true): array
41    {
42        $creates = [];
43        $renames = [];
44        $alters = [];
45        $drops = [];
46
47        $liveTables = $this->inspector->tables();
48        $desiredTables = [];
49        $renamedAway = [];
50
51        foreach ($desired as $blueprint) {
52            $table = $blueprint->getTable();
53            $desiredTables[] = $table;
54
55            // A DECLARED table rename: the decision, not a guess. Verified
56            // against the live schema — the old table must exist and the
57            // new one must not. A declaration that does not match reality
58            // is a TYPO (the host thinks it is renaming but the database
59            // would silently create a DUPLICATE table) — fail fast, never
60            // fall through to a create.
61            $renamedFrom = $blueprint->getRenamedFrom();
62
63            if ($renamedFrom !== null) {
64                if (!in_array($renamedFrom, $liveTables, true)) {
65                    throw new \LogicException(sprintf(
66                        'Blueprint declares a rename of [%s] to [%s], but [%s] does not exist in the '
67                        . 'live schema — the declaration does not match reality. Fix the old table '
68                        . 'name, or drop the renamedFrom() declaration if a new table was intended.',
69                        $renamedFrom,
70                        $table,
71                        $renamedFrom,
72                    ));
73                }
74
75                if (in_array($table, $liveTables, true)) {
76                    throw new \LogicException(sprintf(
77                        'Blueprint declares a rename of [%s] to [%s], but [%s] already exists in the '
78                        . 'live schema — the rename target is taken. Fix the new table name.',
79                        $renamedFrom,
80                        $table,
81                        $table,
82                    ));
83                }
84
85                $renames[] = new SchemaChange(
86                    $table,
87                    SchemaOperation::RenameTable,
88                    $blueprint,
89                    false,
90                    sprintf(
91                        'rename table [%s] to [%s] — data travels with the rename',
92                        $renamedFrom,
93                        $table,
94                    ),
95                    false,
96                    $renamedFrom,
97                );
98                // The old table is RENAMED AWAY, not dropped — the drop
99                // loop must not emit a DropTable for it.
100                $renamedAway[] = $renamedFrom;
101
102                // The rename is the STARTING point, not the terminal one:
103                // the columns the rename carries over (the old table's live
104                // shape) are diffed against the desired shape, so the follow-up
105                // column/index/constraint changes land in the SAME plan. The
106                // changes target the NEW name — the rename applies first
107                // (renames precede alters in the final ordering).
108                foreach ([
109                    ...$this->diffTable($table, $blueprint, $renamedFrom),
110                    $this->diffIndexes($table, $blueprint, $renamedFrom),
111                    ...$this->diffForeignKeys($table, $blueprint, $renamedFrom),
112                    ...$this->diffChecks($table, $blueprint, $renamedFrom),
113                ] as $change) {
114                    if ($change !== null) {
115                        $alters[] = $change;
116                    }
117                }
118                continue;
119            }
120
121            if (!in_array($table, $liveTables, true)) {
122                $creates[] = new SchemaChange(
123                    $table,
124                    SchemaOperation::CreateTable,
125                    $blueprint,
126                    false,
127                    "create table [{$table}]",
128                );
129                continue;
130            }
131
132            // ALL diff passes run for an existing table — a table can need
133            // a column alter AND an index rebuild AND constraint changes
134            // at once. Column alters come first (an index rebuild may
135            // reference a just-added column).
136            foreach ([
137                ...$this->diffTable($table, $blueprint),
138                $this->diffIndexes($table, $blueprint),
139                ...$this->diffForeignKeys($table, $blueprint),
140                ...$this->diffChecks($table, $blueprint),
141            ] as $change) {
142                if ($change !== null) {
143                    $alters[] = $change;
144                }
145            }
146        }
147
148        // A live table the desired state no longer declares is a drop —
149        // destructive, and always last (reverse-dependency ordered). A
150        // table RENAMED AWAY by a declared rename is not a drop, and a
151        // PROTECTED table is never dropped — protection must not be
152        // defeatable by the host filtering the drop after the fact, so
153        // it is enforced here, before the changes are ever linked.
154        // With $dropTables off the plan is ADDITIVE-ONLY: undeclared
155        // tables are left untouched (not synced), and with no drop list
156        // the rename tie has nothing to pair against — creates stay
157        // plain creates.
158        if ($dropTables) {
159            foreach ($liveTables as $table) {
160                if (in_array($table, $renamedAway, true)) {
161                    continue;
162                }
163
164                if (in_array($table, $protected, true)) {
165                    continue;
166                }
167
168                if (!in_array($table, $desiredTables, true)) {
169                    $drops[] = new SchemaChange(
170                        $table,
171                        SchemaOperation::DropTable,
172                        new Blueprint($table),
173                        true,
174                        "drop table [{$table}] — DESTRUCTIVE: data loss",
175                    );
176                }
177            }
178        }
179
180        $creates = $this->orderCreatesByDependencies($creates);
181        $drops = $this->orderDropsByDependencies($drops);
182
183        $changes = [...$creates, ...$renames, ...$alters, ...$drops];
184
185        return $this->tieTableRenames($changes, $creates, $drops);
186    }
187
188    /**
189     * Order creates so referenced tables come first (topological sort).
190     *
191     * @param  list<SchemaChange>  $creates
192     * @return list<SchemaChange>
193     * @throws \LogicException
194     */
195    private function orderCreatesByDependencies(array $creates): array
196    {
197        if (count($creates) < 2) {
198            return $creates;
199        }
200
201        $byTable = [];
202
203        foreach ($creates as $create) {
204            $byTable[$create->table] = $create;
205        }
206
207        // Edges: referrer → referenced (referrer depends on referenced).
208        $dependencies = [];
209        $declaredOrder = [];
210
211        foreach ($creates as $index => $create) {
212            $declaredOrder[$create->table] = $index;
213            $dependencies[$create->table] = [];
214
215            foreach ($create->blueprint->getForeignKeys() as $foreignKey) {
216                $referenced = $foreignKey['references'][0] ?? null;
217
218                if ($referenced === null || $referenced === $create->table) {
219                    continue; // external or self-reference — no edge.
220                }
221
222                if (isset($byTable[$referenced])) {
223                    $dependencies[$create->table][] = $referenced;
224                }
225            }
226        }
227
228        // Kahn's algorithm with declaration-order tie-breaking.
229        $ordered = [];
230        $remaining = $dependencies;
231
232        while ($remaining !== []) {
233            $ready = [];
234
235            foreach ($remaining as $table => $deps) {
236                if ($deps === []) {
237                    $ready[] = $table;
238                }
239            }
240
241            if ($ready === []) {
242                $cycle = implode(' → ', array_keys($remaining)) . ' → ' . (string) array_key_first($remaining);
243
244                throw new \LogicException(sprintf(
245                    'Circular foreign-key dependency among the desired tables: %s. No valid creation '
246                    . 'order exists. Drop one of the foreign keys (most "cycles" are a parent link plus '
247                    . 'a convenience back-reference that needs no constraint), or create the tables in '
248                    . 'two passes: create without the cyclic foreign key, then ALTER TABLE ADD CONSTRAINT '
249                    . 'afterwards.',
250                    $cycle,
251                ));
252            }
253
254            usort($ready, fn (string $a, string $b) => $declaredOrder[$a] <=> $declaredOrder[$b]);
255
256            foreach ($ready as $table) {
257                $ordered[] = $byTable[$table];
258                unset($remaining[$table]);
259
260                foreach ($remaining as &$deps) {
261                    $deps = array_values(array_diff($deps, [$table]));
262                }
263                unset($deps);
264            }
265        }
266
267        return $ordered;
268    }
269
270    /**
271     * Order drops in reverse dependency order — children before parents.
272     *
273     * @param  list<SchemaChange>  $drops
274     * @return list<SchemaChange>
275     */
276    private function orderDropsByDependencies(array $drops): array
277    {
278        if (count($drops) < 2) {
279            return $drops;
280        }
281
282        $dropSet = [];
283
284        foreach ($drops as $drop) {
285            $dropSet[$drop->table] = true;
286        }
287
288        // A drop of a REFERENCED table must wait for the drops of the
289        // tables that reference it (its children go first). The edge
290        // points parent → child: the parent's drop depends on the
291        // child's drop.
292        $dependencies = [];
293
294        foreach ($drops as $drop) {
295            $dependencies[$drop->table] = [];
296        }
297
298        foreach ($drops as $drop) {
299            foreach ($this->inspector->table($drop->table)->foreignKeys as $foreignKey) {
300                $referenced = $foreignKey['referencesTable'];
301
302                if ($referenced !== $drop->table && isset($dependencies[$referenced])) {
303                    // $drop (child) references $referenced (parent): the
304                    // PARENT's drop waits for this child's drop.
305                    $dependencies[$referenced][] = $drop->table;
306                }
307            }
308        }
309
310        // Reverse topological order: emit a table only after every table
311        // that references it has been emitted. Kahn's on reversed edges.
312        $ordered = [];
313        $remaining = $dependencies;
314
315        while ($remaining !== []) {
316            $ready = [];
317
318            foreach ($remaining as $table => $deps) {
319                if ($deps === []) {
320                    $ready[] = $table;
321                }
322            }
323
324            if ($ready === []) {
325                // A cycle among dropped tables — impossible to resolve by
326                // ordering; emit in input order (the database will reject
327                // with a clear FK error, which is the honest outcome).
328                foreach (array_keys($remaining) as $table) {
329                    $ordered[] = $drops[array_search($table, array_column($drops, 'table'), true)];
330                }
331
332                break;
333            }
334
335            foreach ($ready as $table) {
336                $ordered[] = $drops[array_search($table, array_column($drops, 'table'), true)];
337                unset($remaining[$table]);
338
339                foreach ($remaining as &$deps) {
340                    $deps = array_values(array_diff($deps, [$table]));
341                }
342                unset($deps);
343            }
344        }
345
346        return $ordered;
347    }
348
349    /**
350     * Tie table-level create/drop pairs into rename advisories.
351     *
352     * A pair is flagged when the created table's columns overlap the
353     * dropped table's live columns. The tie never rewrites operations —
354     * both sides of a flagged pair carry {@see SchemaChange::$renameOf}.
355     *
356     * @param  list<SchemaChange>  $changes
357     * @param  list<SchemaChange>  $creates
358     * @param  list<SchemaChange>  $drops
359     * @return list<SchemaChange>
360     */
361    private function tieTableRenames(array $changes, array $creates, array $drops): array
362    {
363        if ($creates === [] || $drops === []) {
364            return $changes;
365        }
366
367        // Live column names per dropped table, for overlap scoring.
368        $dropColumns = [];
369        foreach ($drops as $drop) {
370            $dropColumns[$drop->table] = array_map(
371                fn (array $column) => $column['name'],
372                $this->inspector->table($drop->table)->columns,
373            );
374        }
375
376        // Best drop per create, by shared-column ratio.
377        $best = []; // create table => [drop table, overlap]
378
379        foreach ($creates as $create) {
380            $createNames = array_map(
381                fn (array $column) => $column['name'],
382                $create->blueprint->getColumns(),
383            );
384
385            foreach ($drops as $drop) {
386                $shared = count(array_intersect($createNames, $dropColumns[$drop->table]));
387                $overlap = count($createNames) === 0 ? 0.0 : $shared / count($createNames);
388
389                if ($overlap < 0.5) {
390                    continue; // below threshold — unrelated, do not flag.
391                }
392
393                if (!isset($best[$create->table]) || $overlap > $best[$create->table][1]) {
394                    $best[$create->table] = [$drop->table, $overlap];
395                }
396            }
397        }
398
399        if ($best === []) {
400            return $changes;
401        }
402
403        // Rebuild each change that participates in a pair, with the link.
404        $linked = [];
405
406        foreach ($best as $createTable => [$dropTable, $overlap]) {
407            $linked[$createTable] = $dropTable;
408            $linked[$dropTable] = $createTable;
409        }
410
411        foreach ($changes as $index => $change) {
412            if (!isset($linked[$change->table])) {
413                continue;
414            }
415
416            $other = $linked[$change->table];
417            $label = $change->operation === SchemaOperation::DropTable
418                ? sprintf(
419                    '%s — POSSIBLE RENAME of [%s]: columns shared. If intended, '
420                    . 'copy the data between the steps and author the rename '
421                    . '(ALTER TABLE ... RENAME TO ...) in host code.',
422                    $change->description,
423                    $other,
424                )
425                : sprintf(
426                    '%s — POSSIBLE RENAME of [%s] (%d%% column overlap).',
427                    $change->description,
428                    $other,
429                    (int) round($best[$change->table][1] * 100),
430                );
431
432            $changes[$index] = new SchemaChange(
433                $change->table,
434                $change->operation,
435                $change->blueprint,
436                $change->destructive,
437                $label,
438                $change->possibleRename,
439                $other,
440            );
441        }
442
443        return $changes;
444    }
445
446    /**
447     * Diff one table's desired state against its live columns.
448     *
449     * @param  string  $table
450     * @param  Blueprint  $blueprint
451     * @param  string|null  $liveTable  The live table to read, when it differs from the target (a declared rename).
452     * @return list<SchemaChange>
453     */
454    private function diffTable(string $table, Blueprint $blueprint, string|null $liveTable = null): array
455    {
456        $live = $this->inspector->table($liveTable ?? $table);
457        $liveColumns = [];
458
459        foreach ($live->columns as $column) {
460            $liveColumns[$column['name']] = $column;
461        }
462
463        // A declared rename SATISFIES the desired `to` column (it arrives
464        // via the rename, not an add) and RETIRES the live `from` column
465        // (it leaves via the rename, not a drop) — the add/drop diff must
466        // not double-count either side. The desired `to` shape is kept
467        // for the MODIFY comparison: a rename + shape change sequences
468        // RenameColumn then ModifyColumn.
469        ['columns' => $desiredColumns, 'renames' => $renames, 'renamedDesired' => $renamedDesired] = $this->columnSets($blueprint, $liveColumns);
470
471        $additions = [];
472        $drops = [];
473        $modifications = [];
474
475        // The drift DETAIL rides the detection pass: the facets each
476        // modified column drifts by are rendered here, while the
477        // live/desired pair is in hand — modifyChange() never re-runs
478        // the tests. Keys are the modification names (the `to` side for
479        // renamed columns); values are the rendered facet strings.
480        // Destructiveness is classified in the same pass: nullability
481        // tightening and cast risk are exactly the arms driftDetail()
482        // and castSafety() already know about.
483        $details = [];
484        $destructive = false;
485
486        foreach ($desiredColumns as $name => $column) {
487            if (!isset($liveColumns[$name])) {
488                $additions[] = $name;
489                continue;
490            }
491
492            // Content drift: the column exists on both sides — compare the
493            // facets. Type via the dialect's round-trip mapping; nullability
494            // and default directly. An enum column's inline CHECK is part
495            // of its definition — a values change is content drift.
496            $detail = $this->driftDetail($liveColumns[$name], $column);
497
498            if (!$this->enumCheckMatches($table, $column, $live)) {
499                $detail[] = sprintf('enum values changed: %s', $this->enumValuesDetail($column));
500            }
501
502            if ($detail !== []) {
503                $modifications[] = $name;
504                $details[$name] = $detail;
505            }
506
507            if ($column['nullable'] === false && $liveColumns[$name]['nullable'] === true) {
508                $destructive = true; // nullability tightened.
509            }
510
511            // A type change must be a cast the dialect can perform: fail
512            // fast on an impossible one, flag a data-dependent one.
513            $safety = $this->inspector->castSafety($liveColumns[$name]['type'], $column['type']);
514
515            if ($safety === CastSafety::Uncastable) {
516                throw new \LogicException(sprintf(
517                    'Cannot modify [%s].[%s]: the live type [%s] cannot be cast to [%s].',
518                    $table,
519                    $name,
520                    $liveColumns[$name]['type'],
521                    $column['type']->value,
522                ));
523            }
524
525            if ($safety === CastSafety::Risky) {
526                $destructive = true; // the cast may lose data or fail on some values.
527            }
528        }
529
530        foreach ($liveColumns as $name => $liveColumn) {
531            if (isset($renames[$name])) {
532                continue; // a declared rename — not a drop.
533            }
534
535            if (!isset($desiredColumns[$name])) {
536                $drops[] = $name;
537            }
538        }
539
540        // Renamed columns: the desired `to` shape is compared against the
541        // live `from` shape — a rename + shape change sequences
542        // RenameColumn (first) then ModifyColumn. Same single-pass detail
543        // rendering as above (no enum arm here: the rename carries the
544        // desired shape, and its CHECK is handled by the plain-name pass
545        // when the desired column is not also renamed away).
546        foreach ($renamedDesired as $to => $column) {
547            $from = array_search($to, $renames, true);
548
549            if ($from === false || !isset($liveColumns[$from])) {
550                continue;
551            }
552
553            $detail = $this->driftDetail($liveColumns[$from], $column);
554
555            if (!$this->enumCheckMatches($table, $column, $live)) {
556                $detail[] = sprintf('enum values changed: %s', $this->enumValuesDetail($column));
557            }
558
559            if ($detail !== []) {
560                $modifications[] = $to;
561                $details[$to] = $detail;
562            }
563
564            if ($column['nullable'] === false && $liveColumns[$from]['nullable'] === true) {
565                $destructive = true; // nullability tightened.
566            }
567
568            $safety = $this->inspector->castSafety($liveColumns[$from]['type'], $column['type']);
569
570            if ($safety === CastSafety::Uncastable) {
571                throw new \LogicException(sprintf(
572                    'Cannot modify [%s].[%s]: the live type [%s] cannot be cast to [%s].',
573                    $table,
574                    $to,
575                    $liveColumns[$from]['type'],
576                    $column['type']->value,
577                ));
578            }
579
580            if ($safety === CastSafety::Risky) {
581                $destructive = true; // the cast may lose data or fail on some values.
582            }
583        }
584
585        $changes = [];
586
587        // The rename change FIRST — subsequent alters target the new name.
588        if ($renames !== []) {
589            $changes[] = $this->renameChange($table, $renames);
590        }
591
592        // Adds and drops are SEPARATE changes — a merged add+drop alter
593        // would dispatch only the dominant side and silently lose the
594        // other. The add applies first (a later modify may reference a
595        // just-added column); the drop follows. Each change carries the
596        // FULL desired blueprint plus the NAMES of the columns it acts on
597        // — the dialects filter the blueprint by those names.
598        if ($additions !== []) {
599            $changes[] = $this->addChange($table, $blueprint, $additions, $drops);
600        }
601
602        if ($drops !== []) {
603            $changes[] = $this->dropChange($table, $blueprint, $additions, $drops);
604        }
605
606        if ($modifications !== []) {
607            $changes[] = $this->modifyChange($table, $blueprint, $modifications, $details, $destructive);
608        }
609
610        return $changes;
611    }
612
613    /**
614     * Build the desired column set, the verified renames, and the renamed desired shapes.
615     *
616     * @param  Blueprint  $blueprint
617     * @param  array<string, array<string, mixed>>  $liveColumns
618     * @return array{columns: array<string, array<string, mixed>>, renames: array<string, string>, renamedDesired: array<string, array<string, mixed>>}
619     */
620    private function columnSets(Blueprint $blueprint, array $liveColumns): array
621    {
622        $desiredColumns = [];
623
624        foreach ($blueprint->getColumns() as $column) {
625            // An explicit dropColumn() removes the column from the desired
626            // set — a hand-written ALTER delta says "this column goes", so
627            // the live column of that name must diff as a drop, not match
628            // the still-present metadata declaration.
629            $desiredColumns[$column['name']] = $column;
630        }
631
632        foreach ($blueprint->getDropColumns() as $name) {
633            unset($desiredColumns[$name]);
634        }
635
636        // Declared renames: the live `from` column becomes the `to` column.
637        // The rename is verified against the live schema (old exists, new
638        // absent); a declaration that does not match reality is IGNORED for
639        // the diff (the columns diff as they are — the host sees the real
640        // shape, never a wrong rename).
641        $renames = [];
642
643        foreach ($blueprint->getColumnRenames() as $rename) {
644            if (isset($liveColumns[$rename['from']]) && !isset($liveColumns[$rename['to']])) {
645                $renames[$rename['from']] = $rename['to'];
646            }
647        }
648
649        $renamedDesired = [];
650
651        foreach ($renames as $from => $to) {
652            if (isset($desiredColumns[$to])) {
653                $renamedDesired[$to] = $desiredColumns[$to];
654            }
655
656            unset($desiredColumns[$to]);
657        }
658
659        return ['columns' => $desiredColumns, 'renames' => $renames, 'renamedDesired' => $renamedDesired];
660    }
661
662    /**
663     * The human-readable facets a live column drifts from the declared
664     * shape — the same tests the detection pass classifies, rendered:
665     * type (`live -> declared native`), nullability, default, enum values.
666     *
667     * @param  array<string, mixed>  $liveColumn
668     * @param  array<string, mixed>  $column
669     * @return list<string>
670     */
671    private function driftDetail(array $liveColumn, array $column): array
672    {
673        $detail = [];
674
675        $typeMatches = $this->inspector->columnTypeMatches(
676            $liveColumn['type'],
677            $column['type'],
678            $column['length'],
679            $column['precision'],
680            $column['scale'] ?? null,
681        );
682
683        if (!$typeMatches) {
684            $detail[] = sprintf(
685                '%s -> %s',
686                (string) $liveColumn['type'],
687                $this->inspector->schemaGrammar->type(
688                    $column['type'],
689                    $column['length'],
690                    $column['precision'],
691                    $column['scale'] ?? null,
692                ),
693            );
694        }
695
696        if ($liveColumn['nullable'] !== $column['nullable']) {
697            $detail[] = $column['nullable'] ? 'not null -> nullable' : 'nullable -> not null';
698        }
699
700        if (!$this->defaultsMatch($liveColumn['default'], $column['default'])) {
701            $detail[] = sprintf(
702                'default changed: %s -> %s',
703                $liveColumn['default'] === null || $liveColumn['default'] === false
704                    ? 'NULL'
705                    : (string) $liveColumn['default'],
706                $this->renderDefault($column['default']),
707            );
708        }
709
710        return $detail;
711    }
712
713    /**
714     * Render a declared column default for a description — a plain
715     * scalar as-is, `NULL` for null, everything else through var_export.
716     *
717     * @param  mixed  $default
718     * @return string
719     */
720    private function renderDefault(mixed $default): string
721    {
722        if ($default === null) {
723            return 'NULL';
724        }
725
726        if (is_bool($default)) {
727            return $default ? 'true' : 'false';
728        }
729
730        if (is_scalar($default)) {
731            return (string) $default;
732        }
733
734        return var_export($default, true);
735    }
736
737    /**
738     * Build the RenameColumn change for a table's verified renames.
739     *
740     * @param  string  $table
741     * @param  array<string, string>  $renames
742     * @return SchemaChange
743     */
744    private function renameChange(string $table, array $renames): SchemaChange
745    {
746        $renameBlueprint = new Blueprint($table);
747
748        foreach ($renames as $from => $to) {
749            $renameBlueprint = $renameBlueprint->renameColumn($from, $to);
750        }
751
752        return new SchemaChange(
753            $table,
754            SchemaOperation::RenameColumn,
755            $renameBlueprint,
756            false,
757            sprintf(
758                'rename column(s) on [%s]: [%s] — data travels with the rename',
759                $table,
760                implode(', ', array_map(fn (string $from) => "[{$from}] -> [{$renames[$from]}]", array_keys($renames))),
761            ),
762        );
763    }
764
765    /**
766     * Build the AddColumn change for a table's column additions.
767     *
768     * @param  string  $table
769     * @param  Blueprint  $blueprint  The full desired blueprint.
770     * @param  list<string>  $additions
771     * @param  list<string>  $drops  The drop side, for the rename advisory.
772     * @return SchemaChange
773     */
774    private function addChange(string $table, Blueprint $blueprint, array $additions, array $drops): SchemaChange
775    {
776        $description = sprintf(
777            'alter table [%s]: add column(s) [%s]',
778            $table,
779            implode(', ', $additions),
780        );
781
782        // A mixed add+drop is the rename SHAPE — flagged on BOTH halves so
783        // the host asks, never guessed.
784        $possibleRename = $drops !== [];
785
786        if ($possibleRename) {
787            $description .= sprintf(
788                ' — POSSIBLE RENAME: [%s] -> [%s]? If intended, declare it with'
789                . ' Blueprint::renameColumn() and re-diff; applying as-is destroys the dropped data.',
790                implode(', ', $drops),
791                implode(', ', $additions),
792            );
793        }
794
795        return new SchemaChange($table, SchemaOperation::AddColumn, $blueprint, false, $description, $possibleRename, null, $additions);
796    }
797
798    /**
799     * Build the DropColumn change for a table's column drops.
800     *
801     * @param  string  $table
802     * @param  Blueprint  $blueprint  The full desired blueprint.
803     * @param  list<string>  $additions  The add side, for the rename advisory.
804     * @param  list<string>  $drops
805     * @return SchemaChange
806     */
807    private function dropChange(string $table, Blueprint $blueprint, array $additions, array $drops): SchemaChange
808    {
809        $description = sprintf(
810            'alter table [%s]: drop column(s) [%s] — DESTRUCTIVE: data loss',
811            $table,
812            implode(', ', $drops),
813        );
814
815        $possibleRename = $additions !== [];
816
817        if ($possibleRename) {
818            $description .= sprintf(
819                ' — POSSIBLE RENAME: [%s] -> [%s]? If intended, declare it with'
820                . ' Blueprint::renameColumn() and re-diff; applying as-is destroys the dropped data.',
821                implode(', ', $drops),
822                implode(', ', $additions),
823            );
824        }
825
826        return new SchemaChange($table, SchemaOperation::DropColumn, $blueprint, true, $description, $possibleRename, null, $drops);
827    }
828
829    /**
830     * Build the ModifyColumn change from the detection pass's findings.
831     *
832     * @param  string  $table
833     * @param  Blueprint  $blueprint  The full desired blueprint carried on the change.
834     * @param  list<string>  $modifications  The drifted column names (the subject).
835     * @param  array<string, list<string>>  $details  The rendered drift facets per modified column,
836     *        computed in the detection pass ({@see diffTable}) — keys match $modifications.
837     * @param  bool  $destructive  Classified in the detection pass: nullability tightened or a
838     *        data-dependent cast.
839     * @return SchemaChange
840     */
841    private function modifyChange(string $table, Blueprint $blueprint, array $modifications, array $details, bool $destructive): SchemaChange
842    {
843        $detailed = [];
844
845        foreach ($modifications as $name) {
846            $detail = $details[$name] ?? [];
847
848            $detailed[] = $detail === [] ? $name : sprintf('%s (%s)', $name, implode(', ', $detail));
849        }
850
851        return new SchemaChange(
852            $table,
853            SchemaOperation::ModifyColumn,
854            $blueprint,
855            $destructive,
856            sprintf(
857                'modify column(s) on [%s]: [%s]%s',
858                $table,
859                implode(', ', $detailed),
860                $destructive ? ' — DESTRUCTIVE: existing rows may violate the new shape' : '',
861            ),
862            false,
863            null,
864            $modifications,
865        );
866    }
867
868    /**
869     * Compare a live column default against the declared one.
870     *
871     * @param  mixed  $liveDefault
872     * @param  mixed  $declaredDefault
873     * @return bool
874     */
875    private function defaultsMatch(mixed $liveDefault, mixed $declaredDefault): bool
876    {
877        if ($liveDefault === null || $liveDefault === false) {
878            return $declaredDefault === null;
879        }
880
881        if ($declaredDefault instanceof \BlueprintAU\Radiant\Database\Query\Expression) {
882            return (string) $liveDefault === $declaredDefault->value;
883        }
884
885        // Inspectors pass the default through as text, and every dialect
886        // reports a string default as its quoted SQL literal (`''`, `'x'` —
887        // Postgres appends a `::type` cast). Compare the unquoted literal,
888        // so a converged column does not re-plan as a ModifyColumn forever.
889        if (is_string($liveDefault)) {
890            return $this->unquoteLiteral($liveDefault) == $declaredDefault;
891        }
892
893        return $liveDefault == $declaredDefault;
894    }
895
896    /**
897     * Strip the SQL literal quoting a dialect wraps a string default in.
898     *
899     * @param  string  $value
900     * @return string
901     */
902    private function unquoteLiteral(string $value): string
903    {
904        $quote = $value[0] ?? '';
905
906        if ($quote !== "'" && $quote !== '"') {
907            return $value;
908        }
909
910        // Find the literal's closing quote (a doubled quote is an escape),
911        // so a `::` INSIDE the literal is never mistaken for a cast.
912        $length = strlen($value);
913        $end = null;
914
915        for ($i = 1; $i < $length; $i++) {
916            if ($value[$i] !== $quote) {
917                continue;
918            }
919
920            if (($i + 1) < $length && $value[$i + 1] === $quote) {
921                $i++; // escaped quote — keep scanning.
922                continue;
923            }
924
925            $end = $i;
926            break;
927        }
928
929        if ($end === null) {
930            return $value; // unbalanced — never guess.
931        }
932
933        $rest = substr($value, $end + 1);
934
935        // A Postgres type cast (`'x'::character varying`) rides AFTER the
936        // literal; anything else means this is not a plain literal.
937        if ($rest !== '' && !str_starts_with($rest, '::')) {
938            return $value;
939        }
940
941        return str_replace($quote . $quote, $quote, substr($value, 1, $end - 1));
942    }
943
944    /**
945     * Diff one table's declared foreign keys against the live ones —
946     * shape-first matching.
947     *
948     * @param  string  $table
949     * @param  Blueprint  $blueprint
950     * @param  string|null  $liveTable  The live table to read, when it differs from the target (a declared rename).
951     * @return list<SchemaChange>
952     */
953    private function diffForeignKeys(string $table, Blueprint $blueprint, string|null $liveTable = null): array
954    {
955        $live = $this->inspector->table($liveTable ?? $table);
956
957        $liveForeignKeys = $live->foreignKeys;
958        $matched = [];
959
960        $changes = [];
961        $adds = [];
962        $drops = [];
963
964        foreach ($blueprint->getForeignKeys() as $desiredFk) {
965            $matchedShape = null;
966
967            foreach ($liveForeignKeys as $index => $liveFk) {
968                if ($this->foreignKeyShapesMatch($desiredFk, $liveFk)) {
969                    $matchedShape = $index;
970                    break;
971                }
972            }
973
974            if ($matchedShape !== null) {
975                $matched[] = $matchedShape;
976                continue; // in sync — regardless of name.
977            }
978
979            $adds[] = $desiredFk;
980        }
981
982        foreach ($liveForeignKeys as $index => $liveFk) {
983            if (in_array($index, $matched, true)) {
984                continue;
985            }
986
987            // A live FK the desired state does not declare is a drop —
988            // but ONLY when the desired state declares the table at all
989            // (the differ never drops constraints from tables it is not
990            // managing). The live constraint name is the drop handle.
991            $name = $liveFk['name'] ?? null;
992
993            if ($name === null) {
994                continue; // unnamed live constraint — cannot address it.
995            }
996
997            $changes[] = new SchemaChange(
998                $table,
999                SchemaOperation::DropForeignKey,
1000                $blueprint,
1001                false,
1002                sprintf(
1003                    'drop foreign key [%s] on [%s] — the constraint goes, the rows stay',
1004                    $name,
1005                    $table,
1006                ),
1007            );
1008        }
1009
1010        foreach ($adds as $desiredFk) {
1011            // The change carries the ORIGINAL desired blueprint: on SQLite
1012            // the FK add routes through the table rebuild (which renders
1013            // the whole desired schema); the in-place dialects read the
1014            // FK from the blueprint's first entry.
1015            $actions = array_filter([
1016                $desiredFk['onDelete'] === null ? null : "on delete {$desiredFk['onDelete']->value}",
1017                $desiredFk['onUpdate'] === null ? null : "on update {$desiredFk['onUpdate']->value}",
1018            ]);
1019
1020            $changes[] = new SchemaChange(
1021                $table,
1022                SchemaOperation::AddForeignKey,
1023                $blueprint,
1024                false,
1025                sprintf(
1026                    'add foreign key on [%s] ([%s] -> [%s] ([%s]))%s',
1027                    $table,
1028                    implode(', ', $desiredFk['columns']),
1029                    (string) $desiredFk['references'][0],
1030                    implode(', ', array_slice($desiredFk['references'], 1)),
1031                    $actions === [] ? '' : ' ' . implode(' ', $actions),
1032                ),
1033            );
1034        }
1035
1036        return $changes;
1037    }
1038
1039    /**
1040     * Whether a declared FK shape matches a live FK shape — the
1041     * shape-first identity test.
1042     *
1043     * @param  array{columns: list<string>, references: list<string>, onDelete: \BlueprintAU\Radiant\Database\Schema\Enums\ForeignKeyAction|null, onUpdate: \BlueprintAU\Radiant\Database\Schema\Enums\ForeignKeyAction|null, deferrable: bool, initiallyDeferred: bool}  $desiredFk
1044     * @param  array{columns: list<string>, referencesTable: string, referencesColumns: list<string>, onDelete: string|null, onUpdate: string|null, deferrable: bool, name?: string|null}  $liveFk
1045     * @return bool
1046     */
1047    private function foreignKeyShapesMatch(array $desiredFk, array $liveFk): bool
1048    {
1049        if ($desiredFk['columns'] !== $liveFk['columns']) {
1050            return false;
1051        }
1052
1053        $referencedTable = $desiredFk['references'][0] ?? null;
1054        $referencedColumns = array_slice($desiredFk['references'], 1);
1055
1056        if ($referencedTable !== $liveFk['referencesTable']) {
1057            return false;
1058        }
1059
1060        if ($referencedColumns !== $liveFk['referencesColumns']) {
1061            return false;
1062        }
1063
1064        // Actions: the declared enum value vs the live normalized text
1065        // (the inspector normalizes `NO ACTION` to null on both sides).
1066        $desiredDelete = $desiredFk['onDelete']?->value;
1067        $desiredUpdate = $desiredFk['onUpdate']?->value;
1068
1069        return $desiredDelete === $liveFk['onDelete'] && $desiredUpdate === $liveFk['onUpdate'];
1070    }
1071
1072    /**
1073     * Diff one table's declared CHECK constraints against the live ones.
1074     *
1075     * @param  string  $table
1076     * @param  Blueprint  $blueprint
1077     * @param  string|null  $liveTable  The live table to read, when it differs from the target (a declared rename).
1078     * @return list<SchemaChange>
1079     */
1080    private function diffChecks(string $table, Blueprint $blueprint, string|null $liveTable = null): array
1081    {
1082        $live = $this->inspector->table($liveTable ?? $table);
1083
1084        $liveChecks = [];
1085
1086        foreach ($live->checks as $check) {
1087            if ($check['name'] !== null) {
1088                $liveChecks[$check['name']] = $check;
1089            }
1090        }
1091
1092        $changes = [];
1093
1094        foreach ($blueprint->getChecks() as $desiredCheck) {
1095            // Every declared CHECK carries a FINAL name (derived at
1096            // declaration time when omitted — same rule as indexes), so
1097            // the diff is by name like every other constraint.
1098            $name = $desiredCheck['name'];
1099
1100            $liveCheck = $liveChecks[$name] ?? null;
1101
1102            if ($liveCheck === null) {
1103                // The change carries the ORIGINAL desired blueprint: on
1104                // SQLite the CHECK add routes through the table rebuild.
1105                $changes[] = new SchemaChange(
1106                    $table,
1107                    SchemaOperation::AddCheck,
1108                    $blueprint,
1109                    false,
1110                    sprintf('add check [%s] on [%s]', $name, $table),
1111                );
1112                continue;
1113            }
1114
1115            // Same name — compare the normalized expressions. A mismatch
1116            // is ADVISORY-ONLY: reported, never executed.
1117            if (!$this->checkExpressionsMatch($desiredCheck['expression'], $liveCheck['expression'])) {
1118                $changes[] = new SchemaChange(
1119                    $table,
1120                    SchemaOperation::AddCheck,
1121                    new Blueprint($table),
1122                    false,
1123                    sprintf(
1124                        'check [%s] on [%s] EXPRESSION DRIFT — declared [%s], live [%s]. '
1125                        . 'Advisory only: author the drop+add by hand if intended.',
1126                        $name,
1127                        $table,
1128                        $desiredCheck['expression'],
1129                        (string) $liveCheck['expression'],
1130                    ),
1131                );
1132            }
1133        }
1134
1135        // Live CHECKs the desired state no longer declares — report-only
1136        // advisory (the differ never drops a constraint it cannot verify
1137        // the expression of).
1138        foreach ($liveChecks as $name => $liveCheck) {
1139            $declared = false;
1140
1141            foreach ($blueprint->getChecks() as $desiredCheck) {
1142                if ($desiredCheck['name'] === $name) {
1143                    $declared = true;
1144                    break;
1145                }
1146            }
1147
1148            if (!$declared) {
1149                $changes[] = new SchemaChange(
1150                    $table,
1151                    SchemaOperation::DropCheck,
1152                    new Blueprint($table),
1153                    false,
1154                    sprintf(
1155                        'live check [%s] on [%s] is NOT declared — advisory only: author the drop by hand if intended.',
1156                        $name,
1157                        $table,
1158                    ),
1159                );
1160            }
1161        }
1162
1163        return $changes;
1164    }
1165
1166    /**
1167     * Whether two CHECK expressions match after conservative
1168     * normalization — whitespace collapsed, dialect quoting stripped.
1169     *
1170     * @param  string  $declared
1171     * @param  string|null  $live
1172     * @return bool
1173     */
1174    private function checkExpressionsMatch(string $declared, string|null $live): bool
1175    {
1176        if ($live === null) {
1177            return false; // unparseable live text — never assume a match.
1178        }
1179
1180        $normalize = fn (string $expression): string => preg_replace('/\s+/', ' ', trim(str_replace(['"', '`', "'"], '', $expression))) ?? $expression;
1181
1182        return $normalize($declared) === $normalize($live);
1183    }
1184
1185    /**
1186     * Whether an enum column's inline CHECK matches the live schema —
1187     * the values-drift comparison. A non-enum column always matches
1188     * (no CHECK is part of its definition).
1189     *
1190     * @param  string  $table
1191     * @param  array<string, mixed>  $column  The desired ColumnShape.
1192     * @param  LiveTable  $live
1193     * @return bool
1194     */
1195    private function enumCheckMatches(string $table, array $column, LiveTable $live): bool
1196    {
1197        if ($column['type'] !== ColumnType::Enum) {
1198            return true;
1199        }
1200
1201        $values = $column['values'] ?? null;
1202
1203        if ($values === null || $values === []) {
1204            return true; // nothing declared — nothing to compare.
1205        }
1206
1207        // Render the desired CHECK exactly as the grammar does, then
1208        // compare against every live CHECK on the table (the inline CHECK
1209        // is unnamed on some dialects, so match by expression). The
1210        // comparison normalizes quoting away, so the dialect's wrap
1211        // character does not matter — plain double quotes suffice.
1212        $desired = 'CHECK ("' . $column['name'] . '" IN ('
1213            . implode(', ', array_map(fn (string $value) => "'" . str_replace("'", "''", $value) . "'", $values)) . '))';
1214
1215        foreach ($live->checks as $check) {
1216            if ($this->checkExpressionsMatch($desired, $check['expression'])) {
1217                return true;
1218            }
1219        }
1220
1221        return false;
1222    }
1223
1224    /**
1225     * Render a drifted enum column's declared values for a description —
1226     * the quoted list the grammar's CHECK carries.
1227     *
1228     * @param  array<string, mixed>  $column  The desired ColumnShape.
1229     * @return string
1230     */
1231    private function enumValuesDetail(array $column): string
1232    {
1233        $values = $column['values'] ?? [];
1234
1235        return '[' . implode(', ', array_map(
1236            fn (string $value) => "'" . str_replace("'", "''", $value) . "'",
1237            $values,
1238        )) . ']';
1239    }
1240
1241    /**
1242     * Diff one table's declared indexes against the live ones — option
1243     * drift only.
1244     *
1245     * @param  string  $table
1246     * @param  Blueprint  $blueprint
1247     * @param  string|null  $liveTable  The live table to read, when it differs from the target (a declared rename).
1248     * @return SchemaChange|null
1249     */
1250    private function diffIndexes(string $table, Blueprint $blueprint, string|null $liveTable = null): ?SchemaChange
1251    {
1252        $live = $this->inspector->table($liveTable ?? $table);
1253
1254        // Live indexes by name (named ones only — unnamed ride the
1255        // columns' unique flag).
1256        $liveIndexes = [];
1257
1258        foreach ($live->indexes as $index) {
1259            if ($index['name'] !== null) {
1260                $liveIndexes[$index['name']] = $index;
1261            }
1262        }
1263
1264        $rebuild = new Blueprint($table);
1265        $detailed = [];
1266
1267        foreach ($blueprint->getIndexes() as $index) {
1268            $name = $index['name'];
1269            $liveIndex = $liveIndexes[$name] ?? null;
1270
1271            if ($liveIndex === null) {
1272                continue; // absent live = deployment gap, not option drift.
1273            }
1274
1275            $whereMatches = ($index['where'] ?? null) === ($liveIndex['where'] ?? null);
1276            $nullsMatch = $index['nullsNotDistinct'] === $liveIndex['nullsNotDistinct'];
1277
1278            if ($whereMatches && $nullsMatch) {
1279                continue;
1280            }
1281
1282            $rebuild = $rebuild->index($name, $index['columns'], unique: $index['unique'], where: $index['where'], nullsNotDistinct: $index['nullsNotDistinct']);
1283
1284            // WHICH option drifted and from what to what — rendered while
1285            // the live/desired pair is in hand.
1286            $options = [];
1287
1288            if (!$whereMatches) {
1289                $options[] = sprintf(
1290                    'where: %s -> %s',
1291                    $liveIndex['where'] ?? 'none',
1292                    $index['where'] ?? 'none',
1293                );
1294            }
1295
1296            if (!$nullsMatch) {
1297                $options[] = sprintf(
1298                    'nulls not distinct: %s -> %s',
1299                    $liveIndex['nullsNotDistinct'] ? 'on' : 'off',
1300                    $index['nullsNotDistinct'] ? 'on' : 'off',
1301                );
1302            }
1303
1304            $detailed[] = sprintf('%s (%s)', $name, implode('; ', $options));
1305        }
1306
1307        if ($detailed === []) {
1308            return null;
1309        }
1310
1311        $description = sprintf(
1312            'alter indexes on [%s]: rebuild [%s] — index options drifted (partial predicate / NULLS NOT DISTINCT)',
1313            $table,
1314            implode(', ', $detailed),
1315        );
1316
1317        return new SchemaChange($table, SchemaOperation::AlterIndexes, $rebuild, false, $description);
1318    }
1319}