Lines 97.12% 135 / 139
Functions and Methods 87.50% 21 / 24
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
SqliteConnection 97.12% 135 / 139 87.50% 21 / 24 58 0.00% 0 / 1
 getDefaultQueryGrammar 100.00% 1 / 1 100.00% 1 / 1 1
 getDefaultSchemaGrammar 100.00% 1 / 1 100.00% 1 / 1 1
 getDefaultSchemaInspector 100.00% 1 / 1 100.00% 1 / 1 1
 applyModifyColumn 100.00% 1 / 1 100.00% 1 / 1 1
 applyDropColumn 100.00% 6 / 6 100.00% 1 / 1 2
 applyAddColumn 91.66% 11 / 12 0.00% 0 / 1 3.01
 addRequiresRebuild 100.00% 8 / 8 100.00% 1 / 1 4
 modifyColumn 100.00% 1 / 1 100.00% 1 / 1 1
 addForeignKey 100.00% 1 / 1 100.00% 1 / 1 1
 dropForeignKey 100.00% 1 / 1 100.00% 1 / 1 1
 addCheck 100.00% 1 / 1 100.00% 1 / 1 1
 dropCheck 100.00% 1 / 1 100.00% 1 / 1 1
 changeRequiresStandaloneTransaction 100.00% 3 / 3 100.00% 1 / 1 2
 changeRoutesThroughRebuild 100.00% 9 / 9 100.00% 1 / 1 4
 changeInvolvesForeignKeys 100.00% 8 / 8 100.00% 1 / 1 3
 renameSourceInPlan 83.33% 5 / 6 0.00% 0 / 1 5.12
 involvesForeignKeys 100.00% 3 / 3 100.00% 1 / 1 2
 rebuildTable 97.05% 66 / 68 0.00% 0 / 1 18
 supportsSavepoints 100.00% 1 / 1 100.00% 1 / 1 1
 supportsTransactionalDdl 100.00% 1 / 1 100.00% 1 / 1 1
 createSavepoint 100.00% 1 / 1 100.00% 1 / 1 1
 releaseSavepoint 100.00% 1 / 1 100.00% 1 / 1 1
 rollbackToSavepoint 100.00% 1 / 1 100.00% 1 / 1 1
 withLock 100.00% 2 / 2 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Connections;
