Test Failed
Push — master ( d04237...2b9c1d )
by Def
01:53 queued 12s
created

Schema::findConstraints()   B

Complexity

Conditions 8
Paths 36

Size

Total Lines 78
Code Lines 47

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 8.125

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 8
eloc 47
c 3
b 0
f 0
nc 36
nop 1
dl 0
loc 78
ccs 21
cts 24
cp 0.875
crap 8.125
rs 7.9119

How to fix   Long Method   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Pgsql;
6
7
use JsonException;
8
use 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
    protected function resolveTableName(string $name): TableSchema
182
    {
183
        $resolvedName = new TableSchema();
184 76
185
        $parts = explode('.', str_replace('"', '', $name));
186 76
187
        if (isset($parts[1])) {
188 76
            $resolvedName->schemaName($parts[0]);
189
            $resolvedName->name($parts[1]);
190 76
        } else {
191 5
            $resolvedName->schemaName($this->defaultSchema);
192 5
            $resolvedName->name($name);
193
        }
194 71
195 71
        $resolvedName->fullName(
196
            (
197
                $resolvedName->getSchemaName() !== $this->defaultSchema ?
198 76
                    (string) $resolvedName->getSchemaName() . '.' :
199
                    ''
200 76
            ) . $resolvedName->getName()
201
        );
202 76
203 76
        return $resolvedName;
204
    }
205
206 76
    /**
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
    protected function findSchemaNames(): array
217
    {
218
        $sql = <<<'SQL'
219 2
            SELECT "ns"."nspname"
220
            FROM "pg_namespace" AS "ns"
221 2
            WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
222
            ORDER BY "ns"."nspname" ASC
223
            SQL;
224
225
        return $this->getDb()->createCommand($sql)->queryColumn();
226
    }
227
228 2
    /**
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
    protected function findTableNames(string $schema = ''): array
241
    {
242
        if ($schema === '') {
243 5
            $schema = $this->defaultSchema;
244
        }
245 5
246 5
        $sql = <<<'SQL'
247
            SELECT c.relname AS table_name
248
            FROM pg_class c
249 5
            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
        return $this->getDb()->createCommand($sql, [':schemaName' => $schema])->queryColumn();
255
    }
256
257 5
    /**
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
    protected function loadTableSchema(string $name): ?TableSchema
267
    {
268
        $table = new TableSchema();
269 104
270
        $this->resolveTableNames($table, $name);
271 104
272
        if ($this->findColumns($table)) {
273 104
            $this->findConstraints($table);
274
            return $table;
275 104
        }
276 98
277 98
        return null;
278
    }
279
280 17
    /**
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
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
290
    {
291
        $tablePrimaryKey = $this->loadTableConstraints($tableName, 'primaryKey');
292 31
293
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
294 31
    }
295
296 31
    /**
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
    protected function loadTableForeignKeys(string $tableName): array
306
    {
307
        $tableForeignKeys = $this->loadTableConstraints($tableName, 'foreignKeys');
308 4
309
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
310 4
    }
311
312 4
    /**
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
    protected function loadTableIndexes(string $tableName): array
322
    {
323
        $sql = <<<'SQL'
324 28
            SELECT
325
                "ic"."relname" AS "name",
326 28
                "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
        $resolvedName = $this->resolveTableName($tableName);
343
344
        $indexes = $this->getDb()->createCommand($sql, [
345 28
            ':schemaName' => $resolvedName->getSchemaName(),
346
            ':tableName' => $resolvedName->getName(),
347 28
        ])->queryAll();
348 28
349 28
        /** @var array<array-key, array<array-key, mixed>> @indexes */
350 28
        $indexes = $this->normalizePdoRowKeyCase($indexes, true);
351
        $indexes = ArrayHelper::index($indexes, null, 'name');
352
        $result = [];
353 28
354 28
        /**
355 28
         * @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
        foreach ($indexes as $name => $index) {
367
            $ic = (new IndexConstraint())
368
                ->name($name)
369 28
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
370 25
                ->primary($index[0]['index_is_primary'])
371 25
                ->unique($index[0]['index_is_unique']);
372 25
373 25
            $result[] = $ic;
374 25
        }
375
376 25
        return $result;
377
    }
378
379 28
    /**
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
    protected function loadTableUniques(string $tableName): array
389
    {
390
        $tableUniques = $this->loadTableConstraints($tableName, 'uniques');
391 13
392
        return is_array($tableUniques) ? $tableUniques : [];
393 13
    }
394
395 13
    /**
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
    protected function loadTableChecks(string $tableName): array
405
    {
406
        $tableChecks = $this->loadTableConstraints($tableName, 'checks');
407 13
408
        return is_array($tableChecks) ? $tableChecks : [];
409 13
    }
410
411 13
    /**
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
    protected function loadTableDefaultValues(string $tableName): array
0 ignored issues
show
Unused Code introduced by
The parameter $tableName is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

420
    protected function loadTableDefaultValues(/** @scrutinizer ignore-unused */ string $tableName): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
