Passed
Push — master ( ada7f0...2f8f13 )
by Alexander
09:08 queued 05:34
created

Schema::findColumns()   D

Complexity

Conditions 22
Paths 28

Size

Total Lines 139
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 52
CRAP Score 22.0245

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 22
eloc 68
c 5
b 1
f 0
nc 28
nop 1
dl 0
loc 139
ccs 52
cts 54
cp 0.963
crap 22.0245
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
     */
168
    protected string|array $tableQuoteCharacter = '"';
169
170 14
    public function createColumn(string $type, array|int|string $length = null): ColumnInterface
171
    {
172 14
        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
     *
182
     * @see TableSchemaInterface
183
     */
184 228
    protected function resolveTableName(string $name): TableSchemaInterface
185
    {
186 228
        $resolvedName = new TableSchema();
187
188 228
        $parts = array_reverse($this->db->getQuoter()->getTableNameParts($name));
189 228
        $resolvedName->name($parts[0] ?? '');
190 228
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
191
192 228
        $resolvedName->fullName(
193 228
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
194 228
            implode('.', array_reverse($parts)) : $resolvedName->getName()
195 228
        );
196
197 228
        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
     *
210
     * @return array All schemas name in the database, except system schemas.
211
     */
212 1
    protected function findSchemaNames(): array
213
    {
214 1
        $sql = <<<SQL
215
        SELECT "ns"."nspname"
216
        FROM "pg_namespace" AS "ns"
217
        WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
218
        ORDER BY "ns"."nspname" ASC
219 1
        SQL;
220
221 1
        return $this->db->createCommand($sql)->queryColumn();
222
    }
223
224
    /**
225
     * @throws Exception
226
     * @throws InvalidConfigException
227
     * @throws Throwable
228
     */
229 176
    protected function findTableComment(TableSchemaInterface $tableSchema): void
230
    {
231 176
        $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
        WHERE
236
        pc.relname=:tableName AND
237
        pn.nspname=:schemaName
238 176
        SQL;
239
240 176
        $comment = $this->db->createCommand($sql, [
241 176
            ':schemaName' => $tableSchema->getSchemaName(),
242 176
            ':tableName' => $tableSchema->getName(),
243 176
        ])->queryScalar();
244
245 176
        $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
     *
261
     * @return array All tables name in the database. The names have NO schema name prefix.
262
     */
263 12
    protected function findTableNames(string $schema = ''): array
264
    {
265 12
        if ($schema === '') {
266 11
            $schema = $this->defaultSchema;
267
        }
268
269 12
        $sql = <<<SQL
270
        SELECT c.relname AS table_name
271
        FROM pg_class c
272
        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
        ORDER BY c.relname
275 12
        SQL;
276
277 12
        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
     *
289
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table doesn't exist.
290
     */
291 176
    protected function loadTableSchema(string $name): TableSchemaInterface|null
292
    {
293 176
        $table = $this->resolveTableName($name);
294 176
        $this->findTableComment($table);
295
296 176
        if ($this->findColumns($table)) {
297 154
            $this->findConstraints($table);
298 154
            return $table;
299
        }
300
301 42
        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
     *
313
     * @return Constraint|null Primary key for the given table, `null` if the table has no primary key.
314
     */
315 40
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
316
    {
317 40
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
318
319 40
        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
     *
333
     * @psaml-return array|ForeignKeyConstraint[]
334
     */
335 8
    protected function loadTableForeignKeys(string $tableName): array
336
    {
337 8
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
338
339 8
        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
     *
351
     * @return IndexConstraint[] Indexes for the given table.
352
     */
353 38
    protected function loadTableIndexes(string $tableName): array
354
    {
355 38
        $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
            ON "ia"."attrelid" = "i"."indexrelid"
370
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
371
        ORDER BY "ia"."attnum" ASC
372 38
        SQL;
373
374 38
        $resolvedName = $this->resolveTableName($tableName);
375 38
        $indexes = $this->db->createCommand($sql, [
376 38
            ':schemaName' => $resolvedName->getSchemaName(),
377 38
            ':tableName' => $resolvedName->getName(),
378 38
        ])->queryAll();
379
380
        /** @psalm-var array[] $indexes */
381 38
        $indexes = $this->normalizeRowKeyCase($indexes, true);
382 38
        $indexes = DbArrayHelper::index($indexes, null, ['name']);
383 38
        $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
         *   }
395
         * > $index
396
         */
397 38
        foreach ($indexes as $name => $index) {
398 35
            $ic = (new IndexConstraint())
399 35
                ->name($name)
400 35
                ->columnNames(DbArrayHelper::getColumn($index, 'column_name'))
401 35
                ->primary($index[0]['index_is_primary'])
402 35
                ->unique($index[0]['index_is_unique']);
403
404 35
            $result[] = $ic;
405
        }
406
407 38
        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
     *
421
     * @psalm-return array|Constraint[]
422
     */
423 17
    protected function loadTableUniques(string $tableName): array
424
    {
425 17
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
426
427 17
        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
     *
441
     * @psaml-return array|CheckConstraint[]
442
     */
443 17
    protected function loadTableChecks(string $tableName): array
444
    {
445 17
        $tableChecks = $this->loadTableConstraints($tableName, self::CHECKS);
446
447 17
        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
     *
457
     * @return DefaultValueConstraint[] Default value constraints for the given table.
458
     */
459 13
    protected function loadTableDefaultValues(string $tableName): array
460
    {
461 13
        throw new NotSupportedException(__METHOD__ . ' is not supported by PostgreSQL.');
462
    }
463
464
    /**
465
     * @throws Exception
466
     * @throws InvalidConfigException
467
     * @throws Throwable
468
     */
469 3
    protected function findViewNames(string $schema = ''): array
470
    {
471 3
        if ($schema === '') {
472 1
            $schema = $this->defaultSchema;
473
        }
474
475 3
        $sql = <<<SQL
476
        SELECT c.relname AS table_name
477
        FROM pg_class c
478
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
479
        WHERE ns.nspname = :schemaName AND (c.relkind = 'v' OR c.relkind = 'm')
480
        ORDER BY c.relname
481 3
        SQL;
482
483 3
        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
     * @throws InvalidConfigException
493
     * @throws Throwable
494
     */
495 154
    protected function findConstraints(TableSchemaInterface $table): void
496
    {
497
        /**
498
         * We need to extract the constraints de hard way since:
499
         * {@see https://www.postgresql.org/message-id/[email protected]}
500
         */
501
502 154
        $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
            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 154
        $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
         *   }
540
         * > $rows
541
         */
542 154
        $rows = $this->db->createCommand($sql, [
543 154
            ':schemaName' => $table->getSchemaName(),
544 154
            ':tableName' => $table->getName(),
545 154
        ])->queryAll();
546
547 154
        foreach ($rows as $constraint) {
548
            /** @psalm-var array{
549
             *     constraint_name: string,
550
             *     column_name: string,
551
             *     foreign_table_name: string,
552
             *     foreign_table_schema: string,
553
             *     foreign_column_name: string,
554
             *   } $constraint */
555 16
            $constraint = $this->normalizeRowKeyCase($constraint, false);
556
557 16
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
558 3
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
559
            } else {
560 16
                $foreignTable = $constraint['foreign_table_name'];
561
            }
562
563 16
            $name = $constraint['constraint_name'];
564
565 16
            if (!isset($constraints[$name])) {
566 16
                $constraints[$name] = [
567 16
                    'tableName' => $foreignTable,
568 16
                    'columns' => [],
569 16
                ];
570
            }
571
572 16
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
573
        }
574
575
        /**
576
         * @psalm-var int|string $foreingKeyName.
577
         * @psalm-var array{tableName: string, columns: array} $constraint
578
         */
579 154
        foreach ($constraints as $foreingKeyName => $constraint) {
580 16
            $table->foreignKey(
581 16
                (string) $foreingKeyName,
582 16
                array_merge([$constraint['tableName']], $constraint['columns'])
583 16
            );
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
     *
596
     * @return array With index and column names.
597
     */
598 1
    protected function getUniqueIndexInformation(TableSchemaInterface $table): array
599
    {
600 1
        $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
        WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
612
        AND c.relname = :tableName AND ns.nspname = :schemaName
613
        ORDER BY i.relname, k
614 1
        SQL;
615
616 1
        return $this->db->createCommand($sql, [
617 1
            ':schemaName' => $table->getSchemaName(),
618 1
            ':tableName' => $table->getName(),
619 1
        ])->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
     *
640
     * @return array All unique indexes for the given table.
641
     */
642 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
643
    {
644 1
        $uniqueIndexes = [];
645
646
        /** @psalm-var array{indexname: string, columnname: string} $row */
647 1
        foreach ($this->getUniqueIndexInformation($table) as $row) {
648
            /** @psalm-var array{indexname: string, columnname: string} $row */
649 1
            $row = $this->normalizeRowKeyCase($row, false);
650
651 1
            $column = $row['columnname'];
652
653 1
            if (str_starts_with($column, '"') && str_ends_with($column, '"')) {
654
                /**
655
                 * postgres will quote names that aren't lowercase-only.
656
                 *
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 1
        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
     *
678
     * @return bool Whether the table exists in the database.
679
     */
680 176
    protected function findColumns(TableSchemaInterface $table): bool
681
    {
682 176
        $orIdentity = '';
683
684 176
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
685 169
            $orIdentity = 'OR a.attidentity != \'\'';
686
        }
687
688 176
        $sql = <<<SQL
689 176
        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
            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 176
            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
            AND d.nspname = :schemaName
742
        ORDER BY
743
            a.attnum;
744 176
        SQL;
745
746 176
        $columns = $this->db->createCommand($sql, [
747 176
            ':schemaName' => $table->getSchemaName(),
748 176
            ':tableName' => $table->getName(),
749 176
        ])->queryAll();
750
751 176
        if (empty($columns)) {
752 42
            return false;
753
        }
754
755
        /** @psalm-var array $column */
756 154
        foreach ($columns as $column) {
757
            /** @psalm-var ColumnArray $column */
758 154
            $column = $this->normalizeRowKeyCase($column, false);
759
760
            /** @psalm-var ColumnSchema $loadColumnSchema */
761 154
            $loadColumnSchema = $this->loadColumnSchema($column);
762
763 154
            $table->column($loadColumnSchema->getName(), $loadColumnSchema);
764
765
            /** @psalm-var mixed $defaultValue */
766 154
            $defaultValue = $loadColumnSchema->getDefaultValue();
767
768 154
            if ($loadColumnSchema->isPrimaryKey()) {
769 96
                $table->primaryKey($loadColumnSchema->getName());
770
771 96
                if ($table->getSequenceName() === null) {
772 96
                    $table->sequenceName($loadColumnSchema->getSequenceName());
773
                }
774
775 96
                $loadColumnSchema->defaultValue(null);
776 150
            } elseif ($defaultValue) {
777
                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 71
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
787 71
                        true
788 71
                    )
789
                ) {
790 32
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
791 71
                } elseif ($loadColumnSchema->getType() === 'boolean') {
792 63
                    $loadColumnSchema->defaultValue($defaultValue  === 'true');
793 41
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
794
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
795 41
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
796 32
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
797 41
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
798 38
                    if ($loadColumnSchema->getType() === 'binary' && str_starts_with($matches[1], '\\x')) {
799 32
                        $loadColumnSchema->defaultValue(hex2bin(substr($matches[1], 2)));
800
                    } else {
801 38
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
802
                    }
803
                } elseif (
804 35
                    is_string($defaultValue) &&
805 35
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
806
                ) {
807 35
                    if ($matches[2] === 'NULL') {
808 5
                        $loadColumnSchema->defaultValue(null);
809
                    } else {
810 35
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
811
                    }
812
                } else {
813
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
814
                }
815
            }
816
        }
817
818 154
        return true;
819
    }
820
821
    /**
822
     * Loads the column information into a {@see ColumnSchemaInterface} object.
823
     *
824
     * @psalm-param array{
825
     *   table_schema: string,
826
     *   table_name: string,
827
     *   column_name: string,
828
     *   data_type: string,
829
     *   type_type: string|null,
830
     *   type_scheme: string|null,
831
     *   character_maximum_length: int,
832
     *   column_comment: string|null,
833
     *   modifier: int,
834
     *   is_nullable: bool,
835
     *   column_default: mixed,
836
     *   is_autoinc: bool,
837
     *   sequence_name: string|null,
838
     *   enum_values: array<array-key, float|int|string>|string|null,
839
     *   numeric_precision: int|null,
840
     *   numeric_scale: int|null,
841
     *   size: string|null,
842
     *   is_pkey: bool|null,
843
     *   dimension: int
844
     * } $info Column information.
845
     *
846
     * @return ColumnSchemaInterface The column schema object.
847
     */
848 154
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
849
    {
850 154
        $column = $this->createColumnSchema($info['column_name']);
851 154
        $column->allowNull($info['is_nullable']);
852 154
        $column->autoIncrement($info['is_autoinc']);
853 154
        $column->comment($info['column_comment']);
854
855 154
        if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)
856
        ) {
857 1
            $column->dbType($info['type_scheme'] . '.' . $info['data_type']);
858
        } else {
859 154
            $column->dbType($info['data_type']);
860
        }
861
862 154
        $column->defaultValue($info['column_default']);
863 154
        $column->enumValues(($info['enum_values'] !== null)
864 154
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null);
865 154
        $column->unsigned(false); // has no meaning in PG
866 154
        $column->primaryKey((bool) $info['is_pkey']);
867 154
        $column->precision($info['numeric_precision']);
868 154
        $column->scale($info['numeric_scale']);
869 154
        $column->size($info['size'] === null ? null : (int) $info['size']);
870 154
        $column->dimension($info['dimension']);
871
872
        /**
873
         * pg_get_serial_sequence() doesn't track DEFAULT value change.
874
         *
875
         * GENERATED BY IDENTITY columns always have a null default value.
876
         *
877
         * @psalm-var mixed $defaultValue
878
         */
879 154
        $defaultValue = $column->getDefaultValue();
880 154
        $sequenceName = $info['sequence_name'] ?? null;
881
882
        if (
883 154
            isset($defaultValue) &&
884 154
            is_string($defaultValue) &&
885 154
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
886
        ) {
887 80
            $column->sequenceName(preg_replace(
888 80
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
889 80
                '',
890 80
                $defaultValue
891 80
            ));
892 154
        } elseif ($sequenceName !== null) {
893 5
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
894
        }
895
896 154
        if (isset($this->typeMap[$column->getDbType() ?? ''])) {
897 154
            $column->type($this->typeMap[$column->getDbType() ?? '']);
898
        } else {
899 5
            $column->type(self::TYPE_STRING);
900
        }
901
902 154
        $column->phpType($this->getColumnPhpType($column));
903
904 154
        return $column;
905
    }
906
907
    /**
908
     * Loads multiple types of constraints and returns the specified ones.
909
     *
910
     * @param string $tableName The table name.
911
     * @param string $returnType The return type:
912
     * - primaryKey
913
     * - foreignKeys
914
     * - uniques
915
     * - checks
916
     *
917
     * @throws Exception
918
     * @throws InvalidConfigException
919
     * @throws Throwable
920
     *
921
     * @return array|Constraint|null Constraints.
922
     *
923
     * @psalm-return CheckConstraint[]|Constraint[]|ForeignKeyConstraint[]|Constraint|null
924
     */
925 82
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
926
    {
927 82
        $sql = <<<SQL
928
        SELECT
929
            "c"."conname" AS "name",
930
            "a"."attname" AS "column_name",
931
            "c"."contype" AS "type",
932
            "ftcns"."nspname" AS "foreign_table_schema",
933
            "ftc"."relname" AS "foreign_table_name",
934
            "fa"."attname" AS "foreign_column_name",
935
            "c"."confupdtype" AS "on_update",
936
            "c"."confdeltype" AS "on_delete",
937
            pg_get_constraintdef("c"."oid") AS "check_expr"
938
        FROM "pg_class" AS "tc"
939
        INNER JOIN "pg_namespace" AS "tcns"
940
            ON "tcns"."oid" = "tc"."relnamespace"
941
        INNER JOIN "pg_constraint" AS "c"
942
            ON "c"."conrelid" = "tc"."oid"
943
        INNER JOIN "pg_attribute" AS "a"
944
            ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
945
        LEFT JOIN "pg_class" AS "ftc"
946
            ON "ftc"."oid" = "c"."confrelid"
947
        LEFT JOIN "pg_namespace" AS "ftcns"
948
            ON "ftcns"."oid" = "ftc"."relnamespace"
949
        LEFT JOIN "pg_attribute" "fa"
950
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
951
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
952
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
953 82
        SQL;
954
955
        /** @psalm-var string[] $actionTypes */
956 82
        $actionTypes = [
957 82
            'a' => 'NO ACTION',
958 82
            'r' => 'RESTRICT',
959 82
            'c' => 'CASCADE',
960 82
            'n' => 'SET NULL',
961 82
            'd' => 'SET DEFAULT',
962 82
        ];
963
964 82
        $resolvedName = $this->resolveTableName($tableName);
965 82
        $constraints = $this->db->createCommand($sql, [
966 82
            ':schemaName' => $resolvedName->getSchemaName(),
967 82
            ':tableName' => $resolvedName->getName(),
968 82
        ])->queryAll();
969
970
        /** @psalm-var array[][] $constraints */
971 82
        $constraints = $this->normalizeRowKeyCase($constraints, true);
972 82
        $constraints = DbArrayHelper::index($constraints, null, ['type', 'name']);
973
974 82
        $result = [
975 82
            self::PRIMARY_KEY => null,
976 82
            self::FOREIGN_KEYS => [],
977 82
            self::UNIQUES => [],
978 82
            self::CHECKS => [],
979 82
        ];
980
981
        /**
982
         * @psalm-var string $type
983
         * @psalm-var array $names
984
         */
985 82
        foreach ($constraints as $type => $names) {
986
            /**
987
             * @psalm-var object|string|null $name
988
             * @psalm-var ConstraintArray $constraint
989
             */
990 82
            foreach ($names as $name => $constraint) {
991
                switch ($type) {
992 82
                    case 'p':
993 57
                        $result[self::PRIMARY_KEY] = (new Constraint())
994 57
                            ->name($name)
995 57
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
996 57
                        break;
997 74
                    case 'f':
998 19
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
999 19
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
1000
1001 19
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
1002 19
                            ->name($name)
1003 19
                            ->columnNames(array_values(
1004 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'column_name'))
1005 19
                            ))
1006 19
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
1007 19
                            ->foreignTableName($constraint[0]['foreign_table_name'])
1008 19
                            ->foreignColumnNames(array_values(
1009 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'foreign_column_name'))
1010 19
                            ))
1011 19
                            ->onDelete($onDelete)
1012 19
                            ->onUpdate($onUpdate);
1013 19
                        break;
1014 61
                    case 'u':
1015 58
                        $result[self::UNIQUES][] = (new Constraint())
1016 58
                            ->name($name)
1017 58
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
1018 58
                        break;
1019 15
                    case 'c':
1020 15
                        $result[self::CHECKS][] = (new CheckConstraint())
1021 15
                            ->name($name)
1022 15
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
1023 15
                            ->expression($constraint[0]['check_expr']);
1024 15
                        break;
1025
                }
1026
            }
1027
        }
1028
1029 82
        foreach ($result as $type => $data) {
1030 82
            $this->setTableMetadata($tableName, $type, $data);
1031
        }
1032
1033 82
        return $result[$returnType];
1034
    }
1035
1036
    /**
1037
     * Creates a column schema for the database.
1038
     *
1039
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1040
     *
1041
     * @param string $name Name of the column.
1042
     *
1043
     * @return ColumnSchema
1044
     */
1045 154
    private function createColumnSchema(string $name): ColumnSchema
1046
    {
1047 154
        return new ColumnSchema($name);
1048
    }
1049
1050
    /**
1051
     * Returns the cache key for the specified table name.
1052
     *
1053
     * @param string $name The table name.
1054
     *
1055
     * @return array The cache key.
1056
     */
1057 267
    protected function getCacheKey(string $name): array
1058
    {
1059 267
        return array_merge([self::class], $this->generateCacheKey(), [$this->getRawTableName($name)]);
1060
    }
1061
1062
    /**
1063
     * Returns the cache tag name.
1064
     *
1065
     * This allows {@see refresh()} to invalidate all cached table schemas.
1066
     *
1067
     * @return string The cache tag name.
1068
     */
1069 229
    protected function getCacheTag(): string
1070
    {
1071 229
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
1072
    }
1073
}
1074