Test Failed
Pull Request — master (#81)
by Def
08:12 queued 05:08
created

Schema::findConstraints()   B

Complexity

Conditions 8
Paths 36

Size

Total Lines 76
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 8.125

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 8
eloc 46
c 4
b 0
f 0
nc 36
nop 1
dl 0
loc 76
ccs 21
cts 24
cp 0.875
crap 8.125
rs 7.9337

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\ConstraintFinderInterface;
14
use Yiisoft\Db\Constraint\ConstraintFinderTrait;
15
use Yiisoft\Db\Constraint\DefaultValueConstraint;
16
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
17
use Yiisoft\Db\Constraint\IndexConstraint;
18
use Yiisoft\Db\Exception\Exception;
19
use Yiisoft\Db\Exception\InvalidConfigException;
20
use Yiisoft\Db\Exception\NotSupportedException;
21
use Yiisoft\Db\Expression\Expression;
22
use Yiisoft\Db\Schema\ColumnSchemaBuilder;
23
use Yiisoft\Db\Schema\Schema as AbstractSchema;
24
use Yiisoft\Db\View\ViewFinderTrait;
25
26
use function array_change_key_case;
27
use function array_merge;
28
use function array_unique;
29
use function array_values;
30
use function bindec;
31
use function explode;
32
use function implode;
33
use function preg_match;
34
use function preg_replace;
35
use function str_replace;
36
use function substr;
37
38
/**
39
 * The class Schema is the class for retrieving metadata from a PostgreSQL database
40
 * (version 9.6 and above).
41
 *
42
 * @psalm-type ColumnArray = array{
43
 *   table_schema: string,
44
 *   table_name: string,
45
 *   column_name: string,
46
 *   data_type: string,
47
 *   type_type: string|null,
48
 *   character_maximum_length: int,
49
 *   column_comment: string|null,
50
 *   modifier: int,
51
 *   is_nullable: bool,
52
 *   column_default: mixed,
53
 *   is_autoinc: bool,
54
 *   sequence_name: string|null,
55
 *   enum_values: array<array-key, float|int|string>|string|null,
56
 *   numeric_precision: int|null,
57
 *   numeric_scale: int|null,
58
 *   size: string|null,
59
 *   is_pkey: bool|null,
60
 *   dimension: int
61
 * }
62
 *
63
 * @psalm-type ConstraintArray = array<
64
 *   array-key,
65
 *   array {
66
 *     name: string,
67
 *     column_name: string,
68
 *     type: string,
69
 *     foreign_table_schema: string|null,
70
 *     foreign_table_name: string|null,
71
 *     foreign_column_name: string|null,
72
 *     on_update: string,
73
 *     on_delete: string,
74
 *     check_expr: string
75
 *   }
76
 * >
77
 *
78
 * @psalm-type FindConstraintArray = array{
79
 *   constraint_name: string,
80
 *   column_name: string,
81
 *   foreign_table_name: string,
82
 *   foreign_table_schema: string,
83
 *   foreign_column_name: string,
84
 * }
85
 */
86
final class Schema extends AbstractSchema implements ConstraintFinderInterface
87
{
88
    use ConstraintFinderTrait;
89
    use ViewFinderTrait;
90
91
    public const TYPE_JSONB = 'jsonb';
92
93
    /**
94
     * @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...
95
     *
96
     * {@see http://www.postgresql.org/docs/current/static/datatype.html#DATATYPE-TABLE}
97
     */
98
    private array $typeMap = [
99
        'bit' => self::TYPE_INTEGER,
100
        'bit varying' => self::TYPE_INTEGER,
101
        'varbit' => self::TYPE_INTEGER,
102
        'bool' => self::TYPE_BOOLEAN,
103
        'boolean' => self::TYPE_BOOLEAN,
104
        'box' => self::TYPE_STRING,
105
        'circle' => self::TYPE_STRING,
106
        'point' => self::TYPE_STRING,
107
        'line' => self::TYPE_STRING,
108
        'lseg' => self::TYPE_STRING,
109
        'polygon' => self::TYPE_STRING,
110
        'path' => self::TYPE_STRING,
111
        'character' => self::TYPE_CHAR,
112
        'char' => self::TYPE_CHAR,
113
        'bpchar' => self::TYPE_CHAR,
114
        'character varying' => self::TYPE_STRING,
115
        'varchar' => self::TYPE_STRING,
116
        'text' => self::TYPE_TEXT,
117
        'bytea' => self::TYPE_BINARY,
118
        'cidr' => self::TYPE_STRING,
119
        'inet' => self::TYPE_STRING,
120
        'macaddr' => self::TYPE_STRING,
121
        'real' => self::TYPE_FLOAT,
122
        'float4' => self::TYPE_FLOAT,
123
        'double precision' => self::TYPE_DOUBLE,
124
        'float8' => self::TYPE_DOUBLE,
125
        'decimal' => self::TYPE_DECIMAL,
126
        'numeric' => self::TYPE_DECIMAL,
127
        'money' => self::TYPE_MONEY,
128
        'smallint' => self::TYPE_SMALLINT,
129
        'int2' => self::TYPE_SMALLINT,
130
        'int4' => self::TYPE_INTEGER,
131
        'int' => self::TYPE_INTEGER,
132
        'integer' => self::TYPE_INTEGER,
133
        'bigint' => self::TYPE_BIGINT,
134
        'int8' => self::TYPE_BIGINT,
135
        'oid' => self::TYPE_BIGINT, // should not be used. it's pg internal!
136
        'smallserial' => self::TYPE_SMALLINT,
137
        'serial2' => self::TYPE_SMALLINT,
138
        'serial4' => self::TYPE_INTEGER,
139
        'serial' => self::TYPE_INTEGER,
140
        'bigserial' => self::TYPE_BIGINT,
141
        'serial8' => self::TYPE_BIGINT,
142
        'pg_lsn' => self::TYPE_BIGINT,
143
        'date' => self::TYPE_DATE,
144
        'interval' => self::TYPE_STRING,
145
        'time without time zone' => self::TYPE_TIME,
146
        'time' => self::TYPE_TIME,
147
        'time with time zone' => self::TYPE_TIME,
148
        'timetz' => self::TYPE_TIME,
149
        'timestamp without time zone' => self::TYPE_TIMESTAMP,
150
        'timestamp' => self::TYPE_TIMESTAMP,
151
        'timestamp with time zone' => self::TYPE_TIMESTAMP,
152
        'timestamptz' => self::TYPE_TIMESTAMP,
153
        'abstime' => self::TYPE_TIMESTAMP,
154
        'tsquery' => self::TYPE_STRING,
155
        'tsvector' => self::TYPE_STRING,
156
        'txid_snapshot' => self::TYPE_STRING,
157
        'unknown' => self::TYPE_STRING,
158
        'uuid' => self::TYPE_STRING,
159
        'json' => self::TYPE_JSON,
160
        'jsonb' => self::TYPE_JSON,
161
        'xml' => self::TYPE_STRING,
162
    ];
163
164
    /**
165
     * @var string|null the default schema used for the current session.
166
     */
167
    protected ?string $defaultSchema = 'public';
168
169
    /**
170
     * @var string|string[] character used to quote schema, table, etc. names. An array of 2 characters can be used in
171
     * case starting and ending characters are different.
172
     */
173
    protected $tableQuoteCharacter = '"';
174
175
    /**
176
     * Resolves the table name and schema name (if any).
177
     *
178
     * @param string $name the table name.
179
     *
180
     * @return TableSchema with resolved table, schema, etc. names.
181
     *
182
     * {@see TableSchema}
183
     */
184 76
    protected function resolveTableName(string $name): TableSchema
185
    {
186 76
        $resolvedName = new TableSchema();
187
188 76
        $parts = explode('.', str_replace('"', '', $name));
189
190 76
        if (isset($parts[1])) {
191 5
            $resolvedName->schemaName($parts[0]);
192 5
            $resolvedName->name($parts[1]);
193
            $resolvedName->comment($this->getTableComment($parts[0], $parts[1]));
194 71
        } else {
195 71
            $resolvedName->schemaName($this->defaultSchema);
196
            $resolvedName->name($name);
197
            $resolvedName->comment($this->getTableComment($this->defaultSchema, $name));
198 76
        }
199
200 76
        $resolvedName->fullName(
201
            (
202 76
                $resolvedName->getSchemaName() !== $this->defaultSchema ?
203 76
                    (string) $resolvedName->getSchemaName() . '.' :
204
                    ''
205
            ) . $resolvedName->getName()
206 76
        );
207
208
        return $resolvedName;
209
    }
210
211
    /**
212
     * Returns all schema names in the database, including the default one but not system schemas.
213
     *
214
     * This method should be overridden by child classes in order to support this feature because the default
215
     * implementation simply throws an exception.
216
     *
217
     * @throws Exception|InvalidConfigException|Throwable
218
     *
219 2
     * @return array all schema names in the database, except system schemas.
220
     */
221 2
    protected function findSchemaNames(): array
222
    {
223
        $sql = <<<'SQL'
224
            SELECT "ns"."nspname"
225
            FROM "pg_namespace" AS "ns"
226
            WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
227
            ORDER BY "ns"."nspname" ASC
228 2
            SQL;
229
230
        return $this->getDb()->createCommand($sql)->queryColumn();
231
    }
232
233
    /**
234
     * Returns all table names in the database.
235
     *
236
     * This method should be overridden by child classes in order to support this feature because the default
237
     * implementation simply throws an exception.
238
     *
239
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
240
     *
241
     * @throws Exception|InvalidConfigException|Throwable
242
     *
243 5
     * @return array all table names in the database. The names have NO schema name prefix.
244
     */
245 5
    protected function findTableNames(string $schema = ''): array
246 5
    {
247
        if ($schema === '') {
248
            $schema = $this->defaultSchema;
249 5
        }
250
251
        $sql = <<<'SQL'
252
            SELECT c.relname AS table_name
253
            FROM pg_class c
254
            INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
255
            WHERE ns.nspname = :schemaName AND c.relkind IN ('r','v','m','f', 'p')
256
            ORDER BY c.relname
257 5
            SQL;
258
259
        return $this->getDb()->createCommand($sql, [':schemaName' => $schema])->queryColumn();
260
    }
261
262
    /**
263
     * Loads the metadata for the specified table.
264
     *
265
     * @param string $name table name.
266
     *
267
     * @throws Exception|InvalidConfigException
268
     *
269 104
     * @return TableSchema|null DBMS-dependent table metadata, `null` if the table does not exist.
270
     */
271 104
    protected function loadTableSchema(string $name): ?TableSchema
272
    {
273 104
        $table = new TableSchema();
274
275 104
        $this->resolveTableNames($table, $name);
276 98
277 98
        if ($this->findColumns($table)) {
278
            $this->findConstraints($table);
279
            return $table;
280 17
        }
281
282
        return null;
283
    }
284
285
    /**
286
     * Loads a primary key for the given table.
287
     *
288
     * @param string $tableName table name.
289
     *
290
     * @throws Exception|InvalidConfigException
291
     *
292 31
     * @return Constraint|null primary key for the given table, `null` if the table has no primary key.
293
     */
294 31
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
295
    {
296 31
        $tablePrimaryKey = $this->loadTableConstraints($tableName, 'primaryKey');
297
298
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
299
    }
300
301
    /**
302
     * Loads all foreign keys for the given table.
303
     *
304
     * @param string $tableName table name.
305
     *
306
     * @throws Exception|InvalidConfigException
307
     *
308 4
     * @return array|ForeignKeyConstraint[] foreign keys for the given table.
309
     */
310 4
    protected function loadTableForeignKeys(string $tableName): array
311
    {
312 4
        $tableForeignKeys = $this->loadTableConstraints($tableName, 'foreignKeys');
313
314
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
315
    }
316
317
    /**
318
     * Loads all indexes for the given table.
319
     *
320
     * @param string $tableName table name.
321
     *
322
     * @throws Exception|InvalidConfigException|Throwable
323
     *
324 28
     * @return IndexConstraint[] indexes for the given table.
325
     */
326 28
    protected function loadTableIndexes(string $tableName): array
327
    {
328
        $sql = <<<'SQL'
329
            SELECT
330
                "ic"."relname" AS "name",
331
                "ia"."attname" AS "column_name",
332
                "i"."indisunique" AS "index_is_unique",
333
                "i"."indisprimary" AS "index_is_primary"
334
            FROM "pg_class" AS "tc"
335
            INNER JOIN "pg_namespace" AS "tcns"
336
                ON "tcns"."oid" = "tc"."relnamespace"
337
            INNER JOIN "pg_index" AS "i"
338
                ON "i"."indrelid" = "tc"."oid"
339
            INNER JOIN "pg_class" AS "ic"
340
                ON "ic"."oid" = "i"."indexrelid"
341
            INNER JOIN "pg_attribute" AS "ia"
342
                ON "ia"."attrelid" = "i"."indexrelid"
343
            WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
344
            ORDER BY "ia"."attnum" ASC
345 28
            SQL;
346
347 28
        $resolvedName = $this->resolveTableName($tableName);
348 28
349 28
        $indexes = $this->getDb()->createCommand($sql, [
350 28
            ':schemaName' => $resolvedName->getSchemaName(),
351
            ':tableName' => $resolvedName->getName(),
352
        ])->queryAll();
353 28
354 28
        /** @var array<array-key, array<array-key, mixed>> @indexes */
355 28
        $indexes = $this->normalizePdoRowKeyCase($indexes, true);
356
        $indexes = ArrayHelper::index($indexes, null, 'name');
357
        $result = [];
358
359
        /**
360
         * @var object|string|null $name
361
         * @var array<
362
         *   array-key,
363
         *   array{
364
         *     name: string,
365
         *     column_name: string,
366
         *     index_is_unique: bool,
367
         *     index_is_primary: bool
368
         *   }
369 28
         * > $index
370 25
         */
371 25
        foreach ($indexes as $name => $index) {
372 25
            $ic = (new IndexConstraint())
373 25
                ->name($name)
374 25
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
375
                ->primary($index[0]['index_is_primary'])
376 25
                ->unique($index[0]['index_is_unique']);
377
378
            $result[] = $ic;
379 28
        }
380
381
        return $result;
382
    }
383
384
    /**
385
     * Loads all unique constraints for the given table.
386
     *
387
     * @param string $tableName table name.
388
     *
389
     * @throws Exception|InvalidConfigException
390
     *
391 13
     * @return array|Constraint[] unique constraints for the given table.
392
     */
393 13
    protected function loadTableUniques(string $tableName): array
394
    {
395 13
        $tableUniques = $this->loadTableConstraints($tableName, 'uniques');
396
397
        return is_array($tableUniques) ? $tableUniques : [];
398
    }
399
400
    /**
401
     * Loads all check constraints for the given table.
402
     *
403
     * @param string $tableName table name.
404
     *
405
     * @throws Exception|InvalidConfigException
406
     *
407 13
     * @return array|CheckConstraint[] check constraints for the given table.
408
     */
409 13
    protected function loadTableChecks(string $tableName): array
410
    {
411 13
        $tableChecks = $this->loadTableConstraints($tableName, 'checks');
412
413
        return is_array($tableChecks) ? $tableChecks : [];
414
    }
415
416
    /**
417
     * Loads all default value constraints for the given table.
418
     *
419
     * @param string $tableName table name.
420
     *
421
     * @throws NotSupportedException
422
     *
423 12
     * @return DefaultValueConstraint[] default value constraints for the given table.
424
     */
425 12
    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

425
    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...
426
    {
427
        throw new NotSupportedException('PostgreSQL does not support default value constraints.');
428
    }
429
430
    /**
431
     * Creates a query builder for the PostgreSQL database.
432
     *
433 74
     * @return QueryBuilder query builder instance
434
     */
435 74
    public function createQueryBuilder(): QueryBuilder
436
    {
437
        return new QueryBuilder($this->getDb());
438
    }
439
440
    /**
441
     * Resolves the table name and schema name (if any).
442
     *
443
     * @param TableSchema $table the table metadata object.
444 104
     * @param string $name the table name
445
     */
446 104
    protected function resolveTableNames(TableSchema $table, string $name): void
447
    {
448 104
        $parts = explode('.', str_replace('"', '', $name));
449
450
        if (isset($parts[1])) {
451
            $table->schemaName($parts[0]);
452 104
            $table->name($parts[1]);
453 104
            $table->comment($this->getTableComment($parts[0], $parts[1]));
454
        } else {
455
            $table->schemaName($this->defaultSchema);
456 104
            $table->name($parts[0]);
457
            $table->comment($this->getTableComment($this->defaultSchema, $parts[0]));
458
        }
459 104
460
        if ($table->getSchemaName() !== $this->defaultSchema) {
461
            $name = (string) $table->getSchemaName() . '.' . $table->getName();
462 104
        } else {
463 104
            $name = $table->getName();
464
        }
465 1
466
        $table->fullName($name);
467 1
    }
468
469
    protected function findViewNames(string $schema = ''): array
470
    {
471 1
        if ($schema === '') {
472
            $schema = $this->defaultSchema;
473
        }
474
475
        $sql = <<<'SQL'
476
            SELECT c.relname AS table_name
477
            FROM pg_class c
478
            INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
479 1
            WHERE ns.nspname = :schemaName AND (c.relkind = 'v' OR c.relkind = 'm')
480
            ORDER BY c.relname
481
            SQL;
482
483
        return $this->getDb()->createCommand($sql, [':schemaName' => $schema])->queryColumn();
484
    }
485
486
    /**
487
     * Collects the foreign key column details for the given table.
488
     *
489 98
     * @param TableSchema $table the table metadata
490
     *
491 98
     * @throws Exception|InvalidConfigException|Throwable
492 98
     */
493
    protected function findConstraints(TableSchema $table): void
494 98
    {
495
        $tableSchema = $table->getSchemaName();
496 98
        $tableName = $this->quoteValue($table->getName());
497 98
498
        if ($tableSchema !== null) {
499
            $tableSchema = $this->quoteValue($tableSchema);
500
        }
501
502
        /**
503
         * We need to extract the constraints de hard way since:
504
         * {@see http://www.postgresql.org/message-id/[email protected]}
505 98
         */
506 98
507
        $sql = <<<SQL
508
            SELECT
509
                ct.conname as constraint_name,
510
                a.attname as column_name,
511
                fc.relname as foreign_table_name,
512
                fns.nspname as foreign_table_schema,
513
                fa.attname as foreign_column_name
514
            FROM
515
                (SELECT ct.conname, ct.conrelid, ct.confrelid, ct.conkey, ct.contype, ct.confkey,
516
                        generate_subscripts(ct.conkey, 1) AS s
517
                   FROM pg_constraint ct
518
                ) AS ct
519
                inner join pg_class c on c.oid=ct.conrelid
520
                inner join pg_namespace ns on c.relnamespace=ns.oid
521
                inner join pg_attribute a on a.attrelid=ct.conrelid and a.attnum = ct.conkey[ct.s]
522
                left join pg_class fc on fc.oid=ct.confrelid
523
                left join pg_namespace fns on fc.relnamespace=fns.oid
524
                left join pg_attribute fa on fa.attrelid=ct.confrelid and fa.attnum = ct.confkey[ct.s]
525 98
            WHERE
526 98
                ct.contype='f'
527
                and c.relname={$tableName}
528
                and ns.nspname={$tableSchema}
529
            ORDER BY
530
                fns.nspname, fc.relname, a.attnum
531
            SQL;
532 98
533 98
        /** @var array{array{tableName: string, columns: array}} $constraints */
534
        $constraints = [];
535
        $slavePdo = $this->getDb()->getSlavePdo();
536 98
537 9
        /** @var FindConstraintArray $constraint */
538
        foreach ($this->getDb()->createCommand($sql)->queryAll() as $constraint) {
539
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
540
                $constraint = array_change_key_case($constraint, CASE_LOWER);
541 9
            }
542
543
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
544 9
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
545
            } else {
546
                $foreignTable = $constraint['foreign_table_name'];
547 9
            }
548
549 9
            $name = $constraint['constraint_name'];
550
551 9
            if (!isset($constraints[$name])) {
552
                $constraints[$name] = [
553
                    'tableName' => $foreignTable,
554
                    'columns' => [],
555
                ];
556 9
            }
557
558
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
559
        }
560
561
        /**
562
         * @var int|string $foreingKeyName.
563 98
         * @var array{tableName: string, columns: array} $constraint
564 9
         */
565 9
        foreach ($constraints as $foreingKeyName => $constraint) {
566 9
            $table->foreignKey(
567
                (string) $foreingKeyName,
568
                array_merge([$constraint['tableName']], $constraint['columns'])
569 98
            );
570
        }
571
    }
