Passed
Pull Request — master (#72)
by Insolita
12:56
created

Schema::findConstraints()   B

Complexity

Conditions 9
Paths 72

Size

Total Lines 79
Code Lines 47

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 9.0329

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 9
eloc 47
c 2
b 0
f 0
nc 72
nop 1
dl 0
loc 79
ccs 25
cts 27
cp 0.9259
crap 9.0329
rs 7.6008

How to fix   Long Method   

Long Method

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

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

Commonly applied refactorings include:

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

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