Passed
Push — master ( e96104...60e64e )
by Def
04:02 queued 24s
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 230
    protected function resolveTableName(string $name): TableSchemaInterface
189
    {
190 230
        $resolvedName = new TableSchema();
191
192 230
        $parts = array_reverse($this->db->getQuoter()->getTableNameParts($name));
193 230
        $resolvedName->name($parts[0] ?? '');
194 230
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
195
196 230
        $resolvedName->fullName(
197 230
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
198 230
            implode('.', array_reverse($parts)) : $resolvedName->getName()
199 230
        );
200
201 230
        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 178
    protected function findTableComment(TableSchemaInterface $tableSchema): void
234
    {
235 178
        $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 178
        SQL;
243
244 178
        $comment = $this->db->createCommand($sql, [
245 178
            ':schemaName' => $tableSchema->getSchemaName(),
246 178
            ':tableName' => $tableSchema->getName(),
247 178
        ])->queryScalar();
248
249 178
        $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 178
    protected function loadTableSchema(string $name): TableSchemaInterface|null
296
    {
297 178
        $table = $this->resolveTableName($name);
298 178
        $this->findTableComment($table);
299
300 178
        if ($this->findColumns($table)) {
301 156
            $this->findConstraints($table);
302 156
            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 156
    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 156
        $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 156
        SQL;
531
532
        /** @psalm-var array{array{tableName: string, columns: array}} $constraints */
533 156
        $constraints = [];
534
535
        /** @psalm-var array<FindConstraintArray> $rows */
536 156
        $rows = $this->db->createCommand($sql, [
537 156
            ':schemaName' => $table->getSchemaName(),
538 156
            ':tableName' => $table->getName(),
539 156
        ])->queryAll();
540
541 156
        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 156
        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 178
    protected function findColumns(TableSchemaInterface $table): bool
669
    {
670 178
        $orIdentity = '';
671
672 178
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
673 171
            $orIdentity = 'OR a.attidentity != \'\'';
674
        }
675
676 178
        $sql = <<<SQL
677 178
        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 178
            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 178
        SQL;
733
734 178
        $columns = $this->db->createCommand($sql, [
735 178
            ':schemaName' => $table->getSchemaName(),
736 178
            ':tableName' => $table->getName(),
737 178
        ])->queryAll();
738
739 178
        if (empty($columns)) {
740 42
            return false;
741
        }
742
743
        /** @psalm-var ColumnArray $info */
744 156
        foreach ($columns as $info) {
745
            /** @psalm-var ColumnArray $info */
746 156
            $info = $this->normalizeRowKeyCase($info, false);
747
748
            /** @psalm-var ColumnSchema $column */
749 156
            $column = $this->loadColumnSchema($info);
750
751 156
            $table->column($column->getName(), $column);
752
753 156
            if ($column->isPrimaryKey()) {
754 97
                $table->primaryKey($column->getName());
755
756 97
                if ($table->getSequenceName() === null) {
757 97
                    $table->sequenceName($column->getSequenceName());
758
                }
759
            }
760
        }
761
762 156
        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 156
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
773
    {
774 156
        $column = $this->createColumnSchema($info['column_name']);
775 156
        $column->allowNull($info['is_nullable']);
776 156
        $column->autoIncrement($info['is_autoinc']);
777 156
        $column->comment($info['column_comment']);
778
779 156
        if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)) {
780 1
            $column->dbType($info['type_scheme'] . '.' . $info['data_type']);
781
        } else {
782 156
            $column->dbType($info['data_type']);
783
        }
784
785 156
        $column->enumValues($info['enum_values'] !== null
786 1
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values']))
787 156
            : null);
788 156
        $column->unsigned(false); // has no meaning in PG
789 156
        $column->primaryKey((bool) $info['is_pkey']);
790 156
        $column->precision($info['numeric_precision']);
791 156
        $column->scale($info['numeric_scale']);
792 156
        $column->size($info['size'] === null ? null : (int) $info['size']);
793 156
        $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 156
        $defaultValue = $info['column_default'];
800
801
        if (
802 156
            $defaultValue !== null
803 156
            && preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
804
        ) {
805 81
            $column->sequenceName(preg_replace(
806 81
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
807 81
                '',
808 81
                $defaultValue
809 81
            ));
810 156
        } elseif ($info['sequence_name'] !== null) {
811 5
            $column->sequenceName($this->resolveTableName($info['sequence_name'])->getFullName());
812
        }
813
814 156
        $column->type($this->typeMap[(string) $column->getDbType()] ?? self::TYPE_STRING);
815 156
        $column->phpType($this->getColumnPhpType($column));
816 156
        $column->defaultValue($this->normalizeDefaultValue($defaultValue, $column));
817
818 156
        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 156
    protected function getColumnPhpType(ColumnSchemaInterface $column): string
829
    {
830 156
        if ($column->getType() === self::TYPE_BIT) {
831 33
            return self::PHP_TYPE_INTEGER;
832
        }
833
834 156
        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 156
    private function normalizeDefaultValue(?string $defaultValue, ColumnSchemaInterface $column): mixed
846
    {
847 156
        if ($defaultValue === null || $column->isPrimaryKey()) {
848 153
            return null;
849
        }
850
851 93
        if ($column->getType() === self::TYPE_BOOLEAN && in_array($defaultValue, ['true', 'false'], true)) {
852 65
            return $defaultValue === 'true';
853
        }
854
855
        if (
856 93
            in_array($column->getType(), [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME], true)
857 93
            && in_array(strtoupper($defaultValue), ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'], true)
858
        ) {
859 33
            return new Expression($defaultValue);
860
        }
861
862 93
        if (preg_match("/^B?'(.*?)'::/", $defaultValue, $matches) === 1) {
863 44
            return $column->getType() === self::TYPE_BINARY && str_starts_with($matches[1], '\\x')
864 33
                ? hex2bin(substr($matches[1], 2))
865 44
                : $column->phpTypecast($matches[1]);
866
        }
867
868 82
        if (preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches) === 1) {
869 82
            return $matches[2] !== 'NULL'
870 80
                ? $column->phpTypecast($matches[2])
871 82
                : null;
872
        }
873
874
        return $column->phpTypecast($defaultValue);
875
    }
876
877
    /**
878
     * Loads multiple types of constraints and returns the specified ones.
879
     *
880
     * @param string $tableName The table name.
881
     * @param string $returnType The return type:
882
     * - primaryKey
883
     * - foreignKeys
884
     * - uniques
885
     * - checks
886
     *
887
     * @throws Exception
888
     * @throws InvalidConfigException
889
     * @throws Throwable
890
     *
891
     * @return array|Constraint|null Constraints.
892
     *
893
     * @psalm-return CheckConstraint[]|Constraint[]|ForeignKeyConstraint[]|Constraint|null
894
     */
895 82
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
896
    {
897 82
        $sql = <<<SQL
898
        SELECT
899
            "c"."conname" AS "name",
900
            "a"."attname" AS "column_name",
901
            "c"."contype" AS "type",
902
            "ftcns"."nspname" AS "foreign_table_schema",
903
            "ftc"."relname" AS "foreign_table_name",
904
            "fa"."attname" AS "foreign_column_name",
905
            "c"."confupdtype" AS "on_update",
906
            "c"."confdeltype" AS "on_delete",
907
            pg_get_constraintdef("c"."oid") AS "check_expr"
908
        FROM "pg_class" AS "tc"
909
        INNER JOIN "pg_namespace" AS "tcns"
910
            ON "tcns"."oid" = "tc"."relnamespace"
911
        INNER JOIN "pg_constraint" AS "c"
912
            ON "c"."conrelid" = "tc"."oid"
913
        INNER JOIN "pg_attribute" AS "a"
914
            ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
915
        LEFT JOIN "pg_class" AS "ftc"
916
            ON "ftc"."oid" = "c"."confrelid"
917
        LEFT JOIN "pg_namespace" AS "ftcns"
918
            ON "ftcns"."oid" = "ftc"."relnamespace"
919
        LEFT JOIN "pg_attribute" "fa"
920
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
921
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
922
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
923 82
        SQL;
924
925
        /** @psalm-var string[] $actionTypes */
926 82
        $actionTypes = [
927 82
            'a' => 'NO ACTION',
928 82
            'r' => 'RESTRICT',
929 82
            'c' => 'CASCADE',
930 82
            'n' => 'SET NULL',
931 82
            'd' => 'SET DEFAULT',
932 82
        ];
933
934 82
        $resolvedName = $this->resolveTableName($tableName);
935 82
        $constraints = $this->db->createCommand($sql, [
936 82
            ':schemaName' => $resolvedName->getSchemaName(),
937 82
            ':tableName' => $resolvedName->getName(),
938 82
        ])->queryAll();
939
940
        /** @psalm-var array[][] $constraints */
941 82
        $constraints = $this->normalizeRowKeyCase($constraints, true);
942 82
        $constraints = DbArrayHelper::index($constraints, null, ['type', 'name']);
943
944 82
        $result = [
945 82
            self::PRIMARY_KEY => null,
946 82
            self::FOREIGN_KEYS => [],
947 82
            self::UNIQUES => [],
948 82
            self::CHECKS => [],
949 82
        ];
950
951
        /**
952
         * @psalm-var string $type
953
         * @psalm-var array $names
954
         */
955 82
        foreach ($constraints as $type => $names) {
956
            /**
957
             * @psalm-var object|string|null $name
958
             * @psalm-var ConstraintArray $constraint
959
             */
960 82
            foreach ($names as $name => $constraint) {
961
                switch ($type) {
962 82
                    case 'p':
963 57
                        $result[self::PRIMARY_KEY] = (new Constraint())
964 57
                            ->name($name)
965 57
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
966 57
                        break;
967 74
                    case 'f':
968 19
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
969 19
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
970
971 19
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
972 19
                            ->name($name)
973 19
                            ->columnNames(array_values(
974 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'column_name'))
975 19
                            ))
976 19
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
977 19
                            ->foreignTableName($constraint[0]['foreign_table_name'])
978 19
                            ->foreignColumnNames(array_values(
979 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'foreign_column_name'))
980 19
                            ))
981 19
                            ->onDelete($onDelete)
982 19
                            ->onUpdate($onUpdate);
983 19
                        break;
984 61
                    case 'u':
985 58
                        $result[self::UNIQUES][] = (new Constraint())
986 58
                            ->name($name)
987 58
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
988 58
                        break;
989 15
                    case 'c':
990 15
                        $result[self::CHECKS][] = (new CheckConstraint())
991 15
                            ->name($name)
992 15
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
993 15
                            ->expression($constraint[0]['check_expr']);
994 15
                        break;
995
                }
996
            }
997
        }
998
999 82
        foreach ($result as $type => $data) {
1000 82
            $this->setTableMetadata($tableName, $type, $data);
1001
        }
1002
1003 82
        return $result[$returnType];
1004
    }
1005
1006
    /**
1007
     * Creates a column schema for the database.
1008
     *
1009
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1010
     *
1011
     * @param string $name Name of the column.
1012
     *
1013
     * @return ColumnSchema
1014
     */
1015 156
    private function createColumnSchema(string $name): ColumnSchema
1016
    {
1017 156
        return new ColumnSchema($name);
1018
    }
1019
1020
    /**
1021
     * Returns the cache key for the specified table name.
1022
     *
1023
     * @param string $name The table name.
1024
     *
1025
     * @return array The cache key.
1026
     */
1027 269
    protected function getCacheKey(string $name): array
1028
    {
1029 269
        return array_merge([self::class], $this->generateCacheKey(), [$this->getRawTableName($name)]);
1030
    }
1031
1032
    /**
1033
     * Returns the cache tag name.
1034
     *
1035
     * This allows {@see refresh()} to invalidate all cached table schemas.
1036
     *
1037
     * @return string The cache tag name.
1038
     */
1039 231
    protected function getCacheTag(): string
1040
    {
1041 231
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
1042
    }
1043
}
1044