Passed
Pull Request — master (#93)
by Wilmer
12:16
created

Schema   F

Complexity

Total Complexity 87

Size/Duplication

Total Lines 1023
Duplicated Lines 0 %

Test Coverage

Coverage 96.13%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 466
c 3
b 0
f 0
dl 0
loc 1023
ccs 273
cts 284
cp 0.9613
rs 2
wmc 87

22 Methods

Rating   Name   Duplication   Size   Complexity  
B findConstraints() 0 78 8
D findColumns() 0 157 23
A loadTableForeignKeys() 0 5 2
A createColumnSchema() 0 3 1
B loadColumnSchema() 0 50 8
A loadTableUniques() 0 5 2
A findUniqueIndexes() 0 26 6
B loadTableConstraints() 0 119 8
A getUniqueIndexInformation() 0 22 1
A findSchemaNames() 0 10 1
A resolveTableName() 0 23 3
A loadTableIndexes() 0 56 2
A createColumnSchemaBuilder() 0 3 1
A loadTableSchema() 0 12 2
A loadTableChecks() 0 5 2
A resolveTableNames() 0 19 3
A loadTablePrimaryKey() 0 5 2
A loadTableDefaultValues() 0 3 1
A createQueryBuilder() 0 3 1
A findTableNames() 0 15 2
A findViewNames() 0 15 2
A insert() 0 27 6

How to fix   Complexity   

Complex Class

Complex classes like Schema often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Schema, and based on these observations, apply Extract Interface, too.

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

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