572
573
    /**
574
     * Gets information about given table unique indexes.
575
     *
576
     * @param TableSchema $table the table metadata.
577
     *
578
     * @throws Exception|InvalidConfigException|Throwable
579
     *
580 1
     * @return array with index and column names.
581
     */
582 1
    protected function getUniqueIndexInformation(TableSchema $table): array
583
    {
584
        $sql = <<<'SQL'
585
            SELECT
586
                i.relname as indexname,
587
                pg_get_indexdef(idx.indexrelid, k + 1, TRUE) AS columnname
588
            FROM (
589
              SELECT *, generate_subscripts(indkey, 1) AS k
590
              FROM pg_index
591
            ) idx
592
            INNER JOIN pg_class i ON i.oid = idx.indexrelid
593
            INNER JOIN pg_class c ON c.oid = idx.indrelid
594
            INNER JOIN pg_namespace ns ON c.relnamespace = ns.oid
595
            WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
596
            AND c.relname = :tableName AND ns.nspname = :schemaName
597
            ORDER BY i.relname, k
598 1
            SQL;
599 1
600 1
        return $this->getDb()->createCommand($sql, [
601 1
            ':schemaName' => $table->getSchemaName(),
602
            ':tableName' => $table->getName(),
603
        ])->queryAll();
604
    }
