Lines 98.27% 171 / 174
Functions and Methods 76.92% 10 / 13
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
PostgresSchemaInspector 98.27% 171 / 174 76.92% 10 / 13 44 0.00% 0 / 1
 getDefaultSchemaGrammar 100.00% 1 / 1 100.00% 1 / 1 1
 columnTypeMatches 94.44% 17 / 18 0.00% 0 / 1 13.03
 castSafety 100.00% 7 / 7 100.00% 1 / 1 5
 composeType 100.00% 6 / 6 100.00% 1 / 1 5
 referencingTables 100.00% 13 / 13 100.00% 1 / 1 2
 tables 100.00% 10 / 10 100.00% 1 / 1 2
 table 100.00% 8 / 8 100.00% 1 / 1 2
 columns 100.00% 28 / 28 100.00% 1 / 1 2
 indexes 100.00% 24 / 24 100.00% 1 / 1 3
 parseIndexColumns 87.50% 7 / 8 0.00% 0 / 1 2.01
 parseIndexWhere 75.00% 3 / 4 0.00% 0 / 1 2.06
 foreignKeys 100.00% 45 / 45 100.00% 1 / 1 3
 normalizeAction 100.00% 2 / 2 100.00% 1 / 1 2
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Schema\Inspectors;
6
7/**
8 * Reads the live schema on Postgres — `information_schema` + `pg_catalog`.
9 *
10 * @extends SchemaInspector<\BlueprintAU\Radiant\Database\Schema\Grammars\PostgresSchemaGrammar>
11 */
12final class PostgresSchemaInspector extends SchemaInspector
13{
14    /**
15     * The dialect's schema grammar (the factory hook).
16     *
17     * @return \BlueprintAU\Radiant\Database\Schema\Grammars\PostgresSchemaGrammar
18     */
19    protected function getDefaultSchemaGrammar(): \BlueprintAU\Radiant\Database\Schema\Grammars\SchemaGrammar
20    {
21        return new \BlueprintAU\Radiant\Database\Schema\Grammars\PostgresSchemaGrammar();
22    }
23
24    /**
25     * Whether a live column's native type text matches the declared
26     * logical type — the Postgres mapping.
27     *
28     * @param  string  $liveType
29     * @param  \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType  $declaredType
30     * @param  int|null  $declaredLength
31     * @param  int|null  $declaredPrecision
32     * @param  int|null  $declaredScale
33     * @return bool
34     */
35    public function columnTypeMatches(string $liveType, \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType $declaredType, int|null $declaredLength, int|null $declaredPrecision = null, int|null $declaredScale = null): bool
36    {
37        // Split a size suffix off, normalize the udt base ('int4' →
38        // 'integer', 'bpchar' → 'char', …), then re-attach the size so the
39        // composed text compares against the grammar's rendering verbatim.
40        $base = strtolower($liveType);
41        $suffix = '';
42        if (preg_match('/^(.+?)\((\d+(?:,\d+)?)\)$/', $base, $matches) === 1) {
43            $base = $matches[1];
44            $suffix = '(' . $matches[2] . ')';
45        }
46
47        $normalized = match ($base) {
48            'int4' => 'integer',
49            'int8' => 'bigint',
50            'float8' => 'double precision',
51            'bool' => 'boolean',
52            'timestamp' => 'timestamp',
53            'timestamptz' => 'timestamp',
54            'json' => 'jsonb',
55            'bytea' => 'bytea',
56            'uuid' => 'uuid',
57            'bpchar' => 'char',
58            default => $base,
59        };
60
61        return $normalized . $suffix === strtolower($this->schemaGrammar->type($declaredType, $declaredLength, $declaredPrecision, $declaredScale));
62    }
63
64    /**
65     * Postgres' strict modify-cast classification — it rejects casts no
66     * USING clause can perform (jsonb/bytea/timestamp/bool to a number)
67     * and treats text-to-number/date as a data-dependent Risky cast.
68     *
69     * @param  string  $liveType
70     * @param  \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType  $desiredType
71     * @return \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety
72     */
73    #[\Override]
74    public function castSafety(string $liveType, \BlueprintAU\Radiant\Database\Schema\Enums\ColumnType $desiredType): \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety
75    {
76        $from = $this->liveTypeFamily($liveType);
77        $to = $this->desiredTypeFamily($desiredType);
78
79        // A structured/temporal/bool source has no meaningful numeric cast.
80        if (in_array($from, ['json', 'binary', 'temporal', 'bool'], true) && $to === 'number') {
81            return \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety::Uncastable;
82        }
83
84        // Text to a number or a date parses each value — it can fail.
85        if ($from === 'string' && in_array($to, ['number', 'temporal'], true)) {
86            return \BlueprintAU\Radiant\Database\Schema\Enums\CastSafety::Risky;
87        }
88
89        return parent::castSafety($liveType, $desiredType);
90    }
91
92    /**
93     * Compose a column's type text from its udt name plus its information_
94     * schema size — the udt name alone never carries one.
95     *
96     * Only the types whose grammar rendering includes a size get one:
97     * character types from character_maximum_length, numeric from its
98     * precision/scale (absent when the numeric is unconstrained).
99     *
100     * @param  array<string, mixed>  $row
101     * @return string
102     */
103    private function composeType(array $row): string
104    {
105        $type = strtolower((string) $row['udt_name']);
106
107        if (in_array($type, ['varchar', 'bpchar'], true) && $row['character_maximum_length'] !== null) {
108            return $type . '(' . (int) $row['character_maximum_length'] . ')';
109        }
110
111        if ($type === 'numeric' && $row['numeric_precision'] !== null) {
112            return $type . '(' . (int) $row['numeric_precision'] . ',' . (int) $row['numeric_scale'] . ')';
113        }
114
115        return $type;
116    }
117
118    /**
119     * The live tables that declare a foreign key into the given table.
120     *
121     * @param  string  $table
122     * @return list<string>
123     */
124    public function referencingTables(string $table): array
125    {
126        $statement = $this->pdo->prepare(
127            "SELECT DISTINCT relname FROM pg_catalog.pg_constraint c "
128            . "JOIN pg_catalog.pg_class r ON r.oid = c.conrelid "
129            . "JOIN pg_catalog.pg_namespace n ON n.oid = r.relnamespace "
130            . "WHERE c.contype = 'f' AND c.confrelid = (SELECT oid FROM pg_catalog.pg_class "
131            . "WHERE relname = ? AND relnamespace = (SELECT oid FROM pg_catalog.pg_namespace "
132            . "WHERE nspname = current_schema())) AND n.nspname = current_schema()",
133        );
134        $statement->execute([$table]);
135
136        $tables = [];
137        foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $name) {
138            $tables[] = (string) $name;
139        }
140
141        return $tables;
142    }
143    /**
144     * Every table name in the live schema (the connection's search_path).
145     *
146     * @return list<string>
147     */
148    public function tables(): array
149    {
150        $statement = $this->pdo->prepare(
151            "SELECT tablename FROM pg_catalog.pg_tables "
152            . "WHERE schemaname NOT IN ('pg_catalog', 'information_schema') "
153            . 'ORDER BY tablename',
154        );
155        $statement->execute();
156
157        // Build by append: the append target is inferred as list<string>,
158        // which keeps the return type exact no matter how the underlying
159        // PHP version types fetchAll(PDO::FETCH_COLUMN).
160        $tables = [];
161        foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $name) {
162            $tables[] = (string) $name;
163        }
164
165        return $tables;
166    }
167
168    /**
169     * One table's live schema.
170     *
171     * @param  string  $name
172     * @return LiveTable
173     * @throws \RuntimeException
174     */
175    public function table(string $name): LiveTable
176    {
177        if (!$this->hasTable($name)) {
178            throw new \RuntimeException("Table [{$name}] does not exist in the Postgres schema.");
179        }
180
181        return new LiveTable(
182            $name,
183            $this->columns($name),
184            $this->indexes($name),
185            $this->foreignKeys($name),
186        );
187    }
188
189    /**
190     * The live columns, from `information_schema.columns`.
191     *
192     * @param  string  $name
193     * @return list<array{name: string, type: string, nullable: bool, default: mixed, primaryKey: bool}>
194     */
195    private function columns(string $name): array
196    {
197        $statement = $this->pdo->prepare(
198            'SELECT c.column_name, c.udt_name, c.character_maximum_length, '
199            . 'c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default, '
200            . 'EXISTS ('
201            . '  SELECT 1 FROM information_schema.table_constraints tc '
202            . '  JOIN information_schema.key_column_usage kcu '
203            . '    ON kcu.constraint_name = tc.constraint_name '
204            . '   AND kcu.table_schema = tc.table_schema '
205            . '  WHERE tc.table_schema = c.table_schema '
206            . '    AND tc.table_name = c.table_name '
207            . '    AND tc.constraint_type = \'PRIMARY KEY\' '
208            . '    AND kcu.column_name = c.column_name'
209            . ') AS is_primary '
210            . 'FROM information_schema.columns c '
211            . 'WHERE c.table_schema = current_schema() AND c.table_name = ? '
212            . 'ORDER BY c.ordinal_position',
213        );
214        $statement->execute([$name]);
215
216        $columns = [];
217
218        /** @var array<string, mixed> $row */
219        foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) {
220            $columns[] = [
221                'name' => (string) $row['column_name'],
222                // The udt name ('int4', 'timestamptz') is stable and
223                // comparable across Postgres versions, but it never carries
224                // a size — compose one from information_schema so the text
225                // matches the grammar's rendering ('varchar(100)',
226                // 'numeric(8,2)') and length drift stays detectable.
227                'type' => $this->composeType($row),
228                'nullable' => strtoupper((string) $row['is_nullable']) === 'YES',
229                // Serial/identity columns report nextval(...) — keep the
230                // text; the differ normalizes auto-increment separately.
231                'default' => $row['column_default'],
232                'primaryKey' => ((int) $row['is_primary']) === 1,
233            ];
234        }
235
236        return $columns;
237    }
238
239    /**
240     * The live indexes, from `pg_indexes` (excluding PK-constraint indexes).
241     *
242     * @param  string  $name
243     * @return list<array{name: string|null, columns: list<string>, unique: bool, where: string|null, nullsNotDistinct: bool}>
244     */
245    private function indexes(string $name): array
246    {
247        $statement = $this->pdo->prepare(
248            'SELECT i.relname AS index_name, ix.indisunique, ix.indisprimary, '
249            . 'pg_get_indexdef(ix.indexrelid) AS indexdef '
250            . 'FROM pg_class t '
251            . 'JOIN pg_namespace n ON n.oid = t.relnamespace '
252            . 'JOIN pg_index ix ON ix.indrelid = t.oid '
253            . 'JOIN pg_class i ON i.oid = ix.indexrelid '
254            . 'WHERE n.nspname = current_schema() AND t.relname = ? '
255            . 'ORDER BY i.relname',
256        );
257        $statement->execute([$name]);
258
259        $indexes = [];
260
261        /** @var array<string, mixed> $row */
262        foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) {
263            if (((int) $row['indisprimary']) === 1) {
264                continue; // PK rides the columns' primaryKey flag.
265            }
266
267            $indexdef = (string) $row['indexdef'];
268
269            $indexes[] = [
270                'name' => (string) $row['index_name'],
271                // Parse the column list out of the index definition —
272                // pg_get_indexdef renders "CREATE [UNIQUE] INDEX name ON
273                // table USING btree (col1, col2)".
274                'columns' => $this->parseIndexColumns($indexdef),
275                'unique' => ((int) $row['indisunique']) === 1,
276                // pg_get_indexdef normalizes the predicate but preserves
277                // its semantics — the differ compares it against the
278                // declared text only when both are in sync.
279                'where' => $this->parseIndexWhere($indexdef),
280                'nullsNotDistinct' => str_contains($indexdef, 'NULLS NOT DISTINCT'),
281            ];
282        }
283
284        return $indexes;
285    }
286
287    /**
288     * Extract the column list from a `pg_get_indexdef` definition.
289     *
290     * @param  string  $indexdef
291     * @return list<string>
292     */
293    private function parseIndexColumns(string $indexdef): array
294    {
295        $paren = strrpos($indexdef, '(');
296
297        if ($paren === false) {
298            return [];
299        }
300
301        $inner = rtrim(substr($indexdef, $paren + 1), ') ');
302
303        return array_map(
304            fn (string $column) => trim(trim($column), '"'),
305            explode(',', $inner),
306        );
307    }
308
309    /**
310     * Extract the partial-index predicate from a `pg_get_indexdef`
311     * definition.
312     *
313     * @param  string  $indexdef
314     * @return string|null
315     */
316    private function parseIndexWhere(string $indexdef): ?string
317    {
318        $where = strripos($indexdef, ' WHERE ');
319
320        if ($where === false) {
321            return null;
322        }
323
324        return trim(substr($indexdef, $where + 7));
325    }
326
327    /**
328     * The live foreign keys, from `information_schema` constraint views
329     * (deferrability from `pg_constraint`).
330     *
331     * @param  string  $name
332     * @return list<array{columns: list<string>, referencesTable: string, referencesColumns: list<string>, onDelete: string|null, onUpdate: string|null, deferrable: bool, name: string}>
333     */
334    private function foreignKeys(string $name): array
335    {
336        $statement = $this->pdo->prepare(
337            'SELECT tc.constraint_name, kcu.column_name, ccu.table_name AS referenced_table, '
338            . 'ccu.column_name AS referenced_column, kcu.ordinal_position, '
339            . 'rc.delete_rule, rc.update_rule, pc.condeferrable '
340            . 'FROM information_schema.table_constraints tc '
341            . 'JOIN information_schema.key_column_usage kcu '
342            . '  ON kcu.constraint_name = tc.constraint_name '
343            . ' AND kcu.table_schema = tc.table_schema '
344            . 'JOIN information_schema.constraint_column_usage ccu '
345            . '  ON ccu.constraint_name = tc.constraint_name '
346            . ' AND ccu.table_schema = tc.table_schema '
347            . 'JOIN information_schema.referential_constraints rc '
348            . '  ON rc.constraint_name = tc.constraint_name '
349            . ' AND rc.constraint_schema = tc.constraint_schema '
350            . 'JOIN pg_catalog.pg_constraint pc '
351            . '  ON pc.conname = tc.constraint_name '
352            . ' AND pc.connamespace = (SELECT oid FROM pg_catalog.pg_namespace '
353            . '     WHERE nspname = current_schema()) '
354            . 'WHERE tc.table_schema = current_schema() '
355            . 'AND tc.table_name = ? AND tc.constraint_type = \'FOREIGN KEY\' '
356            . 'ORDER BY tc.constraint_name, kcu.ordinal_position',
357        );
358        $statement->execute([$name]);
359
360        /** @var list<array<string, mixed>> $rows */
361        $rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
362
363        $groups = [];
364
365        foreach ($rows as $row) {
366            $constraintName = (string) $row['constraint_name'];
367            $groups[$constraintName]['columns'][] = (string) $row['column_name'];
368            $groups[$constraintName]['referencesTable'] = (string) $row['referenced_table'];
369            $groups[$constraintName]['referencesColumns'][(int) $row['ordinal_position']] = (string) $row['referenced_column'];
370            $groups[$constraintName]['onDelete'] = $row['delete_rule'];
371            $groups[$constraintName]['onUpdate'] = $row['update_rule'];
372            $groups[$constraintName]['deferrable'] = $row['condeferrable'];
373        }
374
375        $constraints = [];
376
377        foreach ($groups as $constraintName => $group) {
378            $constraints[] = [
379                'columns' => $group['columns'],
380                'referencesTable' => $group['referencesTable'],
381                'referencesColumns' => array_values($group['referencesColumns']),
382                'onDelete' => $this->normalizeAction($group['onDelete']),
383                'onUpdate' => $this->normalizeAction($group['onUpdate']),
384                'deferrable' => ((int) $group['deferrable']) === 1,
385                // The live constraint name — the drop handle.
386                'name' => $constraintName,
387            ];
388        }
389
390        return $constraints;
391    }
392
393    /**
394     * Normalize Postgres' referential-action text to a canonical value.
395     *
396     * @param  mixed  $action
397     * @return string|null
398     */
399    private function normalizeAction(mixed $action): ?string
400    {
401        $normalized = strtoupper(trim((string) $action));
402
403        return $normalized === 'NO ACTION' ? null : $normalized;
404    }
405}