Passed
Push — master ( bfbdfe...8da22b )
by Def
03:38
created

Schema::findColumns()   D

Complexity

Conditions 18
Paths 24

Size

Total Lines 135
Code Lines 64

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 49
CRAP Score 18.0025

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 18
eloc 64
c 5
b 1
f 0
nc 24
nop 1
dl 0
loc 135
ccs 49
cts 50
cp 0.98
crap 18.0025
rs 4.8666

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