605
606
    /**
607
     * Returns all unique indexes for the given table.
608
     *
609
     * Each array element is of the following structure:
610
     *
611
     * ```php
612
     * [
613
     *     'IndexName1' => ['col1' [, ...]],
614
     *     'IndexName2' => ['col2' [, ...]],
615
     * ]
616
     * ```
617
     *
618
     * @param TableSchema $table the table metadata
619
     *
620
     * @throws Exception|InvalidConfigException|Throwable
621
     *
622 1
     * @return array all unique indexes for the given table.
623
     */
624 1
    public function findUniqueIndexes(TableSchema $table): array
625 1
    {
626
        $uniqueIndexes = [];
627
        $slavePdo = $this->getDb()->getSlavePdo();
628 1
629 1
        /** @var array{indexname: string, columnname: string} $row */
630 1
        foreach ($this->getUniqueIndexInformation($table) as $row) {
631
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
632
                $row = array_change_key_case($row, CASE_LOWER);
633 1
            }
634
635 1
            $column = $row['columnname'];
636
637
            if (!empty($column) && $column[0] === '"') {
638
                /**
639
                 * postgres will quote names that are not lowercase-only.
640
                 *
641 1
                 * {@see https://github.com/yiisoft/yii2/issues/10613}
642
                 */
643
                $column = substr($column, 1, -1);
644 1
            }
645
646
            $uniqueIndexes[$row['indexname']][] = $column;
647 1
        }