421
    {
422
        throw new NotSupportedException('PostgreSQL does not support default value constraints.');
423 12
    }
424
425 12
    /**
426
     * Creates a query builder for the PostgreSQL database.
427
     *
428
     * @return QueryBuilder query builder instance
429
     */
430
    public function createQueryBuilder(): QueryBuilder
431
    {
432
        return new QueryBuilder($this->getDb());
433 74
    }
434
435 74
    /**
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
    protected function resolveTableNames(TableSchema $table, string $name): void
442
    {
443
        $parts = explode('.', str_replace('"', '', $name));
444 104
445
        if (isset($parts[1])) {
446 104
            $table->schemaName($parts[0]);
447
            $table->name($parts[1]);
448 104
        } else {
449
            $table->schemaName($this->defaultSchema);
450
            $table->name($parts[0]);
451
        }
452 104
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 104
    protected function findViewNames(string $schema = ''): array
463 104
    {
464
        if ($schema === '') {
465 1
            $schema = $this->defaultSchema;
466
        }
467 1
468
        $sql = <<<'SQL'
469
            SELECT c.relname AS table_name
470
            FROM pg_class c
471 1
            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
        return $this->getDb()->createCommand($sql, [':schemaName' => $schema])->queryColumn();
477
    }
478
479 1
    /**
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
    protected function findConstraints(TableSchema $table): void
487
    {
488
        $tableName = $table->getName();
489 98
        $tableSchema = $table->getSchemaName();
490
491 98
        $tableName = $this->quoteValue($tableName);
492 98
493
        if ($tableSchema !== null) {
494 98
            $tableSchema = $this->quoteValue($tableSchema);
495
        }
496 98
497 98
        /**
498
         * We need to extract the constraints de hard way since:
499
         * {@see http://www.postgresql.org/message-id/[email protected]}
500
         */
501
502
        $sql = <<<SQL
503
            SELECT
504
                ct.conname as constraint_name,
505 98
                a.attname as column_name,
506 98
                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 98
                fns.nspname, fc.relname, a.attnum
526 98
            SQL;
527
528
        /** @var array{array{tableName: string, columns: array}} $constraints */
529
        $constraints = [];
530
        $slavePdo = $this->getDb()->getSlavePdo();
531
532 98
        /** @var FindConstraintArray $constraint */
533 98
        foreach ($this->getDb()->createCommand($sql)->queryAll() as $constraint) {
534
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
535
                $constraint = array_change_key_case($constraint, CASE_LOWER);
536 98
            }
537 9
538
            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
            if (!isset($constraints[$name])) {
547 9
                $constraints[$name] = [
548
                    'tableName' => $foreignTable,
549 9
                    'columns' => [],
550
                ];
551 9
            }
552
553
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
554
        }
555
556 9
        /**
557
         * @var int|string $foreingKeyName.
558
         * @var array{tableName: string, columns: array} $constraint
559
         */
560
        foreach ($constraints as $foreingKeyName => $constraint) {
561
            $table->foreignKey(
562
                (string) $foreingKeyName,
563 98
                array_merge([$constraint['tableName']], $constraint['columns'])
564 9
            );
565 9
        }
566 9
    }
567
568
    /**
569 98
     * 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
    protected function getUniqueIndexInformation(TableSchema $table): array
578
    {
579
        $sql = <<<'SQL'
580 1
            SELECT
581
                i.relname as indexname,
582 1
                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
        return $this->getDb()->createCommand($sql, [
596
            ':schemaName' => $table->getSchemaName(),
597
            ':tableName' => $table->getName(),
598 1
        ])->queryAll();
599 1
    }
600 1
601 1
    /**
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
    public function findUniqueIndexes(TableSchema $table): array
620
    {
621
        $uniqueIndexes = [];
622 1
        $slavePdo = $this->getDb()->getSlavePdo();
623
624 1
        /** @var array{indexname: string, columnname: string} $row */
625 1
        foreach ($this->getUniqueIndexInformation($table) as $row) {
626
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
627
                $row = array_change_key_case($row, CASE_LOWER);
628 1
            }
629 1
630 1
            $column = $row['columnname'];
