Passed
Pull Request — master (#288)
by
unknown
03:50
created

Schema::findColumns()   D

Complexity

Conditions 18
Paths 24

Size

Total Lines 135
Code Lines 64

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 49
CRAP Score 18.0025

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 18
eloc 64
c 5
b 1
f 0
nc 24
nop 1
dl 0
loc 135
ccs 49
cts 50
cp 0.98
crap 18.0025
rs 4.8666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Pgsql;
6
7
use JsonException;
8
use Throwable;
9
use Yiisoft\Db\Constraint\CheckConstraint;
10
use Yiisoft\Db\Constraint\Constraint;
11
use Yiisoft\Db\Constraint\DefaultValueConstraint;
12
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
13
use Yiisoft\Db\Constraint\IndexConstraint;
14
use Yiisoft\Db\Driver\Pdo\AbstractPdoSchema;
15
use Yiisoft\Db\Exception\Exception;
16
use Yiisoft\Db\Exception\InvalidConfigException;
17
use Yiisoft\Db\Exception\NotSupportedException;
18
use Yiisoft\Db\Expression\Expression;
19
use Yiisoft\Db\Helper\DbArrayHelper;
20
use Yiisoft\Db\Schema\Builder\ColumnInterface;
21
use Yiisoft\Db\Schema\ColumnSchemaInterface;
22
use Yiisoft\Db\Schema\TableSchemaInterface;
23
24
use function array_merge;
25
use function array_unique;
26
use function array_values;
27
use function explode;
28
use function hex2bin;
29
use function is_string;
30
use function preg_match;
31
use function preg_replace;
32
use function str_replace;
33
use function str_starts_with;
34
use function substr;
35
36
/**
37
 * Implements the PostgreSQL Server specific schema, supporting PostgreSQL Server version 9.6 and above.
38
 *
39
 * @psalm-type ColumnArray = array{
40
 *   table_schema: string,
41
 *   table_name: string,
42
 *   column_name: string,
43
 *   data_type: string,
44
 *   type_type: string|null,
45
 *   type_scheme: string|null,
46
 *   character_maximum_length: int,
47
 *   column_comment: string|null,
48
 *   modifier: int,
49
 *   is_nullable: bool,
50
 *   column_default: string|null,
51
 *   is_autoinc: bool,
52
 *   sequence_name: string|null,
53
 *   enum_values: array<array-key, float|int|string>|string|null,
54
 *   numeric_precision: int|null,
55
 *   numeric_scale: int|null,
56
 *   size: string|null,
57
 *   is_pkey: bool|null,
58
 *   dimension: int
59
 * }
60
 * @psalm-type ConstraintArray = array<
61
 *   array-key,
62
 *   array {
63
 *     name: string,
64
 *     column_name: string,
65
 *     type: string,
66
 *     foreign_table_schema: string|null,
67
 *     foreign_table_name: string|null,
68
 *     foreign_column_name: string|null,
69
 *     on_update: string,
70
 *     on_delete: string,
71
 *     check_expr: string
72
 *   }
73
 * >
74
 * @psalm-type FindConstraintArray = array{
75
 *   constraint_name: string,
76
 *   column_name: string,
77
 *   foreign_table_name: string,
78
 *   foreign_table_schema: string,
79
 *   foreign_column_name: string,
80
 * }
81
 */
82
final class Schema extends AbstractPdoSchema
83
{
84
    /**
85
     * Define the abstract column type as `bit`.
86
     */
87
    public const TYPE_BIT = 'bit';
88
89
    /**
90
     * @var array The mapping from physical column types (keys) to abstract column types (values).
91
     *
92
     * @link https://www.postgresql.org/docs/current/static/datatype.html#DATATYPE-TABLE
93
     *
94
     * @psalm-var string[]
95
     */
96
    private array $typeMap = [
97
        'bit' => self::TYPE_BIT,
98
        'bit varying' => self::TYPE_BIT,
99
        'varbit' => self::TYPE_BIT,
100
        'bool' => self::TYPE_BOOLEAN,
101
        'boolean' => self::TYPE_BOOLEAN,
102
        'box' => self::TYPE_STRING,
103
        'circle' => self::TYPE_STRING,
104
        'point' => self::TYPE_STRING,
105
        'line' => self::TYPE_STRING,
106
        'lseg' => self::TYPE_STRING,
107
        'polygon' => self::TYPE_STRING,
108
        'path' => self::TYPE_STRING,
109
        'character' => self::TYPE_CHAR,
110
        'char' => self::TYPE_CHAR,
111
        'bpchar' => self::TYPE_CHAR,
112
        'character varying' => self::TYPE_STRING,
113
        'varchar' => self::TYPE_STRING,
114
        'text' => self::TYPE_TEXT,
115
        'bytea' => self::TYPE_BINARY,
116
        'cidr' => self::TYPE_STRING,
117
        'inet' => self::TYPE_STRING,
118
        'macaddr' => self::TYPE_STRING,
119
        'real' => self::TYPE_FLOAT,
120
        'float4' => self::TYPE_FLOAT,
121
        'double precision' => self::TYPE_DOUBLE,
122
        'float8' => self::TYPE_DOUBLE,
123
        'decimal' => self::TYPE_DECIMAL,
124
        'numeric' => self::TYPE_DECIMAL,
125
        'money' => self::TYPE_MONEY,
126
        'smallint' => self::TYPE_SMALLINT,
127
        'int2' => self::TYPE_SMALLINT,
128
        'int4' => self::TYPE_INTEGER,
129
        'int' => self::TYPE_INTEGER,
130
        'integer' => self::TYPE_INTEGER,
131
        'bigint' => self::TYPE_BIGINT,
132
        'int8' => self::TYPE_BIGINT,
133
        'oid' => self::TYPE_BIGINT, // shouldn't be used. it's pg internal!
134
        'smallserial' => self::TYPE_SMALLINT,
135
        'serial2' => self::TYPE_SMALLINT,
136
        'serial4' => self::TYPE_INTEGER,
137
        'serial' => self::TYPE_INTEGER,
138
        'bigserial' => self::TYPE_BIGINT,
139
        'serial8' => self::TYPE_BIGINT,
140
        'pg_lsn' => self::TYPE_BIGINT,
141
        'date' => self::TYPE_DATE,
142
        'interval' => self::TYPE_STRING,
143
        'time without time zone' => self::TYPE_TIME,
144
        'time' => self::TYPE_TIME,
145
        'time with time zone' => self::TYPE_TIME,
146
        'timetz' => self::TYPE_TIME,
147
        'timestamp without time zone' => self::TYPE_TIMESTAMP,
148
        'timestamp' => self::TYPE_TIMESTAMP,
149
        'timestamp with time zone' => self::TYPE_TIMESTAMP,
150
        'timestamptz' => self::TYPE_TIMESTAMP,
151
        'abstime' => self::TYPE_TIMESTAMP,
152
        'tsquery' => self::TYPE_STRING,
153
        'tsvector' => self::TYPE_STRING,
154
        'txid_snapshot' => self::TYPE_STRING,
155
        'unknown' => self::TYPE_STRING,
156
        'uuid' => self::TYPE_STRING,
157
        'json' => self::TYPE_JSON,
158
        'jsonb' => self::TYPE_JSON,
159
        'xml' => self::TYPE_STRING,
160
    ];
161
162
    /**
163
     * @var string|null The default schema used for the current session.
164
     */
165
    protected string|null $defaultSchema = 'public';
166
167
    /**
168
     * @var string|string[] Character used to quote schema, table, etc. names.
169
     *
170
     * An array of 2 characters can be used in case starting and ending characters are different.
171
     */
172
    protected string|array $tableQuoteCharacter = '"';
173
174 14
    public function createColumn(string $type, array|int|string $length = null): ColumnInterface
175
    {
176 14
        return new Column($type, $length);
177
    }
178
179
    /**
180
     * Resolves the table name and schema name (if any).
181
     *
182
     * @param string $name The table name.
183
     *
184
     * @return TableSchemaInterface With resolved table, schema, etc. names.
185
     *
186
     * @see TableSchemaInterface
187
     */
188 228
    protected function resolveTableName(string $name): TableSchemaInterface
189
    {
190 228
        $resolvedName = new TableSchema();
191
192 228
        $parts = array_reverse($this->db->getQuoter()->getTableNameParts($name));
193 228
        $resolvedName->name($parts[0] ?? '');
194 228
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
195
196 228
        $resolvedName->fullName(
197 228
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
198 228
            implode('.', array_reverse($parts)) : $resolvedName->getName()
199 228
        );
200
201 228
        return $resolvedName;
202
    }
203
204
    /**
205
     * Returns all schema names in the database, including the default one but not system schemas.
206
     *
207
     * This method should be overridden by child classes to support this feature because the default implementation
208
     * simply throws an exception.
209
     *
210
     * @throws Exception
211
     * @throws InvalidConfigException
212
     * @throws Throwable
213
     *
214
     * @return array All schemas name in the database, except system schemas.
215
     */
216 1
    protected function findSchemaNames(): array
217
    {
218 1
        $sql = <<<SQL
219
        SELECT "ns"."nspname"
220
        FROM "pg_namespace" AS "ns"
221
        WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
222
        ORDER BY "ns"."nspname" ASC
223 1
        SQL;
224
225 1
        return $this->db->createCommand($sql)->queryColumn();
226
    }
227
228
    /**
229
     * @throws Exception
230
     * @throws InvalidConfigException
231
     * @throws Throwable
232
     */
233 176
    protected function findTableComment(TableSchemaInterface $tableSchema): void
234
    {
235 176
        $sql = <<<SQL
236
        SELECT obj_description(pc.oid, 'pg_class')
237
        FROM pg_catalog.pg_class pc
238
        INNER JOIN pg_namespace pn ON pc.relnamespace = pn.oid
239
        WHERE
240
        pc.relname=:tableName AND
241
        pn.nspname=:schemaName
242 176
        SQL;
243
244 176
        $comment = $this->db->createCommand($sql, [
245 176
            ':schemaName' => $tableSchema->getSchemaName(),
246 176
            ':tableName' => $tableSchema->getName(),
247 176
        ])->queryScalar();
248
249 176
        $tableSchema->comment(is_string($comment) ? $comment : null);
250
    }
251
252
    /**
253
     * Returns all table names in the database.
254
     *
255
     * This method should be overridden by child classes to support this feature because the default implementation
256
     * simply throws an exception.
257
     *
258
     * @param string $schema The schema of the tables.
259
     * Defaults to empty string, meaning the current or default schema.
260
     *
261
     * @throws Exception
262
     * @throws InvalidConfigException
263
     * @throws Throwable
264
     *
265
     * @return array All tables name in the database. The names have NO schema name prefix.
266
     */
267 12
    protected function findTableNames(string $schema = ''): array
268
    {
269 12
        if ($schema === '') {
270 11
            $schema = $this->defaultSchema;
271
        }
272
273 12
        $sql = <<<SQL
274
        SELECT c.relname AS table_name
275
        FROM pg_class c
276
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
277
        WHERE ns.nspname = :schemaName AND c.relkind IN ('r','v','m','f', 'p')
278
        ORDER BY c.relname
279 12
        SQL;
280
281 12
        return $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
282
    }
283
284
    /**
285
     * Loads the metadata for the specified table.
286
     *
287
     * @param string $name The table name.
288
     *
289
     * @throws Exception
290
     * @throws InvalidConfigException
291
     * @throws Throwable
292
     *
293
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table doesn't exist.
294
     */
295 176
    protected function loadTableSchema(string $name): TableSchemaInterface|null
296
    {
297 176
        $table = $this->resolveTableName($name);
298 176
        $this->findTableComment($table);
299
300 176
        if ($this->findColumns($table)) {
301 154
            $this->findConstraints($table);
302 154
            return $table;
303
        }
304
305 42
        return null;
306
    }
307
308
    /**
309
     * Loads a primary key for the given table.
310
     *
311
     * @param string $tableName The table name.
312
     *
313
     * @throws Exception
314
     * @throws InvalidConfigException
315
     * @throws Throwable
316
     *
317
     * @return Constraint|null Primary key for the given table, `null` if the table has no primary key.
318
     */
319 40
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
320
    {
321 40
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
322
323 40
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
324
    }
325
326
    /**
327
     * Loads all foreign keys for the given table.
328
     *
329
     * @param string $tableName The table name.
330
     *
331
     * @throws Exception
332
     * @throws InvalidConfigException
333
     * @throws Throwable
334
     *
335
     * @return array Foreign keys for the given table.
336
     *
337
     * @psaml-return array|ForeignKeyConstraint[]
338
     */
339 8
    protected function loadTableForeignKeys(string $tableName): array
340
    {
341 8
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
342
343 8
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
344
    }
345
346
    /**
347
     * Loads all indexes for the given table.
348
     *
349
     * @param string $tableName The table name.
350
     *
351
     * @throws Exception
352
     * @throws InvalidConfigException
353
     * @throws Throwable
354
     *
355
     * @return IndexConstraint[] Indexes for the given table.
356
     */
357 38
    protected function loadTableIndexes(string $tableName): array
358
    {
359 38
        $sql = <<<SQL
360
        SELECT
361
            "ic"."relname" AS "name",
362
            "ia"."attname" AS "column_name",
363
            "i"."indisunique" AS "index_is_unique",
364
            "i"."indisprimary" AS "index_is_primary"
365
        FROM "pg_class" AS "tc"
366
        INNER JOIN "pg_namespace" AS "tcns"
367
            ON "tcns"."oid" = "tc"."relnamespace"
368
        INNER JOIN "pg_index" AS "i"
369
            ON "i"."indrelid" = "tc"."oid"
370
        INNER JOIN "pg_class" AS "ic"
371
            ON "ic"."oid" = "i"."indexrelid"
372
        INNER JOIN "pg_attribute" AS "ia"
373
            ON "ia"."attrelid" = "i"."indexrelid"
374
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
375
        ORDER BY "ia"."attnum" ASC
376 38
        SQL;
377
378 38
        $resolvedName = $this->resolveTableName($tableName);
379 38
        $indexes = $this->db->createCommand($sql, [
380 38
            ':schemaName' => $resolvedName->getSchemaName(),
381 38
            ':tableName' => $resolvedName->getName(),
382 38
        ])->queryAll();
383
384
        /** @psalm-var array[] $indexes */
385 38
        $indexes = $this->normalizeRowKeyCase($indexes, true);
386 38
        $indexes = DbArrayHelper::index($indexes, null, ['name']);
387 38
        $result = [];
388
389
        /**
390
         * @psalm-var object|string|null $name
391
         * @psalm-var array<
392
         *   array-key,
393
         *   array{
394
         *     name: string,
395
         *     column_name: string,
396
         *     index_is_unique: bool,
397
         *     index_is_primary: bool
398
         *   }
399
         * > $index
400
         */
401 38
        foreach ($indexes as $name => $index) {
402 35
            $ic = (new IndexConstraint())
403 35
                ->name($name)
404 35
                ->columnNames(DbArrayHelper::getColumn($index, 'column_name'))
405 35
                ->primary($index[0]['index_is_primary'])
406 35
                ->unique($index[0]['index_is_unique']);
407
408 35
            $result[] = $ic;
409
        }
410
411 38
        return $result;
412
    }
413
414
    /**
415
     * Loads all unique constraints for the given table.
416
     *
417
     * @param string $tableName The table name.
418
     *
419
     * @throws Exception
420
     * @throws InvalidConfigException
421
     * @throws Throwable
422
     *
423
     * @return array Unique constraints for the given table.
424
     *
425
     * @psalm-return array|Constraint[]
426
     */
427 17
    protected function loadTableUniques(string $tableName): array
428
    {
429 17
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
430
431 17
        return is_array($tableUniques) ? $tableUniques : [];
432
    }
433
434
    /**
435
     * Loads all check constraints for the given table.
436
     *
437
     * @param string $tableName The table name.
438
     *
439
     * @throws Exception
440
     * @throws InvalidConfigException
441
     * @throws Throwable
442
     *
443
     * @return array Check constraints for the given table.
444
     *
445
     * @psaml-return array|CheckConstraint[]
446
     */
447 17
    protected function loadTableChecks(string $tableName): array
448
    {
449 17
        $tableChecks = $this->loadTableConstraints($tableName, self::CHECKS);
450
451 17
        return is_array($tableChecks) ? $tableChecks : [];
452
    }
453
454
    /**
455
     * Loads all default value constraints for the given table.
456
     *
457
     * @param string $tableName The table name.
458
     *
459
     * @throws NotSupportedException
460
     *
461
     * @return DefaultValueConstraint[] Default value constraints for the given table.
462
     */
463 13
    protected function loadTableDefaultValues(string $tableName): array
464
    {
465 13
        throw new NotSupportedException(__METHOD__ . ' is not supported by PostgreSQL.');
466
    }
467
468
    /**
469
     * @throws Exception
470
     * @throws InvalidConfigException
471
     * @throws Throwable
472
     */
473 3
    protected function findViewNames(string $schema = ''): array
474
    {
475 3
        if ($schema === '') {
476 1
            $schema = $this->defaultSchema;
477
        }
478
479 3
        $sql = <<<SQL
480
        SELECT c.relname AS table_name
481
        FROM pg_class c
482
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
483
        WHERE ns.nspname = :schemaName AND (c.relkind = 'v' OR c.relkind = 'm')
484
        ORDER BY c.relname
485 3
        SQL;
486
487 3
        return $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
488
    }
489
490
    /**
491
     * Collects the foreign key column details for the given table.
492
     *
493
     * @param TableSchemaInterface $table The table metadata
494
     *
495
     * @throws Exception
496
     * @throws InvalidConfigException
497
     * @throws Throwable
498
     */
499 154
    protected function findConstraints(TableSchemaInterface $table): void
500
    {
501
        /**
502
         * We need to extract the constraints de hard way since:
503
         * {@see https://www.postgresql.org/message-id/[email protected]}
504
         */
505
506 154
        $sql = <<<SQL
507
        SELECT
508
            ct.conname as constraint_name,
509
            a.attname as column_name,
510
            fc.relname as foreign_table_name,
511
            fns.nspname as foreign_table_schema,
512
            fa.attname as foreign_column_name
513
            FROM
514
            (SELECT ct.conname, ct.conrelid, ct.confrelid, ct.conkey, ct.contype, ct.confkey,
515
                generate_subscripts(ct.conkey, 1) AS s
516
                FROM pg_constraint ct
517
            ) AS ct
518
            inner join pg_class c on c.oid=ct.conrelid
519
            inner join pg_namespace ns on c.relnamespace=ns.oid
520
            inner join pg_attribute a on a.attrelid=ct.conrelid and a.attnum = ct.conkey[ct.s]
521
            left join pg_class fc on fc.oid=ct.confrelid
522
            left join pg_namespace fns on fc.relnamespace=fns.oid
523
            left join pg_attribute fa on fa.attrelid=ct.confrelid and fa.attnum = ct.confkey[ct.s]
524
        WHERE
525
            ct.contype='f'
526
            and c.relname=:tableName
527
            and ns.nspname=:schemaName
528
        ORDER BY
529
            fns.nspname, fc.relname, a.attnum
530 154
        SQL;
531
532
        /** @psalm-var array{array{tableName: string, columns: array}} $constraints */
533 154
        $constraints = [];
534
535
        /** @psalm-var array<FindConstraintArray> $rows */
536 154
        $rows = $this->db->createCommand($sql, [
537 154
            ':schemaName' => $table->getSchemaName(),
538 154
            ':tableName' => $table->getName(),
539 154
        ])->queryAll();
540
541 154
        foreach ($rows as $constraint) {
542
            /** @psalm-var FindConstraintArray $constraint */
543 16
            $constraint = $this->normalizeRowKeyCase($constraint, false);
544
545 16
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
546 3
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
547
            } else {
548 16
                $foreignTable = $constraint['foreign_table_name'];
549
            }
550
551 16
            $name = $constraint['constraint_name'];
552
553 16
            if (!isset($constraints[$name])) {
554 16
                $constraints[$name] = [
555 16
                    'tableName' => $foreignTable,
556 16
                    'columns' => [],
557 16
                ];
558
            }
559
560 16
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
561
        }
562
563
        /**
564
         * @psalm-var int|string $foreingKeyName.
565
         * @psalm-var array{tableName: string, columns: array} $constraint
566
         */
567 154
        foreach ($constraints as $foreingKeyName => $constraint) {
568 16
            $table->foreignKey(
569 16
                (string) $foreingKeyName,
570 16
                array_merge([$constraint['tableName']], $constraint['columns'])
571 16
            );
572
        }
573
    }
574
575
    /**
576
     * Gets information about given table unique indexes.
577
     *
578
     * @param TableSchemaInterface $table The table metadata.
579
     *
580
     * @throws Exception
581
     * @throws InvalidConfigException
582
     * @throws Throwable
583
     *
584
     * @return array With index and column names.
585
     */
586 1
    protected function getUniqueIndexInformation(TableSchemaInterface $table): array
587
    {
588 1
        $sql = <<<'SQL'
589
        SELECT
590
            i.relname as indexname,
591
            pg_get_indexdef(idx.indexrelid, k + 1, TRUE) AS columnname
592
        FROM (
593
            SELECT *, generate_subscripts(indkey, 1) AS k
594
            FROM pg_index
595
        ) idx
596
        INNER JOIN pg_class i ON i.oid = idx.indexrelid
597
        INNER JOIN pg_class c ON c.oid = idx.indrelid
598
        INNER JOIN pg_namespace ns ON c.relnamespace = ns.oid
599
        WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
600
        AND c.relname = :tableName AND ns.nspname = :schemaName
601
        ORDER BY i.relname, k
602 1
        SQL;
603
604 1
        return $this->db->createCommand($sql, [
605 1
            ':schemaName' => $table->getSchemaName(),
606 1
            ':tableName' => $table->getName(),
607 1
        ])->queryAll();
608
    }
609
610
    /**
611
     * Returns all unique indexes for the given table.
612
     *
613
     * Each array element is of the following structure:
614
     *
615
     * ```php
616
     * [
617
     *     'IndexName1' => ['col1' [, ...]],
618
     *     'IndexName2' => ['col2' [, ...]],
619
     * ]
620
     * ```
621
     *
622
     * @param TableSchemaInterface $table The table metadata
623
     *
624
     * @throws Exception
625
     * @throws InvalidConfigException
626
     * @throws Throwable
627
     *
628
     * @return array All unique indexes for the given table.
629
     */
630 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
631
    {
632 1
        $uniqueIndexes = [];
633
634
        /** @psalm-var array{indexname: string, columnname: string} $row */
635 1
        foreach ($this->getUniqueIndexInformation($table) as $row) {
636
            /** @psalm-var array{indexname: string, columnname: string} $row */
637 1
            $row = $this->normalizeRowKeyCase($row, false);
638
639 1
            $column = $row['columnname'];
640
641 1
            if (str_starts_with($column, '"') && str_ends_with($column, '"')) {
642
                /**
643
                 * postgres will quote names that aren't lowercase-only.
644
                 *
645
                 * {@see https://github.com/yiisoft/yii2/issues/10613}
646
                 */
647 1
                $column = substr($column, 1, -1);
648
            }
649
650 1
            $uniqueIndexes[$row['indexname']][] = $column;
651
        }
652
653 1
        return $uniqueIndexes;
654
    }
655
656
    /**
657
     * Collects the metadata of table columns.
658
     *
659
     * @param TableSchemaInterface $table The table metadata.
660
     *
661
     * @throws Exception
662
     * @throws InvalidConfigException
663
     * @throws JsonException
664
     * @throws Throwable
665
     *
666
     * @return bool Whether the table exists in the database.
667
     */
668 176
    protected function findColumns(TableSchemaInterface $table): bool
669
    {
670 176
        $orIdentity = '';
671
672 176
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
673 169
            $orIdentity = 'OR a.attidentity != \'\'';
674
        }
675
676 176
        $sql = <<<SQL
677 176
        SELECT
678
            d.nspname AS table_schema,
679
            c.relname AS table_name,
680
            a.attname AS column_name,
681
            COALESCE(td.typname, tb.typname, t.typname) AS data_type,
682
            COALESCE(td.typtype, tb.typtype, t.typtype) AS type_type,
683
            (SELECT nspname FROM pg_namespace WHERE oid = COALESCE(td.typnamespace, tb.typnamespace, t.typnamespace)) AS type_scheme,
684
            a.attlen AS character_maximum_length,
685
            pg_catalog.col_description(c.oid, a.attnum) AS column_comment,
686
            information_schema._pg_truetypmod(a, t) AS modifier,
687
            NOT (a.attnotnull OR t.typnotnull) AS is_nullable,
688
            COALESCE(t.typdefault, pg_get_expr(ad.adbin, ad.adrelid)) AS column_default,
689 176
            COALESCE(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval', false) $orIdentity AS is_autoinc,
690
            pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname)
691
            AS sequence_name,
692
            CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
693
                THEN array_to_string(
694
                    (
695
                        SELECT array_agg(enumlabel)
696
                        FROM pg_enum
697
                        WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid)
698
                    )::varchar[],
699
                ',')
