Passed
Pull Request — master (#81)
by Wilmer
02:52
created

Schema::findConstraints()   B

Complexity

Conditions 9
Paths 72

Size

Total Lines 80
Code Lines 48

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 9.0995

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 9
eloc 48
c 3
b 0
f 0
nc 72
nop 1
dl 0
loc 80
ccs 25
cts 28
cp 0.8929
crap 9.0995
rs 7.5789

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