Lines
97.50%
156 / 160
Methods
82.35%
14 / 17
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| 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 | ||
| [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] hasTable | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] castSafety | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] liveTypeFamily | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 8 | ||
| [BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector] desiredTypeFamily | 100.00% | 12 / 12 | 100.00% | 1 / 1 | 7 | ||
| 15 | final 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 | } |
Inherited from BlueprintAU\Radiant\Database\Schema\Inspectors\SchemaInspector
| 28 | public function __construct( | |
| 29 | protected readonly \PDO $pdo, | |
| 30 | ) { | |
| 31 | $this->schemaGrammar = $this->getDefaultSchemaGrammar(); | |
| 32 | } |
| 63 | final public function hasTable(string $name): bool | |
| 64 | { | |
| 65 | return in_array($name, $this->tables(), true); | |
| 66 | } |
| 101 | public function castSafety(string $liveType, \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType $desiredType): \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety | |
| 102 | { | |
| 103 | return $this->liveTypeFamily($liveType) === $this->desiredTypeFamily($desiredType) | |
| 104 | ? \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety::Safe | |
| 105 | : \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety::Risky; | |
| 106 | } |
| 114 | protected function liveTypeFamily(string $liveType): string | |
| 115 | { | |
| 116 | $base = strtolower(preg_replace('/\(.*$/', '', trim($liveType)) ?? $liveType); | |
| 117 | ||
| 118 | return match (true) { | |
| 119 | str_contains($base, 'int') => 'number', | |
| 120 | in_array($base, ['numeric', 'decimal', 'float', 'double', 'double precision', 'real'], true) => 'number', | |
| 121 | in_array($base, ['bool', 'boolean'], true) => 'bool', | |
| 122 | in_array($base, ['date', 'time', 'timestamp', 'timestamptz', 'datetime'], true) => 'temporal', | |
| 123 | in_array($base, ['json', 'jsonb'], true) => 'json', | |
| 124 | in_array($base, ['blob', 'bytea', 'binary', 'varbinary'], true) => 'binary', | |
| 125 | default => 'string', | |
| 126 | }; | |
| 127 | } |
| 135 | protected function desiredTypeFamily(\BlueprintAU\Radiant\Database\Schema\Enums\ColumnType $desiredType): string | |
| 136 | { | |
| 137 | return match ($desiredType) { | |
| 138 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Int, | |
| 139 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::BigInt, | |
| 140 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Decimal, | |
| 141 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Float => 'number', | |
| 142 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Boolean => 'bool', | |
| 143 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Date, | |
| 144 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::DateTime, | |
| 145 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Timestamp => 'temporal', | |
| 146 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Json => 'json', | |
| 147 | \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType::Binary => 'binary', | |
| 148 | default => 'string', | |
| 149 | }; | |
| 150 | } |