Lines 98.03% 350 / 357
Methods 90.76% 59 / 65
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 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
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] __construct 100.00% 5 / 5 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] assertSql 100.00% 2 / 2 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] from 100.00% 6 / 6 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] table 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] select 100.00% 8 / 8 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] selectColumn 100.00% 2 / 2 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] insert 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] insertGetId 100.00% 23 / 23 100.00% 1 / 1 8
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] update 100.00% 2 / 2 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] delete 100.00% 2 / 2 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] cursor 100.00% 2 / 2 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] flattenInsertValues 100.00% 5 / 5 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] selectSql 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] selectColumnSql 100.00% 2 / 2 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] cursorSql 100.00% 4 / 4 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] chunkSql 100.00% 11 / 11 100.00% 1 / 1 6
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] prepareAndExecute 100.00% 8 / 8 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] statement 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] affectingStatement 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] bindValues 100.00% 11 / 11 100.00% 1 / 1 10
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] run 100.00% 8 / 8 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] getDefaultValueCodec 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] create 100.00% 3 / 3 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] alter 100.00% 9 / 9 100.00% 1 / 1 5
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] drop 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] apply 93.33% 14 / 15 0.00% 0 / 1 13.05
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] renameTable 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] renameColumn 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] applyColumnRenames 100.00% 2 / 2 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] subjectBlueprint 100.00% 3 / 3 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] rebuildIndexes 100.00% 4 / 4 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] transactionLevel 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] beginTransaction 100.00% 10 / 10 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] commit 100.00% 11 / 11 100.00% 1 / 1 4
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] rollBack 100.00% 11 / 11 100.00% 1 / 1 4
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] reconcileFailedCommit 100.00% 2 / 2 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] coroutineId 88.88% 8 / 9 0.00% 0 / 1 6.05
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] assertSameCoroutine 100.00% 8 / 8 100.00% 1 / 1 4
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] savepointNameFor 75.00% 3 / 4 0.00% 0 / 1 1.02
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] transaction 100.00% 8 / 8 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Database\Connections\SqlConnection] __destruct 100.00% 5 / 5 100.00% 1 / 1 3
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}

Inherited from BlueprintAU\Radiant\Database\Connections\SqlConnection

