Lines 97.03% 131 / 135
Functions and Methods 75.00% 9 / 12
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
SqliteSchemaInspector 97.03% 131 / 135 75.00% 9 / 12 39 0.00% 0 / 1
 getDefaultSchemaGrammar 100.00% 1 / 1 100.00% 1 / 1 1
 columnTypeMatches 100.00% 1 / 1 100.00% 1 / 1 1
 referencingTables 100.00% 10 / 10 100.00% 1 / 1 4
 tables 90.00% 9 / 10 0.00% 0 / 1 3.01
 table 100.00% 9 / 9 100.00% 1 / 1 2
 checks 93.75% 30 / 32 0.00% 0 / 1 10.02
 columns 100.00% 12 / 12 100.00% 1 / 1 2
 indexes 95.23% 20 / 21 0.00% 0 / 1 5
 parseIndexWhere 100.00% 11 / 11 100.00% 1 / 1 4
 foreignKeys 100.00% 25 / 25 100.00% 1 / 1 4
 normalizeAction 100.00% 2 / 2 100.00% 1 / 1 2
 quoteIdentifier 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Schema\Inspectors;
6
7use BlueprintAU\Radiant\Database\Schema\ConstraintNamer;
8
9/**
10 * Reads the live schema on SQLite — `PRAGMA table_info`, `table_xinfo`
11 * internals and the `sqlite_master` index rows.
12 *
13 * @extends SchemaInspector<\BlueprintAU\Radiant\Database\Schema\Grammars\SqliteSchemaGrammar>
14 */
15final class SqliteSchemaInspector extends SchemaInspector
16{
17    /**
18     * The dialect's schema grammar (the factory hook).
19     *
20     * @return \BlueprintAU\Radiant\Database\Schema\Grammars\SqliteSchemaGrammar
21     */
22    protected function getDefaultSchemaGrammar(): \BlueprintAU\Radiant\Database\Schema\Grammars\SchemaGrammar
23    {
24        return new \BlueprintAU\Radiant\Database\Schema\Grammars\SqliteSchemaGrammar();
25    }
26
27    /**
28     * Whether a live column's native type text matches the declared
29     * logical type — the SQLite mapping.
30     *
31     * @param  string  $liveType
32     * @param  \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType  $declaredType
33     * @param  int|null  $declaredLength
34     * @param  int|null  $declaredPrecision
35     * @param  int|null  $declaredScale
36     * @return bool
37     */
38    public function columnTypeMatches(string $liveType, \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType $declaredType, int|null $declaredLength, int|null $declaredPrecision = null, int|null $declaredScale = null): bool
39    {
40        return strtolower($liveType) === strtolower($this->schemaGrammar->type($declaredType, $declaredLength, $declaredPrecision, $declaredScale));
41    }
42
43    /**
44     * The live tables that declare a foreign key into the given table.
45     *
46     * @param  string  $table
47     * @return list<string>
48     */
49    public function referencingTables(string $table): array
50    {
51        $referencing = [];
52
53        foreach ($this->tables() as $candidate) {
54            $statement = $this->pdo->prepare('PRAGMA foreign_key_list(' . $this->quoteIdentifier($candidate) . ')');
55            $statement->execute();
56
57            /** @var list<array<string, mixed>> $rows */
58            $rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
59
60            foreach ($rows as $row) {
61                if ((string) $row['table'] === $table) {
62                    $referencing[] = $candidate;
63                    break;
64                }
65            }
66        }
67
68        return $referencing;
69    }
70    /**
71     * Every table name in the live schema.
72     *
73     * Excludes SQLite's own internals (`sqlite_%`) and shadow tables of
74     * FTS/virtual tables.
75     *
76     * @return list<string>
77     */
78    public function tables(): array
79    {
80        $result = $this->pdo->query(
81            "SELECT name FROM sqlite_master WHERE type = 'table' "
82            . "AND name NOT LIKE 'sqlite_%' ORDER BY name",
83        );
84
85        if ($result === false) {
86            throw new \RuntimeException('Could not list SQLite tables.');
87        }
88
89        // Build by append: the append target is inferred as list<string>,
90        // which keeps the return type exact no matter how the underlying
91        // PHP version types fetchAll(PDO::FETCH_COLUMN).
92        $tables = [];
93        foreach ($result->fetchAll(\PDO::FETCH_COLUMN) as $name) {
94            $tables[] = (string) $name;
95        }
96
97        return $tables;
98    }
99
100    /**
101     * One table's live schema.
102     *
103     * @param  string  $name
104     * @return LiveTable
105     * @throws \RuntimeException
106     */
107    public function table(string $name): LiveTable
108    {
109        if (!$this->hasTable($name)) {
110            throw new \RuntimeException("Table [{$name}] does not exist in the SQLite schema.");
111        }
112
113        return new LiveTable(
114            $name,
115            $this->columns($name),
116            $this->indexes($name),
117            $this->foreignKeys($name),
118            $this->checks($name),
119        );
120    }
121
122    /**
123     * The live CHECK constraints, parsed from the `sqlite_master` SQL.
124     *
125     * @param  string  $name
126     * @return list<array{name: string|null, expression: string|null}>
127     */
128    private function checks(string $name): array
129    {
130        $statement = $this->pdo->prepare(
131            "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
132        );
133        $statement->execute([$name]);
134
135        /** @var string|false $sql */
136        $sql = $statement->fetchColumn();
137
138        if ($sql === false) {
139            return [];
140        }
141
142        // Extract the parenthesized body of the CREATE TABLE, then scan
143        // for CHECK ( ... ) groups with balanced parens.
144        if (preg_match_all('/(?:CONSTRAINT\s+(\S+)\s+)?CHECK\s*\(/i', $sql, $matches, \PREG_OFFSET_CAPTURE) === 0) {
145            return [];
146        }
147
148        $checks = [];
149
150        foreach ($matches[0] as $index => $match) {
151            $nameMatch = $matches[1][$index][0] ?? null;
152            $openParen = (int) $match[1] + strlen((string) $match[0]) - 1;
153
154            // Walk to the matching close paren (balanced scan).
155            $depth = 0;
156            $end = -1;
157            $length = strlen($sql);
158
159            for ($i = $openParen; $i < $length; $i++) {
160                if ($sql[$i] === '(') {
161                    $depth++;
162                } elseif ($sql[$i] === ')') {
163                    $depth--;
164
165                    if ($depth === 0) {
166                        $end = $i;
167                        break;
168                    }
169                }
170            }
171
172            if ($end === -1) {
173                continue; // unbalanced — skip (never guess).
174            }
175
176            $expression = trim(substr($sql, $openParen + 1, $end - $openParen - 1));
177
178            $checks[] = [
179                'name' => $nameMatch === null ? null : trim($nameMatch, '"`\''),
180                'expression' => $expression,
181            ];
182        }
183
184        return $checks;
185    }
186
187    /**
188     * The live columns, from `PRAGMA table_info`.
189     *
190     * @param  string  $name
191     * @return list<array{name: string, type: string, nullable: bool, default: mixed, primaryKey: bool}>
192     */
193    private function columns(string $name): array
194    {
195        $statement = $this->pdo->prepare('PRAGMA table_info(' . $this->quoteIdentifier($name) . ')');
196        $statement->execute();
197
198        $columns = [];
199
200        /** @var array<string, mixed> $row */
201        foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) {
202            $columns[] = [
203                'name' => (string) $row['name'],
204                'type' => strtolower((string) $row['type']),
205                'nullable' => ((int) $row['notnull']) === 0,
206                'default' => $row['dflt_value'],
207                'primaryKey' => ((int) $row['pk']) > 0,
208            ];
209        }
210
211        return $columns;
212    }
213
214    /**
215     * The live indexes, from `PRAGMA index_list` + `index_info`.
216     *
217     * @param  string  $name
218     * @return list<array{name: string|null, columns: list<string>, unique: bool, where: string|null, nullsNotDistinct: bool}>
219     */
220    private function indexes(string $name): array
221    {
222        $statement = $this->pdo->prepare('PRAGMA index_list(' . $this->quoteIdentifier($name) . ')');
223        $statement->execute();
224
225        $indexes = [];
226
227        /** @var array<string, mixed> $row */
228        foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) {
229            $origin = (string) $row['origin'];
230
231            // 'pk' indexes restate the primary key (already on the columns);
232            // 'u'/'c' are unique/constraint indexes — real user schema.
233            if ($origin === 'pk') {
234                continue;
235            }
236
237            $indexName = (string) $row['name'];
238            $infoStatement = $this->pdo->prepare('PRAGMA index_info(' . $this->quoteIdentifier($indexName) . ')');
239            $infoStatement->execute();
240
241            // Build by append: the append target is inferred as list<string>,
242            // which keeps the type exact no matter how the underlying PHP
243            // version types fetchAll(PDO::FETCH_COLUMN).
244            $columns = [];
245            foreach ($infoStatement->fetchAll(\PDO::FETCH_COLUMN, 2) as $column) {
246                $columns[] = (string) $column;
247            }
248
249            $indexes[] = [
250                // Auto-named indexes (sqlite_autoindex_*) are unnamed from
251                // the user's perspective — they came from inline constraints.
252                'name' => str_starts_with($indexName, 'sqlite_autoindex_') ? null : $indexName,
253                'columns' => $columns,
254                'unique' => ((int) $row['unique']) === 1,
255                // The partial-index predicate rides the CREATE INDEX SQL in
256                // sqlite_master — PRAGMA index_list does not expose it.
257                'where' => $this->parseIndexWhere($indexName),
258                // SQLite has no NULLS NOT DISTINCT — always false.
259                'nullsNotDistinct' => false,
260            ];
261        }
262
263        return $indexes;
264    }
265
266    /**
267     * Extract the partial-index predicate for a named index from the
268     * `sqlite_master` SQL.
269     *
270     * @param  string  $indexName
271     * @return string|null
272     */
273    private function parseIndexWhere(string $indexName): ?string
274    {
275        $statement = $this->pdo->prepare(
276            "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?",
277        );
278        $statement->execute([$indexName]);
279
280        $sql = $statement->fetchColumn();
281
282        if ($sql === false || !is_string($sql)) {
283            return null;
284        }
285
286        $where = strripos($sql, ' WHERE ');
287
288        if ($where === false) {
289            return null;
290        }
291
292        return rtrim(trim(substr($sql, $where + 7)), ';');
293    }
294
295    /**
296     * The live foreign keys, from `PRAGMA foreign_key_list`.
297     *
298     * @param  string  $name
299     * @return list<array{columns: list<string>, referencesTable: string, referencesColumns: list<string>, onDelete: string|null, onUpdate: string|null, deferrable: bool, name: string|null}>
300     */
301    private function foreignKeys(string $name): array
302    {
303        $statement = $this->pdo->prepare('PRAGMA foreign_key_list(' . $this->quoteIdentifier($name) . ')');
304        $statement->execute();
305
306        /** @var list<array<string, mixed>> $rows */
307        $rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
308
309        // Composite FKs span several rows (one per column, sharing `id`) —
310        // group by id, keeping declaration order.
311        $groups = [];
312
313        foreach ($rows as $row) {
314            $id = (int) $row['id'];
315            $groups[$id]['columns'][] = (string) $row['from'];
316            $groups[$id]['referencesTable'] = (string) $row['table'];
317            $groups[$id]['referencesColumns'][(int) $row['seq']] = (string) $row['to'];
318            $groups[$id]['onDelete'] = $row['on_delete'];
319            $groups[$id]['onUpdate'] = $row['on_update'];
320        }
321
322        $constraints = [];
323
324        foreach ($groups as $id => $group) {
325            $constraints[] = [
326                'columns' => $group['columns'],
327                'referencesTable' => $group['referencesTable'],
328                'referencesColumns' => array_values($group['referencesColumns']),
329                'onDelete' => $this->normalizeAction($group['onDelete']),
330                'onUpdate' => $this->normalizeAction($group['onUpdate']),
331                // SQLite has no DEFERRABLE — always false.
332                'deferrable' => false,
333                // SQLite does not name inline FK constraints — the derived
334                // `{table}_{columns}_foreign` convention is the handle the
335                // grammar would use; null when it cannot be derived. The
336                // shape comes from the shared {@see ConstraintNamer}, so
337                // the read side can never drift from the write side.
338                'name' => $group['referencesTable'] === ''
339                    ? null
340                    : ConstraintNamer::derive($name, $group['columns'], 'foreign'),
341            ];
342        }
343
344        return $constraints;
345    }
346
347    /**
348     * Normalize SQLite's referential-action text to a canonical value.
349     *
350     * @param  mixed  $action
351     * @return string|null
352     */
353    private function normalizeAction(mixed $action): ?string
354    {
355        $normalized = strtoupper(trim((string) $action));
356
357        return $normalized === 'NO ACTION' ? null : $normalized;
358    }
359
360    /**
361     * Quote an identifier for direct PRAGMA interpolation.
362     *
363     * PRAGMA arguments cannot be bound as parameters.
364     *
365     * @param  string  $name
366     * @return string
367     */
368    private function quoteIdentifier(string $name): string
369    {
370        return '"' . str_replace('"', '""', $name) . '"';
371    }
372}