6
7use BlueprintAU\Radiant\Database\Grammars\Grammar;
8use BlueprintAU\Radiant\Database\Grammars\SqliteGrammar;
9use BlueprintAU\Radiant\Database\Schema\Blueprint;
10use BlueprintAU\Radiant\Database\Schema\Grammars\SchemaGrammar;
11use BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector;
12use BlueprintAU\Radiant\Database\Schema\Inspectors\SqliteSchemaInspector;
13use BlueprintAU\Radiant\Database\Schema\Grammars\SqliteSchemaGrammar;
14use Override;
15
16/**
17 * A database connection backed by SQLite.
18 *
19 * SQLite supports savepoints for nested transactions (like Postgres), so
20 * all four transaction hooks map onto `SAVEPOINT`, `RELEASE SAVEPOINT` and
21 * `ROLLBACK TO SAVEPOINT`.
22 *
23 * @see SqlConnection
24 *
25 * @extends SqlConnection<\BlueprintAU\Radiant\Database\Grammars\SqliteGrammar, \BlueprintAU\Radiant\Database\Schema\Grammars\SqliteSchemaGrammar, \BlueprintAU\Radiant\Database\Schema\Inspectors\SqliteSchemaInspector>
26 */
27final class SqliteConnection extends SqlConnection
28{
29    /**
30     * The default query grammar for this connection.
31     *
32     * @return Grammar
33     */
34    protected function getDefaultQueryGrammar(): Grammar
35    {
36        return new SqliteGrammar();
37    }
38
39    /**
40     * The default schema grammar for this connection.
41     *
42     * @return SchemaGrammar
43     */
44    protected function getDefaultSchemaGrammar(): SchemaGrammar
45    {
46        return new SqliteSchemaGrammar();
47    }
48
49    /**
50     * The dialect's live-schema reader.
51     *
52     * @return SqliteSchemaInspector
53     */
54    protected function getDefaultSchemaInspector(): SchemaInspector
55    {
56        return new SqliteSchemaInspector($this->pdo);
57    }
58
59    /**
60     * Apply a ModifyColumn change through the table rebuild.
61     *
62     * The rebuild renders the full desired shape (the change's main
63     * blueprint), not the drifted subset the in-place dialects compile.
64     *
65     * @param  \BlueprintAU\Radiant\Database\Schema\SchemaChange  $change
66     */
67    #[Override]
68    protected function applyModifyColumn(\BlueprintAU\Radiant\Database\Schema\SchemaChange $change): void
69    {
70        $this->modifyColumn($change->blueprint);
71    }
72
73    /**
74     * Apply a DropColumn change, skipping columns already absent.
75     *
76     * A prior rebuild on the same table renders the full desired shape,
77     * which excludes the dropped columns — a second drop would fail.
78     *
79     * @param  \BlueprintAU\Radiant\Database\Schema\SchemaChange  $change
80     */
81    #[Override]
82    protected function applyDropColumn(\BlueprintAU\Radiant\Database\Schema\SchemaChange $change): void
83    {
84        // A null subject falls back to the blueprint's own dropColumn()
85        // declarations.
86        $subject = $change->subject ?? $change->blueprint->getDropColumns();
87        $live = array_column($this->schemaInspector->table($change->table)->columns, 'name');
88        $pending = array_values(array_intersect($subject, $live));
89
90        if ($pending === []) {
91            return; // Already dropped (a rebuild realized the desired shape).
92        }
93
94        parent::applyDropColumn($change);
95    }
96
97    /**
98     * Apply an AddColumn change, routing a NOT NULL-without-default add
99     * through the table rebuild.
100     *
101     * SQLite cannot add such a column in place to a non-empty table, so
102     * the change rebuilds from the full desired blueprint and backfills
103     * the existing rows. Every other add stays in place.
104     *
105     * @param  \BlueprintAU\Radiant\Database\Schema\SchemaChange  $change
106     */
107    #[Override]
108    protected function applyAddColumn(\BlueprintAU\Radiant\Database\Schema\SchemaChange $change): void
109    {
110        // A null subject acts on every column the blueprint declares.
111        $subject = $change->subject ?? array_map(
112            fn (array $column) => $column['name'],
113            $change->blueprint->getColumns(),
114        );
115        $live = array_column($this->schemaInspector->table($change->table)->columns, 'name');
116        $pending = array_values(array_diff($subject, $live));
117
118        if ($pending === []) {
119            return; // Already added (a rebuild realized the desired shape).
120        }
121
122        if ($this->addRequiresRebuild($change)) {
123            $this->rebuildTable($change->blueprint);
124            return;
125        }
126
127        parent::applyAddColumn($change);
128    }
129
130    /**
131     * Whether an add change carries a NOT NULL column without a default.
132     *
133     * @param  \BlueprintAU\Radiant\Database\Schema\SchemaChange  $change
134     * @return bool
135     */
136    private function addRequiresRebuild(\BlueprintAU\Radiant\Database\Schema\SchemaChange $change): bool
137    {
138        $subject = $change->subject ?? array_map(
139            fn (array $column) => $column['name'],
140            $change->blueprint->getColumns(),
141        );
142
143        foreach ($change->blueprint->onlyColumns($subject)->getColumns() as $column) {
144            if ($column['nullable'] !== true && $column['default'] === null) {
145                return true;
146            }
147        }
148
149        return false;
150    }
151
152    /**
153     * Modify columns on SQLite — routed through the table rebuild.
154     *
155     * @param  Blueprint  $blueprint
156     */
157    #[Override]
158    public function modifyColumn(Blueprint $blueprint): void
159    {
160        $this->rebuildTable($blueprint);
161    }
162
163    /**
164     * Add a foreign-key constraint on SQLite — routed through the table
165     * rebuild.
166     *
167     * @param  string  $table
168     * @param  Blueprint  $blueprint
169     */
170    #[Override]
171    public function addForeignKey(string $table, Blueprint $blueprint): void
172    {
173        $this->rebuildTable($blueprint);
174    }
175
176    /**
177     * Drop a foreign-key constraint on SQLite — routed through the table
178     * rebuild.
179     *
180     * @param  string  $table
181     * @param  Blueprint  $blueprint
182     */
183    #[Override]
184    public function dropForeignKey(string $table, Blueprint $blueprint): void
185    {
186        $this->rebuildTable($blueprint);
187    }
188
189    /**
190     * Add a CHECK constraint on SQLite — routed through the table rebuild.
191     *
192     * @param  string  $table
193     * @param  Blueprint  $blueprint
194     */
195    #[Override]
196    public function addCheck(string $table, Blueprint $blueprint): void
197    {
198        $this->rebuildTable($blueprint);
199    }
200
201    /**
202     * Drop a CHECK constraint on SQLite — routed through the table rebuild.
203     *
204     * @param  string  $table
205     * @param  Blueprint  $blueprint
206     */
207    #[Override]
208    public function dropCheck(string $table, Blueprint $blueprint): void
209    {
210        $this->rebuildTable($blueprint);
211    }
212
213    /**
214     * Whether applying the change needs a transaction-free connection.
215     *
216     * A change routed through a table rebuild whose sequence carries the
217     * `PRAGMA foreign_keys` toggle is the only one: the toggle is a no-op
218     * inside a transaction. The synchronizer consults this before wrapping
219     * its apply loop, so a planned rebuild degrades the transactional
220     * apply instead of failing inside it.
221     *
222     * @param  \BlueprintAU\Radiant\Database\Schema\SchemaChange  $change
223     * @param  list<\BlueprintAU\Radiant\Database\Schema\SchemaChange>  $plan  The whole plan, for rename resolution.
224     * @return bool
225     */
226    #[Override]
227    public function changeRequiresStandaloneTransaction(\BlueprintAU\Radiant\Database\Schema\SchemaChange $change, array $plan = []): bool
228    {
229        if (!$this->changeRoutesThroughRebuild($change)) {
230            return false;
231        }
232
233        // FK involvement alone decides: the PRAGMA toggle appears in the
234        // compiled sequence exactly when the table declares FKs or is a
235        // parent. Compile with enforcement assumed ON to detect the
236        // toggle WITHOUT executing it. The plan supplies the rename
237        // context — an alter for a rename-led plan targets a table the
238        // plan itself brings into existence, so the FK state is read
239        // from the rename's source table, not the not-yet-existing name.
240        return $this->changeInvolvesForeignKeys($change->blueprint->getTable(), $change->blueprint, $plan);
241    }
242
243    /**
244     * Whether a schema change routes through the table rebuild on
245     * SQLite.
246     *
247     * @param  \BlueprintAU\Radiant\Database\Schema\SchemaChange  $change
248     * @return bool
249     */
250    private function changeRoutesThroughRebuild(\BlueprintAU\Radiant\Database\Schema\SchemaChange $change): bool
251    {
252        if ($change->operation === \BlueprintAU\Radiant\Database\Schema\Enums\SchemaOperation::AddColumn) {
253            return $this->addRequiresRebuild($change);
254        }
255
256        return match ($change->operation) {
257            \BlueprintAU\Radiant\Database\Schema\Enums\SchemaOperation::ModifyColumn,
258            \BlueprintAU\Radiant\Database\Schema\Enums\SchemaOperation::AddForeignKey,
259            \BlueprintAU\Radiant\Database\Schema\Enums\SchemaOperation::DropForeignKey,
260            \BlueprintAU\Radiant\Database\Schema\Enums\SchemaOperation::AddCheck,
261            \BlueprintAU\Radiant\Database\Schema\Enums\SchemaOperation::DropCheck => true,
262            default => false,
263        };
264    }
265
266    /**
267     * Whether the change's rebuild sequence would carry the foreign_keys
268     * PRAGMA toggle — answered at predicate time, from plan-aware state.
269     *
270     * The change's table may not exist live yet: an alter in a rename-led
271     * plan targets the name the plan's own rename brings into existence,
272     * so the FK state is read from the rename's source table. The rebuild
273     * itself re-reads the live table at apply time, when the rename has
274     * already run.
275     *
276     * @param  string  $table
277     * @param  Blueprint  $blueprint
278     * @param  list<\BlueprintAU\Radiant\Database\Schema\SchemaChange>  $plan
279     * @return bool
280     */
281    private function changeInvolvesForeignKeys(string $table, Blueprint $blueprint, array $plan = []): bool
282    {
283        // The rename source, when the plan (or the change's own blueprint)
284        // declares one — otherwise the table reads under its own name.
285        $liveName = $blueprint->getRenamedFrom()
286            ?? $this->renameSourceInPlan($table, $plan)
287            ?? $table;
288
289        if ($this->schemaInspector->hasTable($liveName)) {
290            $live = $this->schemaInspector->table($liveName);
291
292            return $live->foreignKeys !== []
293                || $this->schemaInspector->referencingTables($liveName) !== [];
294        }
295
296        // Nothing can reference a table that does not exist yet — the FKs
297        // the rebuild will compile come from the blueprint alone.
298        return $blueprint->getForeignKeys() !== [];
299    }
300
301    /**
302     * The rename source a plan's RenameTable declares for the table.
303     *
304     * @param  string  $table
305     * @param  list<\BlueprintAU\Radiant\Database\Schema\SchemaChange>  $plan
306     * @return string|null
307     */
308    private function renameSourceInPlan(string $table, array $plan): string|null
309    {
310        foreach ($plan as $entry) {
311            if ($entry->operation === \BlueprintAU\Radiant\Database\Schema\Enums\SchemaOperation::RenameTable
312                && $entry->table === $table
313                && $entry->blueprint->getRenamedFrom() !== null) {
314                return $entry->blueprint->getRenamedFrom();
315            }
316        }
317
318        return null;
319    }
320
321    /**
322     * Whether the table's rebuild sequence carries the foreign_keys PRAGMA
323     * toggle — it declares FKs, or any live table references it.
324     *
325     * @param  string  $table
326     * @return bool
327     */
328    private function involvesForeignKeys(string $table): bool
329    {
330        $live = $this->schemaInspector->table($table);
331
332        return $live->foreignKeys !== []
333            || $this->schemaInspector->referencingTables($table) !== [];
334    }
335
336    /**
337     * Rebuild a table — the data-preserving answer to every change SQLite
338     * cannot make in place (content drift, FK/CHECK changes).
339     *
340     * Sequence: PRAGMA off (conditional) → BEGIN → create temp (full
341     * desired schema) → copy live rows → drop old → rename temp →
342     * re-create indexes → `foreign_key_check` must be empty (else
343     * ROLLBACK + throw) → COMMIT → PRAGMA restore.
344     *
345     * @param  Blueprint  $desired
346     * @throws \Throwable
347     */
348    private function rebuildTable(Blueprint $desired): void
349    {
350        $table = $desired->getTable();
351
352        // FK involvement decides the PRAGMA toggle: the table itself
353        // declares FKs, OR any live table references it (it is a parent —
354        // one inspector query, not an N+1 loop over full snapshots).
355        $involvesForeignKeys = $this->involvesForeignKeys($table);
356
357        $foreignKeyConstraintsEnabled = false;
358
359        if ($involvesForeignKeys) {
360            $statement = $this->pdo->query('PRAGMA foreign_keys');
361
362            if ($statement === false) {
363                throw new \RuntimeException('Could not read the SQLite foreign_keys pragma.');
364            }
365
366            $row = $statement->fetch(\PDO::FETCH_OBJ);
367            $foreignKeyConstraintsEnabled = $row !== false && (int) $row->{'foreign_keys'} === 1;
368        }
369
370        // The temp name: validated for the dialect and checked absent.
371        $tempName = $table . '__radiant_new';
372        $this->schemaGrammar->assertValidIdentifier($tempName);
373
374        if ($this->schemaInspector->hasTable($tempName)) {
375            throw new \LogicException(
376                "Cannot rebuild [{$table}]: the temp table [{$tempName}] already exists."
377            );
378        }
379
380        // The live column names — the copy projection is the INTERSECTION
381        // with the desired shape (computed by the grammar's compile).
382        $liveColumns = array_map(
383            fn (array $column) => $column['name'],
384            $this->schemaInspector->table($table)->columns,
385        );
386
387        $statements = $this->schemaGrammar->compileRebuildTable(
388            $desired,
389            $tempName,
390            $liveColumns,
391            $foreignKeyConstraintsEnabled,
392        );
393
394        // The PRAGMA toggle MUST run OUTSIDE the transaction — it is a
395        // no-op inside one (SQLite docs). The compiled list carries the
396        // PRAGMAs at its edges; peel them off and run them around the
397        // transaction straddle. The synchronizer consults
398        // changeRequiresStandaloneTransaction() and defers its apply
399        // past the lock transaction, so what reaches this guard is a
400        // caller that opened its OWN transaction around the rebuild —
401        // the toggle cannot happen there, fail loud.
402        $pragmaOff = null;
403        $pragmaOn = null;
404
405        if ($statements[0] === 'PRAGMA foreign_keys = OFF') {
406            $pragmaOff = array_shift($statements);
407        }
408
409        if (count($statements) > 0 && $statements[count($statements) - 1] === 'PRAGMA foreign_keys = ON') {
410            $pragmaOn = array_pop($statements);
411        }
412
413        if ($pragmaOff !== null && $this->transactionLevel() > 0) {
414            throw new \LogicException(sprintf(
415                'Cannot rebuild [%s] inside a transaction: the foreign_keys PRAGMA toggle is a no-op '
416                . 'inside a transaction, and dropping the table under enforcement would cascade-delete '
417                . 'child rows. On SQLite the schema lock (SqliteLock) is itself a transaction, so the '
418                . 'rebuild cannot run under a lock-held transactional apply either — SchemaSynchronizer::sync() '
419                . 'and apply() degrade to a non-transactional apply automatically; run a direct-connection '
420                . 'rebuild outside any transaction instead (the rebuild stays internally atomic and '
421                . 'fail-fast on FK violations).',
422                $table,
423            ));
424        }
425
426        if ($pragmaOff !== null) {
427            $this->statement($pragmaOff);
428        }
429
430        $this->beginTransaction();
431
432        try {
433            foreach ($statements as $sql) {
434                $this->statement($sql);
435            }
436
437            // Indexes re-created from the ORIGINAL blueprint AFTER the
438            // rename — derived names carry the final table name.
439            foreach ($this->schemaGrammar->compileIndexes($desired) as $indexSql) {
440                $this->statement($indexSql);
441            }
442
443            // The integrity gate: any FK violation rolls the WHOLE rebuild
444            // back — the table is untouched, never silently corrupted.
445            $checkStatement = $this->pdo->query('PRAGMA foreign_key_check');
446
447            if ($checkStatement === false) {
448                throw new \RuntimeException('Could not run the SQLite foreign_key_check pragma.');
449            }
450
451            $violations = $checkStatement->fetchAll(\PDO::FETCH_OBJ);
452
453            if ($violations !== []) {
454                throw new \LogicException(sprintf(
455                    'Rebuilding [%s] would violate foreign keys: %d row(s) reference missing parents. '
456                    . 'The rebuild rolled back; fix the orphaned rows first.',
457                    $table,
458                    count($violations),
459                ));
460            }
461
462            $this->commit();
463        } catch (\Throwable $exception) {
464            $this->rollBack();
465
466            // The PRAGMA was toggled OUTSIDE the transaction — restore it
467            // even on the failure path.
468            if ($pragmaOn !== null) {
469                $this->statement($pragmaOn);
470            }
471
472            throw $exception;
473        }
474
475        if ($pragmaOn !== null) {
476            $this->statement($pragmaOn);
477        }
478    }
479
480    /**
481     * Whether this dialect supports savepoints for nested transactions.
482     *
483     * @return bool
484     */
485    protected function supportsSavepoints(): bool
486    {
487        return true;
488    }
489
490    /**
491     * SQLite DDL is transactional — schema statements roll back with the
492     * transaction.
493     *
494     * @return bool
495     */
496    #[Override]
497    public function supportsTransactionalDdl(): bool
498    {
499        return true;
500    }
501
502    /**
503     * Create a named savepoint.
504     *
505     * @param  string  $name
506     */
507    protected function createSavepoint(string $name): void
508    {
509        $this->pdo->exec("SAVEPOINT {$name}");
510    }
511
512    /**
513     * Release a named savepoint.
514     *
515     * @param  string  $name
516     */
517    protected function releaseSavepoint(string $name): void
518    {
519        $this->pdo->exec("RELEASE SAVEPOINT {$name}");
520    }
521
522    /**
523     * Roll back to a named savepoint.
524     *
525     * @param  string  $name
526     */
527    protected function rollbackToSavepoint(string $name): void
528    {
529        $this->pdo->exec("ROLLBACK TO SAVEPOINT {$name}");
530    }
531
532    /**
533     * Run the callback inside a write transaction — SQLite's native
534     * cross-process serialization.
535     *
536     * @template TReturn
537     *
538     * @param  callable(): TReturn  $callback
539     * @param  string  $name  The lock domain (ignored on SQLite).
540     * @return TReturn
541     * @throws \Throwable
542     */
543    #[Override]
544    public function withLock(callable $callback, string $name): mixed
545    {
546        return (new \BlueprintAU\Radiant\Database\Locks\SqliteLock($this))
547            ->withLock($callback, $name);
548    }
549}