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 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace 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 | */ | |
| 12 | final 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 | } |