700
                ELSE NULL
701
            END AS enum_values,
702
            information_schema._pg_numeric_precision(
703
                COALESCE(td.oid, tb.oid, a.atttypid),
704
                information_schema._pg_truetypmod(a, t)
705
            ) AS numeric_precision,
706
            information_schema._pg_numeric_scale(
707
                COALESCE(td.oid, tb.oid, a.atttypid),
708
                information_schema._pg_truetypmod(a, t)
709
            ) AS numeric_scale,
710
            information_schema._pg_char_max_length(
711
                COALESCE(td.oid, tb.oid, a.atttypid),
712
                information_schema._pg_truetypmod(a, t)
713
            ) AS size,
714
            a.attnum = any (ct.conkey) as is_pkey,
715
            COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
716
        FROM
717
            pg_class c
718
            LEFT JOIN pg_attribute a ON a.attrelid = c.oid
719
            LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
720
            LEFT JOIN pg_type t ON a.atttypid = t.oid
721
            LEFT JOIN pg_type tb ON (a.attndims > 0 OR t.typcategory='A') AND t.typelem > 0 AND t.typelem = tb.oid
722
                                        OR t.typbasetype > 0 AND t.typbasetype = tb.oid
723
            LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
724
            LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
725
            LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p'
726
        WHERE
