Lines 100.00% 53 / 53
Methods 100.00% 6 / 6
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __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
15final 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}