75    public function __construct(protected \Pdo $pdo)
76    {
77        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
78        $this->codec = $this->getDefaultValueCodec();
79        $this->grammar = $this->getDefaultQueryGrammar();
80        $this->schemaGrammar = $this->getDefaultSchemaGrammar();
81        $this->schemaInspector = $this->getDefaultSchemaInspector();
82    }
90    final public static function assertSql(ConnectionInterface $connection): void
91    {
92        if (!$connection instanceof self) {
93            throw new UnsupportedFeatureException('The connection is not a SQL connection.');
94        }
95    }
105    final public static function from(ConnectionInterface $connection): static
106    {
107        if (!$connection instanceof static) {
108            // A non-SQL backend gets the generic message; a SQL one gets
109            // the dialect-specific one.
110            self::assertSql($connection);
111            throw new UnsupportedFeatureException(
112                'The connection is SQL, but not a ' . static::class . '.'
113            );
114        }
115
116        return $connection;
117    }
126    final public function table(string $identifier): QueryBuilder
127    {
128        return new QueryBuilder($this, $identifier);
129    }
139    final public function select(QueryBuilder $query): Collection
140    {
141        if ($query->getLock() !== null && $this->transactionLevel === 0) {
142            throw new \LogicException(
143                'Row locks (lockForUpdate/sharedLock) require an open transaction â€” '
144                . 'outside one, the lock is released at statement end and protects nothing. '
145                . 'Wrap the query in beginTransaction()/transaction().'
146            );
147        }
148
149        $sql = $this->grammar->compileSelect($query);
150        return $this->selectSql($sql, $query->getBindings());
151    }
161    final public function selectColumn(QueryBuilder $query): Collection
162    {
163        $sql = $this->grammar->compileSelect($query);
164        return $this->selectColumnSql($sql, $query->getBindings());
165    }
175    final public function insert(QueryBuilder $query, array $values): int
176    {
177        $rows = $this->normalizeInsertRows($values);
178
179        // Fail fast on ragged rows BEFORE any statement runs â€” the grammar
180        // would reject them at compile, but the binding flattener below
181        // would happily emit a mismatched list first.
182        $this->assertUniformInsertRows($rows);
183
184        $sql = $this->grammar->compileInsert($query, $values);
185        return $this->affectingStatement($sql, $this->flattenInsertValues($values));
186    }
196    final public function insertGetId(QueryBuilder $query, array $values): string|int|null
197    {
198        $pk = $query->getInsertIdColumn();
199
200        if ($pk === null) {
201            // No key declared â€” compile the plain insert and report success.
202            $this->affectingStatement($this->grammar->compileInsert($query, $values), $this->flattenInsertValues($values));
203            return null;
204        }
205
206        // The compile-shaped capability contract: the grammar returns the
207        // statement PLUS whether that statement yields the key (a RETURNING
208        // dialect compiles the clause in; MySQL compiles without it). The
209        // connection never probes a boolean â€” it reads the compile result.
210        $compiled = $this->grammar->compileInsertForId($query, $values, $pk);
211        $bindings = $this->flattenInsertValues($values);
212
213        if ($compiled['returnsKey']) {
214            $row = $this->selectSql($compiled['sql'], $bindings)->first();
215            if ($row === null) {
216                return null;
217            }
218            $id = $this->codec->decode($row->{$pk});
219            return is_int($id) || is_string($id) ? $id : null;
220        }
221
222        $this->statement($compiled['sql'], $bindings);
223        // The lastInsertId() fallback is reachable ONLY on dialects without
224        // RETURNING (MySQL, and old SQLite) â€” Postgres' grammar always uses
225        // RETURNING, so its sequence-based lastval() hazards never apply
226        // here. On MySQL lastInsertId() is connection-scoped and unaffected
227        // by concurrent inserts on other connections. PDO always returns a
228        // string (or false when there is no generated id); the codec passes
229        // strings through untouched, so no decode is needed.
230        $id = $this->pdo->lastInsertId();
231        if ($id === false) {
232            return null;
233        }
234
235        // lastInsertId() is only meaningful for an AUTO_INCREMENT/SERIAL
236        // column: a caller-declared non-auto-increment PK (UUID, char, or a
237        // PK the row value supplies) generates nothing server-side, so the
238        // value here is a stale id from an EARLIER insert on this connection
239        // (or '0'). Returning it would hand the caller a key that does not
240        // identify the row just written â€” fail fast instead.
241        if (!$query->isInsertIdAutoIncrement()) {
242            throw new \LogicException(
243                "insertGetId() declared key column [{$pk}] is not auto-increment â€” no id is generated"
244                . ' server-side, so lastInsertId() would return a stale value from an earlier insert.'
245                . ' Assign the key before inserting and use insert().'
246            );
247        }
248
249        return $id;
250    }
260    final public function update(QueryBuilder $query, array $values): int
261    {
262        $sql = $this->grammar->compileUpdate($query, $values);
263        return $this->affectingStatement($sql, array_merge(array_values($values), $query->getBindings([BindingCategory::Join, BindingCategory::Where])));
264    }
273    final public function delete(QueryBuilder $query): int
274    {
275        $sql = $this->grammar->compileDelete($query);
276        return $this->affectingStatement($sql, $query->getBindings([BindingCategory::Join, BindingCategory::Where]));
277    }
287    final public function cursor(QueryBuilder $query): \Generator
288    {
289        $sql = $this->grammar->compileSelect($query);
290        return $this->cursorSql($sql, $query->getBindings());
291    }
299    protected function flattenInsertValues(array $values): array
300    {
301        $rows = $this->normalizeInsertRows($values);
302        $bindings = [];
303        foreach ($rows as $row) {
304            array_push($bindings, ...array_values($row));
305        }
306        return $bindings;
307    }
319    final public function selectSql(string $sql, array $bindings = []): Collection
320    {
321        return Collection::make($this->run($sql, $bindings, fn(\PDOStatement $stmt) => $stmt->fetchAll(\PDO::FETCH_OBJ)));
322    }
331    final public function selectColumnSql(string $sql, array $bindings = []): Collection
332    {
333        /** @var list<mixed> $column */
334        $column = $this->run($sql, $bindings, fn(\PDOStatement $stmt) => $stmt->fetchAll(\PDO::FETCH_COLUMN, 0));
335        return Collection::make($column);
336    }
350    final public function cursorSql(string $sql, array $bindings = []): \Generator
351    {
352        $stmt = $this->prepareAndExecute($sql, $bindings);
353        try {
354            while ($row = $stmt->fetch(\PDO::FETCH_OBJ)) {
355                yield $row;
356            }
357        } finally {
358            $stmt->closeCursor();
359        }
360    }
376    final public function chunkSql(string $sql, array $bindings, int $size, callable $callback): void
377    {
378        if ($size < 1) {
379            throw new \InvalidArgumentException("Chunk size must be at least 1; got {$size}.");
380        }
381        $chunk = [];
382        foreach ($this->cursorSql($sql, $bindings) as $row) {
383            $chunk[] = $row;
384            if (count($chunk) === $size) {
385                if ($callback($chunk) === false) {
386                    return;
387                }
388                $chunk = [];
389            }
390        }
391        if ($chunk !== []) {
392            $callback($chunk);
393        }
394    }
405    private function prepareAndExecute(string $sql, array $bindings): \PDOStatement
406    {
407        try {
408            $stmt = $this->pdo->prepare($sql);
409            $this->bindValues($stmt, $bindings);
410            $stmt->execute();
411            return $stmt;
412        } catch (\PDOException $e) {
413            if ($this->isConnectionLoss($e)) {
414                $this->stale = true;
415            }
416            throw new QueryException($sql, $bindings, $e);
417        }
418    }
426    final public function statement(string $sql, array $bindings = []): void
427    {
428        $this->run($sql, $bindings, fn() => null);
429    }
438    final public function affectingStatement(string $sql, array $bindings = []): int
439    {
440        return $this->run($sql, $bindings, fn(\PDOStatement $stmt) => $stmt->rowCount());
441    }
450    protected function bindValues(\PDOStatement $stmt, array $bindings): void
451    {
452        foreach ($bindings as $key => $value) {
453            // The write path: bindable value â†’ codec â†’ driver value â†’ bind.
454            if (!is_scalar($value) && !$value instanceof \DateTimeInterface && $value !== null) {
455                throw new \InvalidArgumentException(
456                    'Binding must be a scalar, null, or DateTimeInterface; got ' . get_debug_type($value) . '.'
457                );
458            }
459            $value = $this->codec->encode($value);
460
461            // Bind with an EXPLICIT PDO type. Without one, PDO defaults to
462            // PARAM_STR â€” and an expression like `count(*) > ?` then
463            // compares against the string '1', which SQLite evaluates as
464            // text-vs-number and always false. Typed columns survive the
465            // string bind via column affinity; aggregate expressions have
466            // no affinity to save them. (int→PARAM_INT, float→PARAM_STR â€”
467            // PDO has no float type and SQLite compares numerically anyway,
468            // bool→PARAM_INT, null→PARAM_NULL.)
469            $type = match (true) {
470                is_int($value) => \PDO::PARAM_INT,
471                is_bool($value) => \PDO::PARAM_INT,
472                $value === null => \PDO::PARAM_NULL,
473                default => \PDO::PARAM_STR,
474            };
475
476            $stmt->bindValue(is_int($key) ? $key + 1 : $key, $value, $type);
477        }
478    }
491    final protected function run(string $sql, array $bindings, callable $callback): mixed
492    {
493        try {
494            $stmt = $this->pdo->prepare($sql);
495            $this->bindValues($stmt, $bindings);
496            $stmt->execute();
497            return $callback($stmt);
498        } catch (\PDOException $e) {
499            if ($this->isConnectionLoss($e)) {
500                $this->stale = true;
501            }
502            throw new QueryException($sql, $bindings, $e);
503        }
504    }
513    protected function getDefaultValueCodec(): ValueCodecInterface
514    {
515        return new DefaultValueCodec();
516    }
572    final public function create(Blueprint $blueprint): void
573    {
574        $this->statement($this->schemaGrammar->compileCreate($blueprint));
575
576        foreach ($this->schemaGrammar->compileIndexes($blueprint) as $indexSql) {
577            $this->statement($indexSql);
578        }
579    }
587    final public function alter(SchemaOperation $operation, Blueprint $blueprint): void
588    {
589        $statements = match ($operation) {
590            SchemaOperation::AddColumn => $this->schemaGrammar->compileAddColumns($blueprint),
591            SchemaOperation::DropColumn => $this->schemaGrammar->compileDropColumns($blueprint),
592            default => throw new \LogicException(
593                "Operation [{$operation->value}] is not a column alter; use the "
594                . 'dedicated create/drop/rebuildIndexes paths.'
595            ),
596        };
597
598        foreach ($statements as $sql) {
599            $this->statement($sql);
600        }
601    }
608    final public function drop(string $table): void
609    {
610        $this->statement($this->schemaGrammar->compileDrop($table));
611    }
619    final public function apply(\BlueprintAU\Radiant\Database\Schema\SchemaChange $change): void
620    {
621        match ($change->operation) {
622            SchemaOperation::CreateTable => $this->create($change->blueprint),
623            SchemaOperation::AddColumn => $this->applyAddColumn($change),
624            SchemaOperation::DropColumn => $this->applyDropColumn($change),
625            SchemaOperation::DropTable => $this->drop($change->table),
626            SchemaOperation::AlterIndexes => $this->rebuildIndexes($change->blueprint),
627            SchemaOperation::RenameTable => $this->renameTable($change->blueprint->getRenamedFrom() ?? throw new \LogicException(
628                "A RenameTable change for [{$change->table}] carries no renamedFrom declaration."
629            ), $change->table),
630            SchemaOperation::RenameColumn => $this->applyColumnRenames($change->blueprint),
631            SchemaOperation::ModifyColumn => $this->applyModifyColumn($change),
632            SchemaOperation::AddForeignKey => $this->addForeignKey($change->table, $change->blueprint),
633            SchemaOperation::DropForeignKey => $this->dropForeignKey($change->table, $change->blueprint),
634            SchemaOperation::AddCheck => $this->addCheck($change->table, $change->blueprint),
635            SchemaOperation::DropCheck => $this->dropCheck($change->table, $change->blueprint),
636        };
637    }
645    final public function renameTable(string $from, string $to): void
646    {
647        $this->statement($this->schemaGrammar->compileRenameTable($from, $to));
648    }
657    final public function renameColumn(string $table, string $from, string $to): void
658    {
659        $this->statement($this->schemaGrammar->compileRenameColumn($table, $from, $to));
660    }
668    private function applyColumnRenames(Blueprint $blueprint): void
669    {
670        foreach ($blueprint->getColumnRenames() as $rename) {
671            $this->renameColumn($blueprint->getTable(), $rename['from'], $rename['to']);
672        }
673    }
725    private function subjectBlueprint(\BlueprintAU\Radiant\Database\Schema\SchemaChange $change): Blueprint
726    {
727        if ($change->subject === null) {
728            return $change->blueprint;
729        }
730
731        return $change->blueprint->onlyColumns($change->subject);
732    }
824    final public function rebuildIndexes(Blueprint $blueprint): void
825    {
826        foreach ($blueprint->getIndexes() as $index) {
827            $this->statement($this->schemaGrammar->compileDropIndex($index['name'], $blueprint->getTable()));
828        }
829
830        foreach ($this->schemaGrammar->compileIndexes($blueprint) as $indexSql) {
831            $this->statement($indexSql);
832        }
833    }
890    final public function transactionLevel(): int
891    {
892        return $this->transactionLevel;
893    }
928    final public function beginTransaction(): void
929    {
930        $this->assertSameCoroutine('beginTransaction');
931        $toLevel = $this->transactionLevel + 1;
932        if ($toLevel === 1) {
933            $this->pdo->beginTransaction();
934            $this->transactionOwner = $this->coroutineId();
935        } elseif ($this->supportsSavepoints()) {
936            // Unique per-frame name (depth + sequence): depth alone collides
937            // when interleaved coroutine frames nest on one connection.
938            $name = 'trans' . $toLevel . '_' . (++$this->savepointSequence);
939            $this->createSavepoint($name);
940            $this->savepointsByLevel[$toLevel] = $name;
941        }
942        $this->transactionLevel = $toLevel;
943    }
948    final public function commit(): void
949    {
950        $this->assertSameCoroutine('commit');
951        $toLevel = $this->transactionLevel - 1;
952        $this->transactionLevel = $toLevel;
953        if ($toLevel === 0) {
954            try {
955                $this->pdo->commit();
956            } catch (\PDOException $e) {
957                $this->reconcileFailedCommit();
958                throw $e;
959            }
960            $this->transactionOwner = null;
961        } elseif ($this->supportsSavepoints()) {
962            $this->releaseSavepoint($this->savepointNameFor($toLevel + 1));
963        }
964    }
969    final public function rollBack(): void
970    {
971        $this->assertSameCoroutine('rollBack');
972        $toLevel = $this->transactionLevel - 1;
973        $this->transactionLevel = $toLevel;
974        if ($toLevel === 0) {
975            try {
976                $this->pdo->rollBack();
977            } catch (\PDOException $e) {
978                $this->reconcileFailedCommit();
979                throw $e;
980            }
981            $this->transactionOwner = null;
982        } elseif ($this->supportsSavepoints()) {
983            $this->rollbackToSavepoint($this->savepointNameFor($toLevel + 1));
984        }
985    }
991    private function reconcileFailedCommit(): void
992    {
993        try {
994            $this->pdo->rollBack();
995        } catch (\Throwable) {
996            // Nothing more can be done here â€” the connection is dead or the
997            // transaction is already gone; staleness handling takes over.
998        }
999    }
1006    private function coroutineId(): string
1007    {
1008        if (\class_exists(\Fiber::class)) {
1009            $fiber = \Fiber::getCurrent();
1010            if ($fiber !== null) {
1011                return 'fiber:' . (string) \spl_object_id($fiber);
1012            }
1013        }
1014        if (\class_exists('Swoole\Coroutine')
1015            && ($cid = \Swoole\Coroutine::getCid()) > 0
1016        ) {
1017            return 'swoole:' . (string) $cid;
1018        }
1019        $pid = getmypid();
1020        return 'proc:' . ($pid === false ? 'unknown' : (string) $pid);
1021    }
1029    private function assertSameCoroutine(string $operation): void
1030    {
1031        if ($this->transactionLevel > 0
1032            && $this->transactionOwner !== null
1033            && $this->transactionOwner !== $this->coroutineId()
1034        ) {
1035            throw new \LogicException(
1036                "{$operation}() called from a different coroutine than the one that opened"
1037                . ' the transaction. Connections are not coroutine-safe while a transaction'
1038                . ' is open â€” use one connection per coroutine.'
1039            );
1040        }
1041    }
1049    private function savepointNameFor(int $level): string
1050    {
1051        $name = $this->savepointsByLevel[$level]
1052            ?? 'trans' . $level;
1053
1054        unset($this->savepointsByLevel[$level]);
1055
1056        return $name;
1057    }
1067    final public function transaction(callable $callback): mixed
1068    {
1069        $this->beginTransaction();
1070        try {
1071            $result = $callback($this);
1072            $this->commit();
1073            return $result;
1074        } catch (\Throwable $e) {
1075            try {
1076                $this->rollBack();
1077            } catch (\Throwable) {
1078                // The original failure is what the caller needs; a failed
1079                // rollback is secondary (and usually shares its cause).
1080            }
1081            throw $e;
1082        }
1083    }
1090    public function __destruct()
1091    {
1092        if ($this->transactionLevel === 0) {
1093            return;
1094        }
1095        try {
1096            $this->transactionLevel = 0;
1097            $this->pdo->rollBack();
1098        } catch (\Throwable) {
1099            // Teardown is best-effort: the connection may already be dead
1100            // (which also releases the server-side transaction).
1101        }
1102    }