727
            a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
728
            AND c.relname = :tableName
729
            AND d.nspname = :schemaName
730
        ORDER BY
731
            a.attnum;
732 176
        SQL;
733
734 176
        $columns = $this->db->createCommand($sql, [
735 176
            ':schemaName' => $table->getSchemaName(),
736 176
            ':tableName' => $table->getName(),
737 176
        ])->queryAll();
738
739 176
        if (empty($columns)) {
740 42
            return false;
741
        }
742
743
        /** @psalm-var ColumnArray $info */
744 154
        foreach ($columns as $info) {
745
            /** @psalm-var ColumnArray $info */
746 154
            $info = $this->normalizeRowKeyCase($info, false);
747
748
            /** @psalm-var ColumnSchema $column */
749 154
            $column = $this->loadColumnSchema($info);
750
751 154
            $table->column($column->getName(), $column);
752
753 154
            if ($column->isPrimaryKey()) {
754 96
                $table->primaryKey($column->getName());
755
756 96
                if ($table->getSequenceName() === null) {
757 96
                    $table->sequenceName($column->getSequenceName());
758
                }
759
            }
760
        }
761
762 154
        return true;
763
    }
764
765
    /**
766
     * Loads the column information into a {@see ColumnSchemaInterface} object.
767
     *
768
     * @psalm-param ColumnArray $info Column information.
769
     *
770
     * @return ColumnSchemaInterface The column schema object.
771
     */