648
649
        return $uniqueIndexes;
650
    }
651
652
    /**
653
     * Collects the metadata of table columns.
654
     *
655
     * @param TableSchema $table the table metadata.
656
     *
657
     * @throws Exception|InvalidConfigException|JsonException|Throwable
658
     *
659 104
     * @return bool whether the table exists in the database.
660
     */
661 104
    protected function findColumns(TableSchema $table): bool
662 104
    {
663 104
        $tableName = $this->quoteValue($table->getName());
664
        $schemaName = $table->getSchemaName();
665 104
        $orIdentity = '';
666
667 104
        if ($schemaName !== null) {
668 104
            $schemaName = $this->quoteValue($schemaName);
669
        }
670
671 104
        if (version_compare($this->getDb()->getServerVersion(), '12.0', '>=')) {
672 104
            $orIdentity = 'OR a.attidentity != \'\'';
673
        }
674
675 104
        $sql = <<<SQL
676 104
            SELECT
677
                d.nspname AS table_schema,
678
                c.relname AS table_name,
679
                a.attname AS column_name,
680
                COALESCE(td.typname, tb.typname, t.typname) AS data_type,
681
                COALESCE(td.typtype, tb.typtype, t.typtype) AS type_type,
682
                a.attlen AS character_maximum_length,
683
                pg_catalog.col_description(c.oid, a.attnum) AS column_comment,
684
                a.atttypmod AS modifier,
685
                a.attnotnull = false AS is_nullable,
686
                CAST(pg_get_expr(ad.adbin, ad.adrelid) AS varchar) AS column_default,
687 104
                coalesce(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval',false) {$orIdentity} AS is_autoinc,
688
                pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname)
689
                    AS sequence_name,
690
                CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
691
                    THEN array_to_string(
692
                        (
693
                            SELECT array_agg(enumlabel)
694
                            FROM pg_enum
695
                            WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid)
696
                            )::varchar[],
697
                        ',')
