Test Failed
Pull Request — master (#284)
by
unknown
03:19
created

Schema::findColumns()   D

Complexity

Conditions 23
Paths 28

Size

Total Lines 138
Code Lines 67

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 48
CRAP Score 23.0338

Importance

Changes 6
Bugs 2 Features 0
Metric Value
cc 23
eloc 67
c 6
b 2
f 0
nc 28
nop 1
dl 0
loc 138
ccs 48
cts 50
cp 0.96
crap 23.0338
rs 4.1666

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