Passed
Push — dev ( e636f1...8e33b7 )
by Def
05:23 queued 02:46
created

Schema::findConstraints()   B

Complexity

Conditions 6
Paths 20

Size

Total Lines 96
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 6.0033

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 6
eloc 46
c 4
b 0
f 0
nc 20
nop 1
dl 0
loc 96
ccs 21
cts 22
cp 0.9545
crap 6.0033
rs 8.5559

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