Passed
Pull Request — master (#153)
by
unknown
03:15
created

Schema::findColumns()   D

Complexity

Conditions 20
Paths 26

Size

Total Lines 150
Code Lines 64

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 43
CRAP Score 20.035

Importance

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