Passed
Pull Request — dev (#114)
by Def
19:21 queued 16:50
created

SchemaPDOPgsql::findColumns()   D

Complexity

Conditions 23
Paths 96

Size

Total Lines 158
Code Lines 70

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 47
CRAP Score 23.0359

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 23
eloc 70
c 1
b 0
f 0
nc 96
nop 1
dl 0
loc 158
ccs 47
cts 49
cp 0.9592
crap 23.0359
rs 4.1666

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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