631
632
            if (!empty($column) && $column[0] === '"') {
633 1
                /**
634
                 * postgres will quote names that are not lowercase-only.
635 1
                 *
636
                 * {@see https://github.com/yiisoft/yii2/issues/10613}
637
                 */
638
                $column = substr($column, 1, -1);
639
            }
640
641 1
            $uniqueIndexes[$row['indexname']][] = $column;
642
        }
643
644 1
        return $uniqueIndexes;
645
    }
646
647 1
    /**
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
    protected function findColumns(TableSchema $table): bool
657
    {
658
        $tableName = $table->getName();
659 104
        $schemaName = $table->getSchemaName();
660
        $orIdentity = '';
661 104
662 104
        $tableName = $this->getDb()->quoteValue($tableName);
663 104
664
        if ($schemaName !== null) {
665 104
            $schemaName = $this->getDb()->quoteValue($schemaName);
666
        }
667 104
668 104
        if (version_compare($this->getDb()->getServerVersion(), '12.0', '>=')) {
669
            $orIdentity = 'OR a.attidentity != \'\'';
670
        }
671 104
672 104
        $sql = <<<SQL
673
            SELECT
674
                d.nspname AS table_schema,
675 104
                c.relname AS table_name,
676 104
                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 104
                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 104
                a.attnum;
743 104
            SQL;
744
745
        /** @var array columns */
746
        $columns = $this->getDb()->createCommand($sql)->queryAll();
747
        $slavePdo = $this->getDb()->getSlavePdo();
748
749 104
        if (empty($columns)) {
750 104
            return false;
751
        }
752 104
753 17
        /** @var array<array-key, mixed> $column */
754
        foreach ($columns as $column) {
755
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
756
                $column = array_change_key_case($column, CASE_LOWER);
757 98
            }
758 98
759 1
            /** @psalm-var ColumnArray $column */
760
            $loadColumnSchema = $this->loadColumnSchema($column);
761
            $table->columns($loadColumnSchema->getName(), $loadColumnSchema);
762
763 98
            /** @var mixed $defaultValue */
764 98
            $defaultValue = $loadColumnSchema->getDefaultValue();
765
766
            if ($loadColumnSchema->isPrimaryKey()) {
767 98
                $table->primaryKey($loadColumnSchema->getName());
768
769 98
                if ($table->getSequenceName() === null) {
770 66
                    $table->sequenceName($loadColumnSchema->getSequenceName());
771
                }
772 66
773 66
                $loadColumnSchema->defaultValue(null);
774
            } elseif ($defaultValue) {
775
                if (
776 66
                    is_string($defaultValue) &&
777 95
                    in_array(
778
                        $loadColumnSchema->getType(),
779 56
                        [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME],
780 56
                        true
781 56
                    ) &&
782 56
                    in_array(
783 56
                        strtoupper($defaultValue),
784
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
785 30
                        true
786 30
                    )
787 30
                ) {
788 56
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
789
                } elseif ($loadColumnSchema->getType() === 'boolean') {
790
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
791 28
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
792 56
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
793 52
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
794 33
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
795
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
796 33
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
797 28
                } elseif (
798 33
                    is_string($defaultValue) &&
799 30
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
800
                ) {
801 31
                    if ($matches[2] === 'NULL') {
802 31
                        $loadColumnSchema->defaultValue(null);
803
                    } else {
804 31
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
805 5
                    }
806
                } else {
807 31
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
808
                }
809
            }
810
        }
811
812
        return true;
813
    }
814
815 98
    /**
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
    protected function loadColumnSchema(array $info): ColumnSchema
842
    {
843
        $column = $this->createColumnSchema();
844 98
        $column->allowNull($info['is_nullable']);
845
        $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 98
859 98
        /**
860 98
         * 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
        $defaultValue = $column->getDefaultValue();
866
        $sequenceName = $info['sequence_name'] ?? null;
867
868 98
        if (
869 98
            isset($defaultValue) &&
870
            is_string($defaultValue) &&
871
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
872 98
        ) {
873 98
            $column->sequenceName(preg_replace(
874 98
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
875
                '',
876 60
                $defaultValue
877 60
            ));
878 60
        } elseif ($sequenceName !== null) {
879 60
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
880
        }
881 96
882 5
        if (isset($this->typeMap[$column->getDbType()])) {
883
            $column->type($this->typeMap[$column->getDbType()]);
884
        } else {
885 98
            $column->type(self::TYPE_STRING);
886 98
        }
887
888
        $column->phpType($this->getColumnPhpType($column));
889
890
        return $column;
891 98
    }
892
893 98
    /**
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
    public function insert(string $table, array $columns)
904
    {
905
        $params = [];
906 1
        $returnColumns = [];
907
        $sql = $this->getDb()->getQueryBuilder()->insert($table, $columns, $params);
908 1
        $tableSchema = $this->getTableSchema($table);
909 1
910 1
        if ($tableSchema !== null) {
911 1
            $returnColumns = $tableSchema->getPrimaryKey();
912
        }
913 1
914 1
        if (!empty($returnColumns)) {
915
            $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 1
        }
922
923 1
        $command = $this->getDb()->createCommand($sql, $params);
924
        $command->prepare(false);
925
        $result = $command->queryOne();
926 1
927 1
        $pdoStatement = $command->getPdoStatement();
928 1
929
        return $pdoStatement !== null && !$pdoStatement->rowCount() ? false : $result;
930 1
    }
931
932 1
    /**
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
    private function loadTableConstraints(string $tableName, string $returnType)
949
    {
950
        /** @var string $sql */