772 154
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
773
    {
774 154
        $column = $this->createColumnSchema($info['column_name']);
775 154
        $column->allowNull($info['is_nullable']);
776 154
        $column->autoIncrement($info['is_autoinc']);
777 154
        $column->comment($info['column_comment']);
778
779 154
        if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)) {
780 1
            $column->dbType($info['type_scheme'] . '.' . $info['data_type']);
781
        } else {
782 154
            $column->dbType($info['data_type']);
783
        }
784
785 154
        $column->enumValues($info['enum_values'] !== null
786 1
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values']))
787 154
            : null);
788 154
        $column->unsigned(false); // has no meaning in PG
789 154
        $column->primaryKey((bool) $info['is_pkey']);
790 154
        $column->precision($info['numeric_precision']);
791 154
        $column->scale($info['numeric_scale']);
792 154
        $column->size($info['size'] === null ? null : (int) $info['size']);
793 154
        $column->dimension($info['dimension']);
794
795
        /**
796
         * pg_get_serial_sequence() doesn't track DEFAULT value change.
797
         * GENERATED BY IDENTITY columns always have a null default value.
798
         */
799 154
        $defaultValue = $info['column_default'];
800
801
        if (
802 154
            $defaultValue !== null
803 154
            && preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
804
        ) {
805 80
            $column->sequenceName(preg_replace(
806 80
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
807 80
                '',
808 80
                $defaultValue
809 80
            ));
810 154
        } elseif ($info['sequence_name'] !== null) {
811 5
            $column->sequenceName($this->resolveTableName($info['sequence_name'])->getFullName());
812
        }
