Lines
100.00%
53 / 53
Functions and Methods
100.00%
6 / 6
Classes and Traits
100.00%
1 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| SchemaSynchronizer | 100.00% | 53 / 53 | 100.00% | 6 / 6 | 18 | 100.00% | 1 / 1 | |||
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| plan | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| apply | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | |||||
| sync | 100.00% | 19 / 19 | 100.00% | 1 / 1 | 3 | |||||
| assertTransactionalSupport | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 3 | |||||
| applyChanges | 100.00% | 23 / 23 | 100.00% | 1 / 1 | 9 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace BlueprintAU\Radiant\Database\Schema; | |
| 6 | ||
| 7 | use BlueprintAU\Radiant\Database\Connections\SqlConnection; | |
| 8 | ||
| 9 | /** | |
| 10 | * The sync runner — diff → confirm → apply, under the schema lock. | |
| 11 | * | |
| 12 | * @see SchemaDiffer | |
| 13 | * @see SqlConnection::apply() | |
| 14 | */ | |
| 15 | final class SchemaSynchronizer | |
| 16 | { | |
| 17 | /** | |
| 18 | * Create a synchronizer over a SQL connection. | |
| 19 | * | |
| 20 | * @param SqlConnection $connection | |
| 21 | */ | |
| 22 | public function __construct( | |
| 23 | private readonly SqlConnection $connection, | |
| 24 | ) { | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Compute the changes needed to reach the desired state, touching | |
| 29 | * nothing. | |
| 30 | * | |
| 31 | * Takes no lock: the caller must hold the `radiant:schema` lock across | |
| 32 | * the whole plan → display → apply flow to guarantee the shown plan is | |
| 33 | * exactly what gets applied. | |
| 34 | * | |
| 35 | * @param list<Blueprint> $desired | |
| 36 | * @param list<string> $protected Tables that must never be dropped or offered as a rename target. | |
| 37 | * @param bool $dropTables Whether undeclared live tables are emitted as DropTable changes. | |
| 38 | * @return list<SchemaChange> | |
| 39 | * @throws \LogicException | |
| 40 | */ | |
| 41 | public function plan(array $desired, array $protected = [], bool $dropTables = true): array | |
| 42 | { | |
| 43 | return (new SchemaDiffer($this->connection->schemaInspector))->diff($desired, $protected, $dropTables); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Apply pre-computed changes — no re-diff. | |
| 48 | * | |
| 49 | * Takes no lock: the caller must hold the `radiant:schema` lock across | |
| 50 | * the whole plan → display → apply flow. | |
| 51 | * | |
| 52 | * When the plan carries a change whose apply needs a transaction-free | |
| 53 | * connection (an FK-involved SQLite table rebuild), a `transactional: | |
| 54 | * true` apply degrades to a non-transactional loop — each such change | |
| 55 | * keeps its own internal atomicity. Run the apply OUTSIDE a held lock | |
| 56 | * transaction in that case: release the lock between planning and | |
| 57 | * applying. | |
| 58 | * | |
| 59 | * @param list<SchemaChange> $plan | |
| 60 | * @param (callable(SchemaChange): bool)|null $confirm The destructive-change gate; null means fail-fast. | |
| 61 | * @param (callable(SchemaChange): void)|null $onChange Invoked after each change is applied successfully. | |
| 62 | * @param bool $transactional Whether the apply loop is atomic. | |
| 63 | * @return list<SchemaChange> The changes actually applied — declined changes are excluded. | |
| 64 | * @throws \LogicException | |
| 65 | * @throws \Throwable | |
| 66 | */ | |
| 67 | public function apply( | |
| 68 | array $plan, | |
| 69 | callable|null $confirm = null, | |
| 70 | callable|null $onChange = null, | |
| 71 | bool $transactional = false, | |
| 72 | ): array { | |
| 73 | $this->assertTransactionalSupport($transactional); | |
| 74 | ||
| 75 | return $this->applyChanges($plan, $confirm, $onChange, $transactional); | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * Sync the desired state to the live schema. | |
| 80 | * | |
| 81 | * Plans and applies under the `radiant:schema` lock — one | |
| 82 | * cross-process section. When the plan carries a change whose apply | |
| 83 | * needs a transaction-free connection (an FK-involved SQLite table | |
| 84 | * rebuild), the apply runs after the lock transaction closes: the | |
| 85 | * lock is itself a transaction on SQLite, and the change cannot run | |
| 86 | * inside any transaction. | |
| 87 | * | |
| 88 | * @param list<Blueprint> $desired | |
| 89 | * @param (callable(SchemaChange): bool)|null $confirm The destructive-change gate; null means fail-fast. | |
| 90 | * @param (callable(SchemaChange): void)|null $onChange Invoked after each change is applied successfully. | |
| 91 | * @param bool $transactional Whether the apply loop is atomic. | |
| 92 | * @return list<SchemaChange> | |
| 93 | * @throws \LogicException | |
| 94 | * @throws \Throwable | |
| 95 | */ | |
| 96 | public function sync( | |
| 97 | array $desired, | |
| 98 | callable|null $confirm = null, | |
| 99 | callable|null $onChange = null, | |
| 100 | bool $transactional = false, | |
| 101 | ): array { | |
| 102 | $this->assertTransactionalSupport($transactional); | |
| 103 | ||
| 104 | ['applied' => $applied, 'deferred' => $deferred] = $this->connection->withLock( | |
| 105 | function () use ($desired, $confirm, $onChange, $transactional): array { | |
| 106 | $changes = $this->plan($desired); | |
| 107 | ||
| 108 | // A change the dialect cannot apply inside a transaction | |
| 109 | // (an FK-involved SQLite rebuild needs the foreign_keys | |
| 110 | // PRAGMA toggle outside one) defers the apply past the | |
| 111 | // lock transaction — the lock IS a transaction on SQLite. | |
| 112 | // The race window is fenced by the rebuild itself: it | |
| 113 | // re-reads the live table per change and its | |
| 114 | // foreign_key_check gate fails loud on drift it cannot | |
| 115 | // reconcile. | |
| 116 | if (array_any( | |
| 117 | $changes, | |
| 118 | fn (SchemaChange $change): bool => $this->connection->changeRequiresStandaloneTransaction($change, $changes), | |
| 119 | )) { | |
| 120 | return ['applied' => [], 'deferred' => $changes]; | |
| 121 | } | |
| 122 | ||
| 123 | return [ | |
| 124 | 'applied' => $this->applyChanges($changes, $confirm, $onChange, $transactional), | |
| 125 | 'deferred' => null, | |
| 126 | ]; | |
| 127 | }, | |
| 128 | 'radiant:schema', | |
| 129 | ); | |
| 130 | ||
| 131 | if ($deferred !== null) { | |
| 132 | return $this->applyChanges($deferred, $confirm, $onChange, $transactional); | |
| 133 | } | |
| 134 | ||
| 135 | return $applied; | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Refuse a transactional apply on a dialect without transactional DDL. | |
| 140 | * | |
| 141 | * @param bool $transactional | |
| 142 | * @throws \LogicException | |
| 143 | */ | |
| 144 | private function assertTransactionalSupport(bool $transactional): void | |
| 145 | { | |
| 146 | if ($transactional && !$this->connection->supportsTransactionalDdl()) { | |
| 147 | throw new \LogicException(sprintf( | |
| 148 | 'The [%s] dialect does not support transactional DDL (every DDL statement performs an ' | |
| 149 | . 'implicit commit); a transactional apply would silently commit changes one by one ' | |
| 150 | . 'while appearing atomic. Run the apply loop without the transactional option.', | |
| 151 | $this->connection::class, | |
| 152 | )); | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Apply the changes in order, gated by the confirm callback. | |
| 158 | * | |
| 159 | * @param list<SchemaChange> $changes | |
| 160 | * @param (callable(SchemaChange): bool)|null $confirm | |
| 161 | * @param (callable(SchemaChange): void)|null $onChange | |
| 162 | * @param bool $transactional | |
| 163 | * @return list<SchemaChange> | |
| 164 | * @throws \LogicException | |
| 165 | * @throws \Throwable | |
| 166 | */ | |
| 167 | private function applyChanges( | |
| 168 | array $changes, | |
| 169 | callable|null $confirm, | |
| 170 | callable|null $onChange, | |
| 171 | bool $transactional, | |
| 172 | ): array { | |
| 173 | $applied = []; | |
| 174 | ||
| 175 | $apply = function () use ($changes, $confirm, $onChange, &$applied): void { | |
| 176 | foreach ($changes as $change) { | |
| 177 | if ($change->destructive) { | |
| 178 | if ($confirm === null) { | |
| 179 | throw new \LogicException(sprintf( | |
| 180 | 'Refusing to apply the destructive change [%s] without confirmation: %s', | |
| 181 | $change->operation->value, | |
| 182 | $change->description, | |
| 183 | )); | |
| 184 | } | |
| 185 | ||
| 186 | if (!$confirm($change)) { | |
| 187 | continue; // declined — skipped, not applied. | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | $this->connection->apply($change); | |
| 192 | ||
| 193 | if ($onChange !== null) { | |
| 194 | $onChange($change); | |
| 195 | } | |
| 196 | ||
| 197 | $applied[] = $change; | |
| 198 | } | |
| 199 | }; | |
| 200 | ||
| 201 | // A change the dialect cannot apply inside a transaction (a SQLite | |
| 202 | // table rebuild needs the foreign_keys PRAGMA toggle outside one) | |
| 203 | // skips the wrapper: each such change stays internally atomic on | |
| 204 | // its own. Checking once up front keeps the loop's per-change | |
| 205 | // atomicity boundary uniform for the whole plan. The deferred leg | |
| 206 | // of sync() re-derives the verdict here — the plan-aware predicate | |
| 207 | // answers rename-led batches on its own. | |
| 208 | $degrade = $transactional | |
| 209 | && array_any($changes, fn (SchemaChange $change): bool => $this->connection->changeRequiresStandaloneTransaction($change, $changes)); | |
| 210 | ||
| 211 | if ($transactional && !$degrade) { | |
| 212 | // The connection's transaction() helper: commit on success, | |
| 213 | // roll back on any exception (a failed rollback never | |
| 214 | // replaces the original exception). | |
| 215 | $this->connection->transaction($apply); | |
| 216 | } else { | |
| 217 | $apply(); | |
| 218 | } | |
| 219 | ||
| 220 | return $applied; | |
| 221 | } | |
| 222 | } |