698
                    ELSE NULL
699
                END AS enum_values,
700
                CASE atttypid
701
                     WHEN 21 /*int2*/ THEN 16
702
                     WHEN 23 /*int4*/ THEN 32
703
                     WHEN 20 /*int8*/ THEN 64
704
                     WHEN 1700 /*numeric*/ THEN
705
                          CASE WHEN atttypmod = -1
706
                           THEN null
707
                           ELSE ((atttypmod - 4) >> 16) & 65535
708
                           END
709
                     WHEN 700 /*float4*/ THEN 24 /*FLT_MANT_DIG*/
710
                     WHEN 701 /*float8*/ THEN 53 /*DBL_MANT_DIG*/
711
                     ELSE null
712
                  END   AS numeric_precision,
713
                  CASE
714
                    WHEN atttypid IN (21, 23, 20) THEN 0
715
                    WHEN atttypid IN (1700) THEN
716
                    CASE
717
                        WHEN atttypmod = -1 THEN null
718
                        ELSE (atttypmod - 4) & 65535
719
                    END
720
                       ELSE null
721
                  END AS numeric_scale,
722
                CAST(
723
                         information_schema._pg_char_max_length(
724
                             information_schema._pg_truetypid(a, t),
725
                             information_schema._pg_truetypmod(a, t)
726
                             ) AS numeric
727
                ) AS size,
