Lines
94.16%
565 / 600
Methods
59.09%
13 / 22
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __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 | ||
| 19 | final 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 | } |