Passed
Pull Request — master (#120)
by Wilmer
03:25
created

Schema::findColumns()   D

Complexity

Conditions 23
Paths 96

Size

Total Lines 157
Code Lines 70

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 47
CRAP Score 23.0359

Importance

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