728
                a.attnum = any (ct.conkey) as is_pkey,
729
                COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
730
            FROM
731
                pg_class c
732
                LEFT JOIN pg_attribute a ON a.attrelid = c.oid
733
                LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
734
                LEFT JOIN pg_type t ON a.atttypid = t.oid
735
                LEFT JOIN pg_type tb ON (a.attndims > 0 OR t.typcategory='A') AND t.typelem > 0 AND t.typelem = tb.oid
736
                                            OR t.typbasetype > 0 AND t.typbasetype = tb.oid
737
                LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
738
                LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
739
                LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p'
740
            WHERE
741
                a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
742 104
                AND c.relname = {$tableName}
743 104
                AND d.nspname = {$schemaName}
744
            ORDER BY
745
                a.attnum;
746
            SQL;
747
748
        /** @var array columns */
749 104
        $columns = $this->getDb()->createCommand($sql)->queryAll();
750 104
        $slavePdo = $this->getDb()->getSlavePdo();
751
752 104
        if (empty($columns)) {
753 17
            return false;
754
        }
755
756
        /** @var array<array-key, mixed> $column */
757 98
        foreach ($columns as $column) {
758 98
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
759 1
                $column = array_change_key_case($column, CASE_LOWER);
760
            }
761
762
            /** @psalm-var ColumnArray $column */
763 98
            $loadColumnSchema = $this->loadColumnSchema($column);
764 98
            $table->columns($loadColumnSchema->getName(), $loadColumnSchema);
765
766
            /** @var mixed $defaultValue */
767 98
            $defaultValue = $loadColumnSchema->getDefaultValue();
768
769 98
            if ($loadColumnSchema->isPrimaryKey()) {
770 66
                $table->primaryKey($loadColumnSchema->getName());
771
772 66
                if ($table->getSequenceName() === null) {
773 66
                    $table->sequenceName($loadColumnSchema->getSequenceName());
774
                }
775
776 66
                $loadColumnSchema->defaultValue(null);
777 95
            } elseif ($defaultValue) {
778
                if (
779 56
                    is_string($defaultValue) &&
780 56
                    in_array(
781 56
                        $loadColumnSchema->getType(),
782 56
                        [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME],
783 56
                        true
784
                    ) &&
785 30
                    in_array(
786 30
                        strtoupper($defaultValue),
787 30
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
788 56
                        true
789
                    )
790
                ) {
791 28
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
792 56
                } elseif ($loadColumnSchema->getType() === 'boolean') {
793 52
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
794 33
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
795
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
796 33
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
797 28
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
798 33
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
799 30
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
800
                } elseif (
801 31
                    is_string($defaultValue) &&
802 31
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
803
                ) {
804 31
                    if ($matches[2] === 'NULL') {
805 5
                        $loadColumnSchema->defaultValue(null);
806
                    } else {
807 31
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
808
                    }
809
                } else {
810
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
811
                }
812
            }
813
        }
814
815 98
        return true;
816
    }
817
818
    /**
819
     * Loads the column information into a {@see ColumnSchema} object.
820
     *
821
     * @param array{
822
     *   table_schema: string,
823
     *   table_name: string,
824
     *   column_name: string,
825
     *   data_type: string,
826
     *   type_type: string|null,
827
     *   character_maximum_length: int,
828
     *   column_comment: string|null,
829
     *   modifier: int,
830
     *   is_nullable: bool,
831
     *   column_default: mixed,
832
     *   is_autoinc: bool,
833
     *   sequence_name: string|null,
834
     *   enum_values: array<array-key, float|int|string>|string|null,
835
     *   numeric_precision: int|null,
836
     *   numeric_scale: int|null,
837
     *   size: string|null,
838
     *   is_pkey: bool|null,
839
     *   dimension: int
840
     * } $info column information.
841
     *
842
     * @return ColumnSchema the column schema object.
843
     */