813
814 154
        $column->type($this->typeMap[(string) $column->getDbType()] ?? self::TYPE_STRING);
815 154
        $column->phpType($this->getColumnPhpType($column));
816 154
        $column->defaultValue($this->normalizeDefaultValue($defaultValue, $column));
817
818 154
        return $column;
819
    }
820
821
    /**
822
     * Extracts the PHP type from an abstract DB type.
823
     *
824
     * @param ColumnSchemaInterface $column The column schema information.
825
     *
826
     * @return string The PHP type name.
827
     */
828 154
    protected function getColumnPhpType(ColumnSchemaInterface $column): string
829
    {
830 154
        if ($column->getType() === self::TYPE_BIT) {
831 32
            return self::PHP_TYPE_INTEGER;
832
        }
833
834 154
        return parent::getColumnPhpType($column);
835
    }
836
837
    /**
838
     * Converts column's default value according to {@see ColumnSchema::phpType} after retrieval from the database.
839
     *
840
     * @param string|null $defaultValue The default value retrieved from the database.
841
     * @param ColumnSchemaInterface $column The column schema object.
842
     *
843
     * @return mixed The normalized default value.
844
     *
845
     * @psalm-suppress PossiblyNullArgument
846
     */
847 154
    private function normalizeDefaultValue(?string $defaultValue, ColumnSchemaInterface $column): mixed
