Passed
Pull Request — master (#153)
by
unknown
02:46
created

Schema::findColumns()   D

Complexity

Conditions 20
Paths 26

Size

Total Lines 151
Code Lines 65

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 43
CRAP Score 20.035

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