951 61
        $sql = <<<'SQL'
952
            SELECT
953
                "c"."conname" AS "name",
954 61
                "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
        $actionTypes = [
981
            'a' => 'NO ACTION',
982
            'r' => 'RESTRICT',
983 61
            'c' => 'CASCADE',
984
            'n' => 'SET NULL',
985
            'd' => 'SET DEFAULT',
986
        ];
987
988
        $resolvedName = $this->resolveTableName($tableName);
989
990
        $constraints = $this->getDb()->createCommand($sql, [
991 61
            ':schemaName' => $resolvedName->getSchemaName(),
992
            ':tableName' => $resolvedName->getName(),
993 61
        ])->queryAll();
994 61
995 61
        /** @var array<array-key, array> $constraints */
996 61
        $constraints = $this->normalizePdoRowKeyCase($constraints, true);
997
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
998
999 61
        $result = [
1000 61
            'primaryKey' => null,
1001
            'foreignKeys' => [],
1002 61
            'uniques' => [],
1003
            'checks' => [],
1004
        ];
1005
1006
        /**
1007
         * @var string $type
1008
         * @var array $names
1009
         */
1010
        foreach ($constraints as $type => $names) {
1011
            /**
1012
             * @psalm-var object|string|null $name
1013 61
             * @psalm-var ConstraintArray $constraint
1014
             */
1015
            foreach ($names as $name => $constraint) {
1016
                switch ($type) {
1017
                    case 'p':
1018 61
                        $ct = (new Constraint())
1019 61
                            ->name($name)
1020 61
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1021 46
1022 46
                        $result['primaryKey'] = $ct;
1023 46
                        break;
1024
                    case 'f':
1025 46
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
1026 46
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
1027 59
1028 13
                        $fk = (new ForeignKeyConstraint())
1029 13
                            ->name($name)
1030
                            ->columnNames(array_values(
1031 13
                                array_unique(ArrayHelper::getColumn($constraint, 'column_name'))
1032 13
                            ))
1033 13
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
1034 13
                            ->foreignTableName($constraint[0]['foreign_table_name'])
1035
                            ->foreignColumnNames(array_values(
1036 13
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
1037 13
                            ))
1038 13
                            ->onDelete($onDelete)
1039 13
                            ->onUpdate($onUpdate);
1040
1041 13
                        $result['foreignKeys'][] = $fk;
1042 13
                        break;
1043
                    case 'u':
1044 13
                        $ct = (new Constraint())
1045 13
                            ->name($name)
1046 47
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1047 46
1048 46
                        $result['uniques'][] = $ct;
1049 46
                        break;
1050
                    case 'c':
1051 46
                        $ck = (new CheckConstraint())
1052 46
                            ->name($name)
1053 10
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
1054 10
                            ->expression($constraint[0]['check_expr']);
1055 10
1056 10
                        $result['checks'][] = $ck;
1057 10
                        break;
1058
                }
1059 10
            }
1060 10
        }
1061
1062
        foreach ($result as $type => $data) {
1063
            $this->setTableMetadata($tableName, $type, $data);
1064
        }
1065 61
1066 61
        return $result[$returnType];
1067
    }
1068
1069 61
    /**
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
    private function createColumnSchema(): ColumnSchema
1077
    {
1078
        return new ColumnSchema();
1079 98
    }
1080
1081 98
    /**
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
    public function createColumnSchemaBuilder(string $type, $length = null): ColumnSchemaBuilder
1092
    {
1093
        return new ColumnSchemaBuilder($type, $length);
1094 4
    }
1095
}
1096