Passed
Pull Request — master (#241)
by Def
03:31
created

Schema::findColumns()   D

Complexity

Conditions 20
Paths 26

Size

Total Lines 151
Code Lines 65

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 50
CRAP Score 20.0228

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 20
eloc 65
c 5
b 1
f 0
nc 26
nop 1
dl 0
loc 151
ccs 50
cts 52
cp 0.9615
crap 20.0228
rs 4.1666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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