844 98
    protected function loadColumnSchema(array $info): ColumnSchema
845
    {
846 98
        $column = $this->createColumnSchema();
847 98
        $column->allowNull($info['is_nullable']);
848 98
        $column->autoIncrement($info['is_autoinc']);
849 98
        $column->comment($info['column_comment']);
850 98
        $column->dbType($info['data_type']);
851 98
        $column->defaultValue($info['column_default']);
852 98
        $column->enumValues(($info['enum_values'] !== null)
853 98
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null);
854 98
        $column->unsigned(false); // has no meaning in PG
855 98
        $column->primaryKey((bool) $info['is_pkey']);
856 98
        $column->name($info['column_name']);
857 98
        $column->precision($info['numeric_precision']);
858 98
        $column->scale($info['numeric_scale']);
859 98
        $column->size($info['size'] === null ? null : (int) $info['size']);
860 98
        $column->dimension($info['dimension']);
861
862
        /**
863
         * pg_get_serial_sequence() doesn't track DEFAULT value change. GENERATED BY IDENTITY columns always have null
864
         * default value.
865
         *
866
         * @var mixed $defaultValue
867
         */
868 98
        $defaultValue = $column->getDefaultValue();
869 98
        $sequenceName = $info['sequence_name'] ?? null;
870
871
        if (
872 98
            isset($defaultValue) &&
873 98
            is_string($defaultValue) &&
874 98
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
875
        ) {
876 60
            $column->sequenceName(preg_replace(
877 60
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
878 60
                '',
879 60
                $defaultValue
880
            ));
881 96
        } elseif ($sequenceName !== null) {
882 5
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
883
        }
884
885 98
        if (isset($this->typeMap[$column->getDbType()])) {
886 98
            $column->type($this->typeMap[$column->getDbType()]);
887
        } else {
888
            $column->type(self::TYPE_STRING);
889
        }
890
891 98
        $column->phpType($this->getColumnPhpType($column));
892
893 98
        return $column;
894
    }
895
896
    /**
897
     * Executes the INSERT command, returning primary key values.
898
     *
899
     * @param string $table the table that new rows will be inserted into.
900
     * @param array $columns the column data (name => value) to be inserted into the table.
901
     *
902
     * @throws Exception|InvalidConfigException|Throwable
903
     *
904
     * @return array|false primary key values or false if the command fails.
905
     */
906 1
    public function insert(string $table, array $columns)
907
    {
908 1
        $params = [];
909 1
        $returnColumns = [];
910 1
        $sql = $this->getDb()->getQueryBuilder()->insert($table, $columns, $params);
911 1
        $tableSchema = $this->getTableSchema($table);
912
913 1
        if ($tableSchema !== null) {
914 1
            $returnColumns = $tableSchema->getPrimaryKey();
915
        }
916
917 1
        if (!empty($returnColumns)) {
918 1
            $returning = [];
919
            /** @var string $name */
920 1
            foreach ($returnColumns as $name) {
921 1
                $returning[] = $this->quoteColumnName($name);
922
            }
923 1
            $sql .= ' RETURNING ' . implode(', ', $returning);
924
        }
925
926 1
        $command = $this->getDb()->createCommand($sql, $params);
927 1
        $command->prepare(false);
928 1
        $result = $command->queryOne();
929
930 1
        $pdoStatement = $command->getPdoStatement();
931
932 1
        return $pdoStatement !== null && !$pdoStatement->rowCount() ? false : $result;
933
    }
934
935
    /**
936
     * Loads multiple types of constraints and returns the specified ones.
937
     *
938
     * @param string $tableName table name.
939
     * @param string $returnType return type:
940
     * - primaryKey
941
     * - foreignKeys
942
     * - uniques
943
     * - checks
944
     *
945
     * @throws Exception|InvalidConfigException|Throwable
946
     *
947
     * @return (CheckConstraint|Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
948
     *
949
     * @psalm-return Constraint|list<CheckConstraint|Constraint|ForeignKeyConstraint>|null
950
     */
951 61
    private function loadTableConstraints(string $tableName, string $returnType)
