Passed
Push — master ( 626ac9...e95071 )
by Def
30:57 queued 27:33
created

Schema::findColumns()   D

Complexity

Conditions 20
Paths 26

Size

Total Lines 151
Code Lines 65

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 50
CRAP Score 20.0228

Importance

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