848
    {
849 154
        return match (true) {
850 154
            $defaultValue === null,
851 154
            $column->isPrimaryKey()
852 154
                => null,
853
            /** @psalm-var string $defaultValue */
854 154
            in_array($column->getType(), [
855 154
                self::TYPE_TIMESTAMP,
856 154
                self::TYPE_DATE,
857 154
                self::TYPE_TIME,
858 154
            ], true)
859 154
                && in_array(strtoupper($defaultValue), [
860 154
                    'NOW()',
861 154
                    'CURRENT_TIMESTAMP',
862 154
                    'CURRENT_DATE',
863 154
                    'CURRENT_TIME',
864 154
                ], true)
865 154
                    => new Expression($defaultValue),
866 154
            preg_match("/^B?'(.*?)'::/", $defaultValue, $matches) === 1
867 154
                => $column->getType() === self::TYPE_BINARY
868 154
                    && str_starts_with($matches[1], '\\x')
869 32
                        ? hex2bin(substr($matches[1], 2))
870 38
                        : $column->phpTypecast($matches[1]),
871 154
            preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches) === 1
872 154
                => $matches[2] !== 'NULL'
873 84
                    ? $column->phpTypecast($matches[2])
874 85
                    : null,
875 154
            default
876 154
            => $column->phpTypecast($defaultValue),
877 154
        };
