Passed
Branch master (9f5212)
by Wilmer
18:22 queued 15:27
created

Schema::findColumns()   D

Complexity

Conditions 20
Paths 26

Size

Total Lines 150
Code Lines 64

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 41
CRAP Score 20.0402

Importance

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