952
    {
953
        /** @var string $sql */
954 61
        $sql = <<<'SQL'
955
            SELECT
956
                "c"."conname" AS "name",
957
                "a"."attname" AS "column_name",
958
                "c"."contype" AS "type",
959
                "ftcns"."nspname" AS "foreign_table_schema",
960
                "ftc"."relname" AS "foreign_table_name",
961
                "fa"."attname" AS "foreign_column_name",
962
                "c"."confupdtype" AS "on_update",
963
                "c"."confdeltype" AS "on_delete",
964
                pg_get_constraintdef("c"."oid") AS "check_expr"
965
            FROM "pg_class" AS "tc"
966
            INNER JOIN "pg_namespace" AS "tcns"
967
                ON "tcns"."oid" = "tc"."relnamespace"
968
            INNER JOIN "pg_constraint" AS "c"
969
                ON "c"."conrelid" = "tc"."oid"
970
            INNER JOIN "pg_attribute" AS "a"
971
                ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
972
            LEFT JOIN "pg_class" AS "ftc"
973
                ON "ftc"."oid" = "c"."confrelid"
974
            LEFT JOIN "pg_namespace" AS "ftcns"
975
                ON "ftcns"."oid" = "ftc"."relnamespace"
976
            LEFT JOIN "pg_attribute" "fa"
977
                ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
978
            WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
979
            ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
980
            SQL;
981
982
        /** @var array<array-key, string> $actionTypes */
983 61
        $actionTypes = [
984
            'a' => 'NO ACTION',
985
            'r' => 'RESTRICT',
986
            'c' => 'CASCADE',
987
            'n' => 'SET NULL',
988
            'd' => 'SET DEFAULT',
989
        ];
990
991 61
        $resolvedName = $this->resolveTableName($tableName);
992
993 61
        $constraints = $this->getDb()->createCommand($sql, [
994 61
            ':schemaName' => $resolvedName->getSchemaName(),
995 61
            ':tableName' => $resolvedName->getName(),
996 61
        ])->queryAll();
997
998
        /** @var array<array-key, array> $constraints */
999 61
        $constraints = $this->normalizePdoRowKeyCase($constraints, true);
1000 61
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
1001
1002 61
        $result = [
1003
            'primaryKey' => null,
1004
            'foreignKeys' => [],
1005
            'uniques' => [],
1006
            'checks' => [],
1007
        ];
1008
1009
        /**
1010
         * @var string $type
1011
         * @var array $names
1012
         */
1013 61
        foreach ($constraints as $type => $names) {
1014
            /**
1015
             * @psalm-var object|string|null $name
1016
             * @psalm-var ConstraintArray $constraint
1017
             */
1018 61
            foreach ($names as $name => $constraint) {
1019 61
                switch ($type) {
1020 61
                    case 'p':
1021 46
                        $ct = (new Constraint())
1022 46
                            ->name($name)
1023 46
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1024
1025 46
                        $result['primaryKey'] = $ct;
1026 46
                        break;
1027 59
                    case 'f':
1028 13
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
1029 13
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
1030
1031 13
                        $fk = (new ForeignKeyConstraint())
1032 13
                            ->name($name)
1033 13
                            ->columnNames(array_values(
1034 13
                                array_unique(ArrayHelper::getColumn($constraint, 'column_name'))
1035
                            ))
1036 13
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
1037 13
                            ->foreignTableName($constraint[0]['foreign_table_name'])
1038 13
                            ->foreignColumnNames(array_values(
1039 13
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
1040
                            ))
1041 13
                            ->onDelete($onDelete)
1042 13
                            ->onUpdate($onUpdate);
1043
1044 13
                        $result['foreignKeys'][] = $fk;
1045 13
                        break;
1046 47
                    case 'u':
1047 46
                        $ct = (new Constraint())
1048 46
                            ->name($name)
1049 46
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1050
1051 46
                        $result['uniques'][] = $ct;
1052 46
                        break;
1053 10
                    case 'c':
1054 10
                        $ck = (new CheckConstraint())
1055 10
                            ->name($name)
1056 10
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
1057 10
                            ->expression($constraint[0]['check_expr']);
1058
1059 10
                        $result['checks'][] = $ck;
1060 10
                        break;
1061
                }
1062
            }
1063
        }
1064
1065 61
        foreach ($result as $type => $data) {
1066 61
            $this->setTableMetadata($tableName, $type, $data);
1067
        }
1068
1069 61
        return $result[$returnType];
1070
    }
1071
1072
    /**
1073
     * Creates a column schema for the database.
1074
     *
1075
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1076
     *
1077
     * @return ColumnSchema column schema instance.
1078
     */
1079 98
    private function createColumnSchema(): ColumnSchema
1080
    {
1081 98
        return new ColumnSchema();
1082
    }
1083
1084
    /**
1085
     * Create a column schema builder instance giving the type and value precision.
1086
     *
1087
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
1088
     *
1089
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
1090
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
1091
     *
1092
     * @return ColumnSchemaBuilder column schema builder instance
1093
     */
1094 4
    public function createColumnSchemaBuilder(string $type, $length = null): ColumnSchemaBuilder
1095
    {
1096 4
        return new ColumnSchemaBuilder($type, $length);
1097
    }
1098
1099
    private function getTableComment(?string $schema, string $name): ?string
1100
    {
1101
        $sql = "SELECT obj_description(oid, 'pg_class') FROM pg_class WHERE relname=" . $this->quoteValue($name);
1102
1103
        if ($schema !== null) {
1104
            $sql .= ' AND relnamespace=' . $this->quoteValue($schema) . '::regnamespace';
1105
        }
1106
1107
        $comment = $this->getDb()->createCommand($sql)->queryScalar();
1108
1109
        return $comment === false ? null : $comment;
1110
    }
1111
}
1112