878
    }
879
880
    /**
881
     * Loads multiple types of constraints and returns the specified ones.
882
     *
883
     * @param string $tableName The table name.
884
     * @param string $returnType The return type:
885
     * - primaryKey
886
     * - foreignKeys
887
     * - uniques
888
     * - checks
889
     *
890
     * @throws Exception
891
     * @throws InvalidConfigException
892
     * @throws Throwable
893
     *
894
     * @return array|Constraint|null Constraints.
895
     *
896
     * @psalm-return CheckConstraint[]|Constraint[]|ForeignKeyConstraint[]|Constraint|null
897
     */
898 82
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
899
    {
900 82
        $sql = <<<SQL
901
        SELECT
902
            "c"."conname" AS "name",
903
            "a"."attname" AS "column_name",
904
            "c"."contype" AS "type",
905
            "ftcns"."nspname" AS "foreign_table_schema",
906
            "ftc"."relname" AS "foreign_table_name",
907
            "fa"."attname" AS "foreign_column_name",
908
            "c"."confupdtype" AS "on_update",
909
            "c"."confdeltype" AS "on_delete",
910
            pg_get_constraintdef("c"."oid") AS "check_expr"
911
        FROM "pg_class" AS "tc"
912
        INNER JOIN "pg_namespace" AS "tcns"
913
            ON "tcns"."oid" = "tc"."relnamespace"
914
        INNER JOIN "pg_constraint" AS "c"
915
            ON "c"."conrelid" = "tc"."oid"
916
        INNER JOIN "pg_attribute" AS "a"
917
            ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
918
        LEFT JOIN "pg_class" AS "ftc"
919
            ON "ftc"."oid" = "c"."confrelid"
920
        LEFT JOIN "pg_namespace" AS "ftcns"
921
            ON "ftcns"."oid" = "ftc"."relnamespace"
922
        LEFT JOIN "pg_attribute" "fa"
923
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
924
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
925
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
926 82
        SQL;
927
928
        /** @psalm-var string[] $actionTypes */
929 82
        $actionTypes = [
930 82
            'a' => 'NO ACTION',
931 82
            'r' => 'RESTRICT',
932 82
            'c' => 'CASCADE',
933 82
            'n' => 'SET NULL',
934 82
            'd' => 'SET DEFAULT',
935 82
        ];
936
937 82
        $resolvedName = $this->resolveTableName($tableName);
938 82
        $constraints = $this->db->createCommand($sql, [
939 82
            ':schemaName' => $resolvedName->getSchemaName(),
940 82
            ':tableName' => $resolvedName->getName(),
941 82
        ])->queryAll();
942
943
        /** @psalm-var array[][] $constraints */
944 82
        $constraints = $this->normalizeRowKeyCase($constraints, true);
945 82
        $constraints = DbArrayHelper::index($constraints, null, ['type', 'name']);
946
947 82
        $result = [
948 82
            self::PRIMARY_KEY => null,
949 82
            self::FOREIGN_KEYS => [],
950 82
            self::UNIQUES => [],
951 82
            self::CHECKS => [],
952 82
        ];
953
954
        /**
955
         * @psalm-var string $type
956
         * @psalm-var array $names
957
         */
958 82
        foreach ($constraints as $type => $names) {
959
            /**
960
             * @psalm-var object|string|null $name
961
             * @psalm-var ConstraintArray $constraint
962
             */
963 82
            foreach ($names as $name => $constraint) {
964
                switch ($type) {
965 82
                    case 'p':
966 57
                        $result[self::PRIMARY_KEY] = (new Constraint())
967 57
                            ->name($name)
968 57
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
969 57
                        break;
970 74
                    case 'f':
971 19
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
972 19
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
973
974 19
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
975 19
                            ->name($name)
976 19
                            ->columnNames(array_values(
977 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'column_name'))
978 19
                            ))
979 19
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
980 19
                            ->foreignTableName($constraint[0]['foreign_table_name'])
981 19
                            ->foreignColumnNames(array_values(
982 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'foreign_column_name'))
983 19
                            ))
