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

Schema::findConstraints()   B

Complexity

Conditions 8
Paths 36

Size

Total Lines 78
Code Lines 47

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 8.0877

Importance

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

How to fix   Long Method   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Pgsql;
6
7
use JsonException;
8
use PDO;
9
use Throwable;
10
use Yiisoft\Arrays\ArrayHelper;
11
use Yiisoft\Db\Constraint\CheckConstraint;
12
use Yiisoft\Db\Constraint\Constraint;
13
use Yiisoft\Db\Constraint\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