Passed
Push — temp ( ae651e...c74d19 )
by Wilmer
23:03 queued 20:07
created

Schema::findConstraints()   B

Complexity

Conditions 5
Paths 10

Size

Total Lines 88
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 5.0035

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 5
eloc 46
c 5
b 0
f 0
nc 10
nop 1
dl 0
loc 88
ccs 18
cts 19
cp 0.9474
crap 5.0035
rs 8.867

How to fix   Long Method   

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|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 137
    protected function loadTableSchema(string $name): TableSchemaInterface|null
260
    {
261 137
        $table = $this->resolveTableName($name);
262
263 137
        if ($this->findColumns($table)) {
264 117
            $this->findConstraints($table);
265 117
            return $table;
266
        }
267
268 31
        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 34
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
281
    {
282 34
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
283
284 34
        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 9
    protected function loadTableForeignKeys(string $tableName): array
299
    {
300 9
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
301
302 9
        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 32
    protected function loadTableIndexes(string $tableName): array
315
    {
316 32
        $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 32
        $resolvedName = $this->resolveTableName($tableName);
336
337 32
        $indexes = $this->db->createCommand($sql, [
338 32
            ':schemaName' => $resolvedName->getSchemaName(),
339 32
            ':tableName' => $resolvedName->getName(),
340 32
        ])->queryAll();
341
342
        /** @var array[] @indexes */
343 32
        $indexes = $this->normalizeRowKeyCase($indexes, true);
344 32
        $indexes = ArrayHelper::index($indexes, null, 'name');
345 32
        $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 32
        foreach ($indexes as $name => $index) {
360 29
            $ic = (new IndexConstraint())
361 29
                ->name($name)
362 29
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
363 29
                ->primary($index[0]['index_is_primary'])
364 29
                ->unique($index[0]['index_is_unique']);
365
366 29
            $result[] = $ic;
367
        }
368
369 32
        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 17
    protected function loadTableUniques(string $tableName): array
384
    {
385 17
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
386
387 17
        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 15
    protected function loadTableChecks(string $tableName): array
402
    {
403 15
        $tableChecks = $this->loadTableConstraints($tableName, self::CHECKS);
404
405 15
        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(__METHOD__ . ' is not supported by PostgreSQL.');
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 117
    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 117
        $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 117
        $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 117
        $rows = $this->db->createCommand($sql, [
497 117
            ':schemaName' => $table->getSchemaName(),
498 117
            ':tableName' => $table->getName(),
499 117
        ])->queryAll();
500
501 117
        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 12
            $constraint = $this->normalizeRowKeyCase($constraint, false);
510
511 12
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
512
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
513
            } else {
514 12
                $foreignTable = $constraint['foreign_table_name'];
515
            }
516
517 12
            $name = $constraint['constraint_name'];
518
519 12
            if (!isset($constraints[$name])) {
520 12
                $constraints[$name] = [
521
                    'tableName' => $foreignTable,
522
                    'columns' => [],
523
                ];
524
            }
525
526 12
            $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 117
        foreach ($constraints as $foreingKeyName => $constraint) {
534 12
            $table->foreignKey(
535
                (string) $foreingKeyName,
536 12
                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 137
    protected function findColumns(TableSchemaInterface $table): bool
628
    {
629 137
        $orIdentity = '';
630
631 137
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
632 130
            $orIdentity = 'OR a.attidentity != \'\'';
633
        }
634
635 137
        $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
            a.atttypmod AS modifier,
645
            a.attnotnull = false AS is_nullable,
646
            CAST(pg_get_expr(ad.adbin, ad.adrelid) AS varchar) AS column_default,
647
            coalesce(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval',false) $orIdentity AS is_autoinc,
648
            pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname)
649
            AS sequence_name,
650
            CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
651
                THEN array_to_string(
652
                    (
653
                        SELECT array_agg(enumlabel)
654
                        FROM pg_enum
655
                        WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid)
656
                    )::varchar[],
657
                ',')
658
                ELSE NULL
659
            END AS enum_values,
660
            CASE atttypid
661
                WHEN 21 /*int2*/ THEN 16
662
                WHEN 23 /*int4*/ THEN 32
663
                WHEN 20 /*int8*/ THEN 64
664
                WHEN 1700 /*numeric*/ THEN
665
                    CASE WHEN atttypmod = -1
666
                        THEN null
667
                        ELSE ((atttypmod - 4) >> 16) & 65535
668
                        END
669
                WHEN 700 /*float4*/ THEN 24 /*FLT_MANT_DIG*/
670
                WHEN 701 /*float8*/ THEN 53 /*DBL_MANT_DIG*/
671
                    ELSE null
672
                    END   AS numeric_precision,
673
            CASE
674
                WHEN atttypid IN (21, 23, 20) THEN 0
675
                WHEN atttypid IN (1700) THEN
676
            CASE
677
                WHEN atttypmod = -1 THEN null
678
                    ELSE (atttypmod - 4) & 65535
679
                    END
680
                    ELSE null
681
                    END AS numeric_scale,
682
                    CAST(
683
                        information_schema._pg_char_max_length(
684
                        information_schema._pg_truetypid(a, t),
685
                        information_schema._pg_truetypmod(a, t)
686
                        ) AS numeric
687
                    ) AS size,
688
                    a.attnum = any (ct.conkey) as is_pkey,
689
                    COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
690
            FROM
691
                pg_class c
692
                LEFT JOIN pg_attribute a ON a.attrelid = c.oid
693
                LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
694
                LEFT JOIN pg_type t ON a.atttypid = t.oid
695
                LEFT JOIN pg_type tb ON (a.attndims > 0 OR t.typcategory='A') AND t.typelem > 0 AND t.typelem = tb.oid
696
                                            OR t.typbasetype > 0 AND t.typbasetype = tb.oid
697
                LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
698
                LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
699
                LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p'
700
            WHERE
701
                a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
702
                AND c.relname = :tableName
703
                AND d.nspname = :schemaName
704
            ORDER BY
705
                a.attnum;
706
        SQL;
707
708 137
        $columns = $this->db->createCommand($sql, [
709 137
            ':schemaName' => $table->getSchemaName(),
710 137
            ':tableName' => $table->getName(),
711 137
        ])->queryAll();
712
713 137
        if (empty($columns)) {
714 31
            return false;
715
        }
716
717
        /** @var array $column */
718 117
        foreach ($columns as $column) {
719
            /** @psalm-var ColumnArray $column */
720 117
            $column = $this->normalizeRowKeyCase($column, false);
721
722
            /** @psalm-var ColumnSchema $loadColumnSchema */
723 117
            $loadColumnSchema = $this->loadColumnSchema($column);
724
725 117
            $table->columns($loadColumnSchema->getName(), $loadColumnSchema);
726
727
            /** @var mixed */
728 117
            $defaultValue = $loadColumnSchema->getDefaultValue();
729
730 117
            if ($loadColumnSchema->isPrimaryKey()) {
731 76
                $table->primaryKey($loadColumnSchema->getName());
732
733 76
                if ($table->getSequenceName() === null) {
734 76
                    $table->sequenceName($loadColumnSchema->getSequenceName());
735
                }
736
737 76
                $loadColumnSchema->defaultValue(null);
738 114
            } elseif ($defaultValue) {
739
                if (
740 62
                    is_string($defaultValue) &&
741 62
                    in_array(
742 62
                        $loadColumnSchema->getType(),
743
                        [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME],
744
                        true
745
                    ) &&
746 30
                    in_array(
747 30
                        strtoupper($defaultValue),
748
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
749
                        true
750
                    )
751
                ) {
752 28
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
753 62
                } elseif ($loadColumnSchema->getType() === 'boolean') {
754 58
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
755 33
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
756
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
757 33
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
758 28
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
759 33
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
760 30
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
761
                } elseif (
762 31
                    is_string($defaultValue) &&
763 31
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
764
                ) {
765 31
                    if ($matches[2] === 'NULL') {
766 5
                        $loadColumnSchema->defaultValue(null);
767
                    } else {
768 31
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
769
                    }
770
                } else {
771
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
772
                }
773
            }
774
        }
775
776 117
        return true;
777
    }
778
779
    /**
780
     * Loads the column information into a {@see ColumnSchemaInterface} object.
781
     *
782
     * @psalm-param array{
783
     *   table_schema: string,
784
     *   table_name: string,
785
     *   column_name: string,
786
     *   data_type: string,
787
     *   type_type: string|null,
788
     *   character_maximum_length: int,
789
     *   column_comment: string|null,
790
     *   modifier: int,
791
     *   is_nullable: bool,
792
     *   column_default: mixed,
793
     *   is_autoinc: bool,
794
     *   sequence_name: string|null,
795
     *   enum_values: array<array-key, float|int|string>|string|null,
796
     *   numeric_precision: int|null,
797
     *   numeric_scale: int|null,
798
     *   size: string|null,
799
     *   is_pkey: bool|null,
800
     *   dimension: int
801
     * } $info column information.
802
     *
803
     * @return ColumnSchemaInterface the column schema object.
804
     */
805 117
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
806
    {
807 117
        $column = $this->createColumnSchema();
808 117
        $column->allowNull($info['is_nullable']);
809 117
        $column->autoIncrement($info['is_autoinc']);
810 117
        $column->comment($info['column_comment']);
811 117
        $column->dbType($info['data_type']);
812 117
        $column->defaultValue($info['column_default']);
813 117
        $column->enumValues(($info['enum_values'] !== null)
814 117
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null);
815 117
        $column->unsigned(false); // has no meaning in PG
816 117
        $column->primaryKey((bool) $info['is_pkey']);
817 117
        $column->name($info['column_name']);
818 117
        $column->precision($info['numeric_precision']);
819 117
        $column->scale($info['numeric_scale']);
820 117
        $column->size($info['size'] === null ? null : (int) $info['size']);
821 117
        $column->dimension($info['dimension']);
822
823
        /**
824
         * pg_get_serial_sequence() doesn't track DEFAULT value change. GENERATED BY IDENTITY columns always have null
825
         * default value.
826
         *
827
         * @var mixed $defaultValue
828
         */
829 117
        $defaultValue = $column->getDefaultValue();
830 117
        $sequenceName = $info['sequence_name'] ?? null;
831
832
        if (
833
            isset($defaultValue) &&
834 117
            is_string($defaultValue) &&
835 117
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
836
        ) {
837 70
            $column->sequenceName(preg_replace(
838
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
839
                '',
840
                $defaultValue
841
            ));
842 115
        } elseif ($sequenceName !== null) {
843
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
844
        }
845
846 117
        if (isset($this->typeMap[$column->getDbType()])) {
847 117
            $column->type($this->typeMap[$column->getDbType()]);
848
        } else {
849
            $column->type(self::TYPE_STRING);
850
        }
851
852 117
        $column->phpType($this->getColumnPhpType($column));
853
854 117
        return $column;
855
    }
856
857
    /**
858
     * Loads multiple types of constraints and returns the specified ones.
859
     *
860
     * @param string $tableName table name.
861
     * @param string $returnType return type:
862
     * - primaryKey
863
     * - foreignKeys
864
     * - uniques
865
     * - checks
866
     *
867
     * @throws Exception|InvalidConfigException|Throwable
868
     *
869
     * @return array|Constraint|null (CheckConstraint|Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
870
     */
871 75
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
872
    {
873 75
        $sql = <<<SQL
874
        SELECT
875
            "c"."conname" AS "name",
876
            "a"."attname" AS "column_name",
877
            "c"."contype" AS "type",
878
            "ftcns"."nspname" AS "foreign_table_schema",
879
            "ftc"."relname" AS "foreign_table_name",
880
            "fa"."attname" AS "foreign_column_name",
881
            "c"."confupdtype" AS "on_update",
882
            "c"."confdeltype" AS "on_delete",
883
            pg_get_constraintdef("c"."oid") AS "check_expr"
884
        FROM "pg_class" AS "tc"
885
        INNER JOIN "pg_namespace" AS "tcns"
886
            ON "tcns"."oid" = "tc"."relnamespace"
887
        INNER JOIN "pg_constraint" AS "c"
888
            ON "c"."conrelid" = "tc"."oid"
889
        INNER JOIN "pg_attribute" AS "a"
890
            ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
891
        LEFT JOIN "pg_class" AS "ftc"
892
            ON "ftc"."oid" = "c"."confrelid"
893
        LEFT JOIN "pg_namespace" AS "ftcns"
894
            ON "ftcns"."oid" = "ftc"."relnamespace"
895
        LEFT JOIN "pg_attribute" "fa"
896
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
897
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
898
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
899
        SQL;
900
901
        /** @var array<array-key, string> $actionTypes */
902 75
        $actionTypes = [
903
            'a' => 'NO ACTION',
904
            'r' => 'RESTRICT',
905
            'c' => 'CASCADE',
906
            'n' => 'SET NULL',
907
            'd' => 'SET DEFAULT',
908
        ];
909
910 75
        $resolvedName = $this->resolveTableName($tableName);
911
912 75
        $constraints = $this->db->createCommand($sql, [
913 75
            ':schemaName' => $resolvedName->getSchemaName(),
914 75
            ':tableName' => $resolvedName->getName(),
915 75
        ])->queryAll();
916
917
        /** @var array<array-key, array> $constraints */
918 75
        $constraints = $this->normalizeRowKeyCase($constraints, true);
919 75
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
920
921 75
        $result = [
922
            self::PRIMARY_KEY => null,
923
            self::FOREIGN_KEYS => [],
924
            self::UNIQUES => [],
925
            self::CHECKS => [],
926
        ];
927
928
        /**
929
         * @var string $type
930
         * @var array $names
931
         */
932 75
        foreach ($constraints as $type => $names) {
933
            /**
934
             * @psalm-var object|string|null $name
935
             * @psalm-var ConstraintArray $constraint
936
             */
937 75
            foreach ($names as $name => $constraint) {
938
                switch ($type) {
939 75
                    case 'p':
940 53
                        $result[self::PRIMARY_KEY] = (new Constraint())
941 53
                            ->name($name)
942 53
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
943 53
                        break;
944 71
                    case 'f':
945 21
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
946 21
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
947
948 21
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
949 21
                            ->name($name)
950 21
                            ->columnNames(array_values(
951 21
                                array_unique(ArrayHelper::getColumn($constraint, 'column_name'))
952
                            ))
953 21
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
954 21
                            ->foreignTableName($constraint[0]['foreign_table_name'])
955 21
                            ->foreignColumnNames(array_values(
956 21
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
957
                            ))
958 21
                            ->onDelete($onDelete)
959 21
                            ->onUpdate($onUpdate);
960 21
                        break;
961 58
                    case 'u':
962 56
                        $result[self::UNIQUES][] = (new Constraint())
963 56
                            ->name($name)
964 56
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
965 56
                        break;
966 15
                    case 'c':
967 15
                        $result[self::CHECKS][] = (new CheckConstraint())
968 15
                            ->name($name)
969 15
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
970 15
                            ->expression($constraint[0]['check_expr']);
971 15
                        break;
972
                }
973
            }
974
        }
975
976 75
        foreach ($result as $type => $data) {
977 75
            $this->setTableMetadata($tableName, $type, $data);
978
        }
979
980 75
        return $result[$returnType];
981
    }
982
983
    /**
984
     * Creates a column schema for the database.
985
     *
986
     * This method may be overridden by child classes to create a DBMS-specific column schema.
987
     *
988
     * @return ColumnSchema column schema instance.
989
     */
990 117
    private function createColumnSchema(): ColumnSchema
991
    {
992 117
        return new ColumnSchema();
993
    }
994
995
    /**
996
     * Create a column schema builder instance giving the type and value precision.
997
     *
998
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
999
     *
1000
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
1001
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
1002
     *
1003
     * @return ColumnSchemaBuilder column schema builder instance
1004
     *
1005
     * @psalm-param int|string|string[]|null $length
1006
     */
1007 4
    public function createColumnSchemaBuilder(string $type, int|string|array|null $length = null): ColumnSchemaBuilder
1008
    {
1009 4
        return new ColumnSchemaBuilder($type, $length);
1010
    }
1011
1012
    /**
1013
     * Returns the cache key for the specified table name.
1014
     *
1015
     * @param string $name the table name.
1016
     *
1017
     * @return array the cache key.
1018
     */
1019 202
    protected function getCacheKey(string $name): array
1020
    {
1021 202
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
1022
    }
1023
1024
    /**
1025
     * Returns the cache tag name.
1026
     *
1027
     * This allows {@see refresh()} to invalidate all cached table schemas.
1028
     *
1029
     * @return string the cache tag name.
1030
     */
1031 202
    protected function getCacheTag(): string
1032
    {
1033 202
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
1034
    }
1035
1036
    /**
1037
     * Changes row's array key case to lower.
1038
     *
1039
     * @param array $row row's array or an array of row's arrays.
1040
     * @param bool $multiple whether multiple rows or a single row passed.
1041
     *
1042
     * @return array normalized row or rows.
1043
     */
1044 176
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
1045
    {
1046 176
        if ($multiple) {
1047 89
            return array_map(static fn (array $row) => array_change_key_case($row, CASE_LOWER), $row);
1048
        }
1049
1050 117
        return array_change_key_case($row, CASE_LOWER);
1051
    }
1052
}
1053