984 19
                            ->onDelete($onDelete)
985 19
                            ->onUpdate($onUpdate);
986 19
                        break;
987 61
                    case 'u':
988 58
                        $result[self::UNIQUES][] = (new Constraint())
989 58
                            ->name($name)
990 58
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
991 58
                        break;
992 15
                    case 'c':
993 15
                        $result[self::CHECKS][] = (new CheckConstraint())
994 15
                            ->name($name)
995 15
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
996 15
                            ->expression($constraint[0]['check_expr']);
997 15
                        break;
998
                }
999
            }
1000
        }
1001
1002 82
        foreach ($result as $type => $data) {
1003 82
            $this->setTableMetadata($tableName, $type, $data);
1004
        }
1005
1006 82
        return $result[$returnType];
1007
    }
1008
1009
    /**
1010
     * Creates a column schema for the database.
1011
     *
1012
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1013
     *
1014
     * @param string $name Name of the column.
1015
     *
1016
     * @return ColumnSchema
1017
     */
1018 154
    private function createColumnSchema(string $name): ColumnSchema
1019
    {
1020 154
        return new ColumnSchema($name);
1021
    }
1022
1023
    /**
1024
     * Returns the cache key for the specified table name.
1025
     *
1026
     * @param string $name The table name.
1027
     *
1028
     * @return array The cache key.
1029
     */
1030 267
    protected function getCacheKey(string $name): array
1031
    {
1032 267
        return array_merge([self::class], $this->generateCacheKey(), [$this->getRawTableName($name)]);
1033
    }
1034
1035
    /**
1036
     * Returns the cache tag name.
1037
     *
1038
     * This allows {@see refresh()} to invalidate all cached table schemas.
1039
     *
1040
     * @return string The cache tag name.
1041
     */
1042 229
    protected function getCacheTag(): string
1043
    {
1044 229
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
1045
    }
1046
}
1047