Passed
Pull Request — master (#70)
by Wilmer
11:42
created

Schema::findConstraints()   B

Complexity

Conditions 6
Paths 18

Size

Total Lines 62
Code Lines 40

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 6.042

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 40
c 1
b 0
f 0
nc 18
nop 1
dl 0
loc 62
ccs 17
cts 19
cp 0.8947
crap 6.042
rs 8.6577

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
 * @psalm-type ColumnArray = array{
40
 *   table_schema: string,
41
 *   table_name: string,
42
 *   column_name: string,
43
 *   data_type: string,
44
 *   type_type: string|null,
45
 *   character_maximum_length: int,
46
 *   column_comment: string|null,
47
 *   modifier: int,
48
 *   is_nullable: bool,
49
 *   column_default: mixed,
50
 *   is_autoinc: bool,
51
 *   sequence_name: string|null,
52
 *   enum_values: array<array-key, float|int|string>|string|null,
53
 *   numeric_precision: int|null,
54
 *   numeric_scale: int|null,
55
 *   size: string|null,
56
 *   is_pkey: bool|null,
57
 *   dimension: int
58
 * }
59
 *
60
 * @psalm-type ConstraintArray = array<
61
 *   array-key,
62
 *   array {
63
 *     name: string,
64
 *     column_name: string,
65
 *     type: string,
66
 *     foreign_table_schema: string|null,
67
 *     foreign_table_name: string|null,
68
 *     foreign_column_name: string|null,
69
 *     on_update: string,
70
 *     on_delete: string,
71
 *     check_expr: string
72
 *   }
73
 * >
74
 */
75
final class Schema extends AbstractSchema implements ConstraintFinderInterface
76
{
77
    use ConstraintFinderTrait;
78
    use ViewFinderTrait;
79
80
    public const TYPE_JSONB = 'jsonb';
81
82
    /**
83
     * @var array<array-key, string> mapping from physical column types (keys) to abstract column types (values).
84
     *
85
     * {@see http://www.postgresql.org/docs/current/static/datatype.html#DATATYPE-TABLE}
86
     */
87
    private array $typeMap = [
88
        'bit' => self::TYPE_INTEGER,
89
        'bit varying' => self::TYPE_INTEGER,
90
        'varbit' => self::TYPE_INTEGER,
91
        'bool' => self::TYPE_BOOLEAN,
92
        'boolean' => self::TYPE_BOOLEAN,
93
        'box' => self::TYPE_STRING,
94
        'circle' => self::TYPE_STRING,
95
        'point' => self::TYPE_STRING,
96
        'line' => self::TYPE_STRING,
97
        'lseg' => self::TYPE_STRING,
98
        'polygon' => self::TYPE_STRING,
99
        'path' => self::TYPE_STRING,
100
        'character' => self::TYPE_CHAR,
101
        'char' => self::TYPE_CHAR,
102
        'bpchar' => self::TYPE_CHAR,
103
        'character varying' => self::TYPE_STRING,
104
        'varchar' => self::TYPE_STRING,
105
        'text' => self::TYPE_TEXT,
106
        'bytea' => self::TYPE_BINARY,
107
        'cidr' => self::TYPE_STRING,
108
        'inet' => self::TYPE_STRING,
109
        'macaddr' => self::TYPE_STRING,
110
        'real' => self::TYPE_FLOAT,
111
        'float4' => self::TYPE_FLOAT,
112
        'double precision' => self::TYPE_DOUBLE,
113
        'float8' => self::TYPE_DOUBLE,
114
        'decimal' => self::TYPE_DECIMAL,
115
        'numeric' => self::TYPE_DECIMAL,
116
        'money' => self::TYPE_MONEY,
117
        'smallint' => self::TYPE_SMALLINT,
118
        'int2' => self::TYPE_SMALLINT,
119
        'int4' => self::TYPE_INTEGER,
120
        'int' => self::TYPE_INTEGER,
121
        'integer' => self::TYPE_INTEGER,
122
        'bigint' => self::TYPE_BIGINT,
123
        'int8' => self::TYPE_BIGINT,
124
        'oid' => self::TYPE_BIGINT, // should not be used. it's pg internal!
125
        'smallserial' => self::TYPE_SMALLINT,
126
        'serial2' => self::TYPE_SMALLINT,
127
        'serial4' => self::TYPE_INTEGER,
128
        'serial' => self::TYPE_INTEGER,
129
        'bigserial' => self::TYPE_BIGINT,
130
        'serial8' => self::TYPE_BIGINT,
131
        'pg_lsn' => self::TYPE_BIGINT,
132
        'date' => self::TYPE_DATE,
133
        'interval' => self::TYPE_STRING,
134
        'time without time zone' => self::TYPE_TIME,
135
        'time' => self::TYPE_TIME,
136
        'time with time zone' => self::TYPE_TIME,
137 71
        'timetz' => self::TYPE_TIME,
138
        'timestamp without time zone' => self::TYPE_TIMESTAMP,
139 71
        'timestamp' => self::TYPE_TIMESTAMP,
140
        'timestamp with time zone' => self::TYPE_TIMESTAMP,
141 71
        'timestamptz' => self::TYPE_TIMESTAMP,
142
        'abstime' => self::TYPE_TIMESTAMP,
143 71
        'tsquery' => self::TYPE_STRING,
144
        'tsvector' => self::TYPE_STRING,
145
        'txid_snapshot' => self::TYPE_STRING,
146
        'unknown' => self::TYPE_STRING,
147 71
        'uuid' => self::TYPE_STRING,
148 71
        'json' => self::TYPE_JSON,
149
        'jsonb' => self::TYPE_JSON,
150
        'xml' => self::TYPE_STRING,
151 71
    ];
152
153 71
    /**
154 71
     * @var string|null the default schema used for the current session.
155
     */
156
    protected ?string $defaultSchema = 'public';
157 71
158
    /**
159
     * @var string|string[] character used to quote schema, table, etc. names. An array of 2 characters can be used in
160
     * case starting and ending characters are different.
161
     */
162
    protected $tableQuoteCharacter = '"';
163
164
    /**
165
     * Resolves the table name and schema name (if any).
166
     *
167
     * @param string $name the table name.
168
     *
169
     * @return TableSchema with resolved table, schema, etc. names.
170 2
     *
171
     * {@see TableSchema}
172 2
     */
173
    protected function resolveTableName(string $name): TableSchema
174
    {
175
        $resolvedName = new TableSchema();
176
177
        $parts = explode('.', str_replace('"', '', $name));
178
179 2
        if (isset($parts[1])) {
180
            $resolvedName->schemaName($parts[0]);
181
            $resolvedName->name($parts[1]);
182
        } else {
183
            $resolvedName->schemaName($this->defaultSchema);
184
            $resolvedName->name($name);
185
        }
186
187
        $resolvedName->fullName(
188
            (
189
                $resolvedName->getSchemaName() !== $this->defaultSchema ?
190
                    (string) $resolvedName->getSchemaName() . '.' :
191
                    ''
192
            ) . (string) $resolvedName->getName()
193
        );
194 5
195
        return $resolvedName;
196 5
    }
197 5
198
    /**
199
     * Returns all schema names in the database, including the default one but not system schemas.
200
     *
201 5
     * This method should be overridden by child classes in order to support this feature because the default
202
     * implementation simply throws an exception.
203
     *
204
     * @throws Exception|InvalidConfigException|Throwable
205
     *
206
     * @return array all schema names in the database, except system schemas.
207
     */
208 5
    protected function findSchemaNames(): array
209
    {
210
        $sql = <<<'SQL'
211
SELECT "ns"."nspname"
212
FROM "pg_namespace" AS "ns"
213
WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
214
ORDER BY "ns"."nspname" ASC
215
SQL;
216
217
        return $this->getDb()->createCommand($sql)->queryColumn();
218
    }
219
220 97
    /**
221
     * Returns all table names in the database.
222 97
     *
223
     * This method should be overridden by child classes in order to support this feature because the default
224 97
     * implementation simply throws an exception.
225
     *
226 97
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
227 91
     *
228 91
     * @throws Exception|InvalidConfigException|Throwable
229
     *
230
     * @return array all table names in the database. The names have NO schema name prefix.
231 16
     */
232
    protected function findTableNames(string $schema = ''): array
233
    {
234
        if ($schema === '') {
235
            $schema = $this->defaultSchema;
236
        }
237
238
        $sql = <<<'SQL'
239
SELECT c.relname AS table_name
240
FROM pg_class c
241
INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
242
WHERE ns.nspname = :schemaName AND c.relkind IN ('r','v','m','f', 'p')
243 31
ORDER BY c.relname
244
SQL;
245 31
246
        return $this->getDb()->createCommand($sql, [':schemaName' => $schema])->queryColumn();
247
    }
248
249
    /**
250
     * Loads the metadata for the specified table.
251
     *
252
     * @param string $name table name.
253
     *
254
     * @throws Exception|InvalidConfigException
255
     *
256
     * @return TableSchema|null DBMS-dependent table metadata, `null` if the table does not exist.
257 4
     */
258
    protected function loadTableSchema(string $name): ?TableSchema
259 4
    {
260
        $table = new TableSchema();
261
262
        $this->resolveTableNames($table, $name);
263
264
        if ($this->findColumns($table)) {
265
            $this->findConstraints($table);
266
            return $table;
267
        }
268
269
        return null;
270
    }
271 28
272
    /**
273 28
     * Loads a primary key for the given table.
274
     *
275
     * @param string $tableName table name.
276
     *
277
     * @throws Exception|InvalidConfigException
278
     *
279
     * @return Constraint|null primary key for the given table, `null` if the table has no primary key.
280
     */
281
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
282
    {
283
        $tablePrimaryKey = $this->loadTableConstraints($tableName, 'primaryKey');
284
285
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
286
    }
287
288
    /**
289
     * Loads all foreign keys for the given table.
290
     *
291
     * @param string $tableName table name.
292 28
     *
293
     * @throws Exception|InvalidConfigException
294 28
     *
295 28
     * @return array|ForeignKeyConstraint[] foreign keys for the given table.
296 28
     */
297 28
    protected function loadTableForeignKeys(string $tableName): array
298
    {
299 28
        $tableForeignKeys = $this->loadTableConstraints($tableName, 'foreignKeys');
300 28
301 28
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
302
    }
303 28
304 25
    /**
305 25
     * Loads all indexes for the given table.
306 25
     *
307 25
     * @param string $tableName table name.
308 25
     *
309
     * @throws Exception|InvalidConfigException|Throwable
310 25
     *
311
     * @return IndexConstraint[] indexes for the given table.
312
     */
313 28
    protected function loadTableIndexes(string $tableName): array
314
    {
315
        $sql = <<<'SQL'
316
SELECT
317
    "ic"."relname" AS "name",
318
    "ia"."attname" AS "column_name",
319
    "i"."indisunique" AS "index_is_unique",
320
    "i"."indisprimary" AS "index_is_primary"
321
FROM "pg_class" AS "tc"
322
INNER JOIN "pg_namespace" AS "tcns"
323
    ON "tcns"."oid" = "tc"."relnamespace"
324
INNER JOIN "pg_index" AS "i"
325 13
    ON "i"."indrelid" = "tc"."oid"
326
INNER JOIN "pg_class" AS "ic"
327 13
    ON "ic"."oid" = "i"."indexrelid"
328
INNER JOIN "pg_attribute" AS "ia"
329
    ON "ia"."attrelid" = "i"."indrelid" AND "ia"."attnum" = ANY ("i"."indkey")
330
WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
331
ORDER BY "ia"."attnum" ASC
332
SQL;
333
334
        $resolvedName = $this->resolveTableName($tableName);
335
336
        $indexes = $this->getDb()->createCommand($sql, [
337
            ':schemaName' => $resolvedName->getSchemaName(),
338
            ':tableName' => $resolvedName->getName(),
339 13
        ])->queryAll();
340
341 13
        /** @var array<array-key, array<array-key, mixed>> @indexes */
342
        $indexes = $this->normalizePdoRowKeyCase($indexes, true);
343
        $indexes = ArrayHelper::index($indexes, null, 'name');
344
        $result = [];
345
346
        /**
347
         * @var object|string|null $name
348
         * @var array<
349
         *   array-key,
350
         *   array{
351
         *     name: string,
352
         *     column_name: string,
353 12
         *     index_is_unique: bool,
354
         *     index_is_primary: bool
355 12
         *   }
356
         * > $index
357
         */
358
        foreach ($indexes as $name => $index) {
359
            $ic = (new IndexConstraint())
360
                ->name($name)
361
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
362
                ->primary($index[0]['index_is_primary'])
363 69
                ->unique($index[0]['index_is_unique']);
364
365 69
            $result[] = $ic;
366
        }
367
368
        return $result;
369
    }
370
371
    /**
372
     * Loads all unique constraints for the given table.
373
     *
374 97
     * @param string $tableName table name.
375
     *
376 97
     * @throws Exception|InvalidConfigException
377
     *
378 97
     * @return array|Constraint[] unique constraints for the given table.
379
     */
380
    protected function loadTableUniques(string $tableName): array
381
    {
382 97
        $tableUniques = $this->loadTableConstraints($tableName, 'uniques');
383 97
384
        return is_array($tableUniques) ? $tableUniques : [];
385
    }
386 97
387 97
    /**
388 97
     * Loads all check constraints for the given table.
389
     *
390
     * @param string $tableName table name.
391
     *
392
     * @throws Exception|InvalidConfigException
393
     *
394
     * @return array|CheckConstraint[] check constraints for the given table.
395
     */
396
    protected function loadTableChecks(string $tableName): array
397
    {
398
        $tableChecks = $this->loadTableConstraints($tableName, 'checks');
399
400
        return is_array($tableChecks) ? $tableChecks : [];
401
    }
402
403
    /**
404
     * Loads all default value constraints for the given table.
405
     *
406
     * @param string $tableName table name.
407
     *
408
     * @throws NotSupportedException
409
     *
410
     * @return DefaultValueConstraint[] default value constraints for the given table.
411
     */
412
    protected function loadTableDefaultValues(string $tableName): array
413
    {
414 91
        throw new NotSupportedException('PostgreSQL does not support default value constraints.');
415
    }
416 91
417 91
    /**
418
     * Creates a query builder for the PostgreSQL database.
419
     *
420
     * @return QueryBuilder query builder instance
421
     */
422
    public function createQueryBuilder(): QueryBuilder
423
    {
424
        return new QueryBuilder($this->getDb());
425 91
    }
426
427
    /**
428
     * Resolves the table name and schema name (if any).
429
     *
430
     * @param TableSchema $table the table metadata object.
431
     * @param string $name the table name
432
     */
433
    protected function resolveTableNames(TableSchema $table, string $name): void
434
    {
435
        $parts = explode('.', str_replace('"', '', $name));
436
437
        if (isset($parts[1])) {
438
            $table->schemaName($parts[0]);
439
            $table->name($parts[1]);
440
        } else {
441
            $table->schemaName($this->defaultSchema);
442
            $table->name($parts[0]);
443 91
        }
444 91
445
        if ($table->getSchemaName() !== $this->defaultSchema) {
446
            $name = (string) $table->getSchemaName() . '.' . (string) $table->getName();
447
        } else {
448
            $name = $table->getName();
449 91
        }
450
451 91
        $table->fullName($name);
452 8
    }
453
454
    protected function findViewNames(string $schema = ''): array
455
    {
456 8
        if ($schema === '') {
457
            $schema = $this->defaultSchema;
458
        }
459 8
460
        $sql = <<<'SQL'
461
SELECT c.relname AS table_name
462 8
FROM pg_class c
463
INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
464 8
WHERE ns.nspname = :schemaName AND (c.relkind = 'v' OR c.relkind = 'm')
465 8
ORDER BY c.relname
466 8
SQL;
467
468
        return $this->getDb()->createCommand($sql, [':schemaName' => $schema])->queryColumn();
469
    }
470
471 8
    /**
472
     * Collects the foreign key column details for the given table.
473
     *
474 91
     * @param TableSchema $table the table metadata
475 8
     *
476
     * @throws Exception|InvalidConfigException|Throwable
477 91
     */
478
    protected function findConstraints(TableSchema $table): void
479
    {
480
        $tableName = $table->getName();
481
        $tableSchema = $table->getSchemaName();
482
483
        if ($tableName !== null) {
484
            $tableName = $this->quoteValue($tableName);
485
        }
486
487
        if ($tableSchema !== null) {
488
            $tableSchema = $this->quoteValue($tableSchema);
489
        }
490
491
        /**
492
         * We need to extract the constraints de hard way since:
493
         * {@see http://www.postgresql.org/message-id/[email protected]}
494
         */
495
496
        $sql = <<<SQL
497
select
498
    ct.conname as constraint_name,
499
    a.attname as column_name,
500
    fc.relname as foreign_table_name,
501
    fns.nspname as foreign_table_schema,
502
    fa.attname as foreign_column_name
503
from
504
    (SELECT ct.conname, ct.conrelid, ct.confrelid, ct.conkey, ct.contype, ct.confkey, generate_subscripts(ct.conkey, 1) AS s
505
       FROM pg_constraint ct
506
    ) AS ct
507
    inner join pg_class c on c.oid=ct.conrelid
508
    inner join pg_namespace ns on c.relnamespace=ns.oid
509
    inner join pg_attribute a on a.attrelid=ct.conrelid and a.attnum = ct.conkey[ct.s]
510
    left join pg_class fc on fc.oid=ct.confrelid
511
    left join pg_namespace fns on fc.relnamespace=fns.oid
512
    left join pg_attribute fa on fa.attrelid=ct.confrelid and fa.attnum = ct.confkey[ct.s]
513
where
514
    ct.contype='f'
515
    and c.relname={$tableName}
516
    and ns.nspname={$tableSchema}
517
order by
518
    fns.nspname, fc.relname, a.attnum
519
SQL;
520
521
        /**
522
         * @var array{
523
         *   array{
524
         *     tableName: string,
525
         *     columns: array
526
         *   }
527
         * } $constraints
528
         */
529
        $constraints = [];
530
        $slavePdo = $this->getDb()->getSlavePdo();
531
532
        /**
533
         * @var array{
534
         *   constraint_name: string,
535
         *   column_name: string,
536
         *   foreign_table_name: string,
537
         *   foreign_table_schema: string,
538
         *   foreign_column_name: string,
539
         * } $constraint
540
         */
541
        foreach ($this->getDb()->createCommand($sql)->queryAll() as $constraint) {
542
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
543
                $constraint = array_change_key_case($constraint, CASE_LOWER);
544
            }
545
546
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
547
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
548
            } else {
549
                $foreignTable = $constraint['foreign_table_name'];
550
            }
551
552
            $name = $constraint['constraint_name'];
553
554
            if (!isset($constraints[$name])) {
555
                $constraints[$name] = [
556
                    'tableName' => $foreignTable,
557
                    'columns' => [],
558
                ];
559
            }
560
561
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
562
        }
563
564
        /**
565 97
         * @var int|string $foreingKeyName.
566
         * @var array{tableName: string, columns: array} $constraint
567 97
         */
568 97
        foreach ($constraints as $foreingKeyName => $constraint) {
569
            $table->foreignKey(
570 97
                (string) $foreingKeyName,
571
                array_merge([$constraint['tableName']], $constraint['columns'])
572 97
            );
573
        }
574
    }
575
576
    /**
577 97
     * Gets information about given table unique indexes.
578
     *
579
     * @param TableSchema $table the table metadata.
580
     *
581
     * @throws Exception|InvalidConfigException|Throwable
582
     *
583
     * @return array with index and column names.
584
     */
585
    protected function getUniqueIndexInformation(TableSchema $table): array
586
    {
587
        $sql = <<<'SQL'
588 97
SELECT
589
    i.relname as indexname,
590
    pg_get_indexdef(idx.indexrelid, k + 1, TRUE) AS columnname
591
FROM (
592
  SELECT *, generate_subscripts(indkey, 1) AS k
593
  FROM pg_index
594
) idx
595
INNER JOIN pg_class i ON i.oid = idx.indexrelid
596
INNER JOIN pg_class c ON c.oid = idx.indrelid
597
INNER JOIN pg_namespace ns ON c.relnamespace = ns.oid
598
WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
599
AND c.relname = :tableName AND ns.nspname = :schemaName
600
ORDER BY i.relname, k
601
SQL;
602
603
        return $this->getDb()->createCommand($sql, [
604
            ':schemaName' => $table->getSchemaName(),
605
            ':tableName' => $table->getName(),
606
        ])->queryAll();
607
    }
608
609
    /**
610
     * Returns all unique indexes for the given table.
611
     *
612
     * Each array element is of the following structure:
613
     *
614
     * ```php
615
     * [
616
     *     'IndexName1' => ['col1' [, ...]],
617
     *     'IndexName2' => ['col2' [, ...]],
618
     * ]
619
     * ```
620
     *
621
     * @param TableSchema $table the table metadata
622
     *
623
     * @throws Exception|InvalidConfigException|Throwable
624
     *
625
     * @return array all unique indexes for the given table.
626
     */
627
    public function findUniqueIndexes(TableSchema $table): array
628
    {
629
        $uniqueIndexes = [];
630
        $slavePdo = $this->getDb()->getSlavePdo();
631
632
        /** @var array{indexname: string, columnname: string} $row */
633 97
        foreach ($this->getUniqueIndexInformation($table) as $row) {
634 97
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
635
                $row = array_change_key_case($row, CASE_LOWER);
636
            }
637
638
            $column = $row['columnname'];
639 97
640
            if (!empty($column) && $column[0] === '"') {
641 97
                /**
642 16
                 * postgres will quote names that are not lowercase-only.
643
                 *
644
                 * {@see https://github.com/yiisoft/yii2/issues/10613}
645 91
                 */
646 91
                $column = substr($column, 1, -1);
647
            }
648
649
            $uniqueIndexes[$row['indexname']][] = $column;
650 91
        }
651 91
652
        return $uniqueIndexes;
653 91
    }
654 62
655
    /**
656 62
     * Collects the metadata of table columns.
657 62
     *
658
     * @param TableSchema $table the table metadata.
659
     *
660 62
     * @throws Exception|InvalidConfigException|JsonException|Throwable
661 88
     *
662
     * @return bool whether the table exists in the database.
663 54
     */
664 29
    protected function findColumns(TableSchema $table): bool
665 29
    {
666 29
        $tableName = $table->getName();
667 54
        $schemaName = $table->getSchemaName();
668
        $orIdentity = '';
669
670 27
        if ($tableName !== null) {
671 54
            $tableName = $this->getDb()->quoteValue($tableName);
672 51
        }
673 31
674
        if ($schemaName !== null) {
675 31
            $schemaName = $this->getDb()->quoteValue($schemaName);
676 27
        }
677 31
678 28
        if (version_compare($this->getDb()->getServerVersion(), '12.0', '>=')) {
679 30
            $orIdentity = 'OR a.attidentity != \'\'';
680 30
        }
681 5
682
        $sql = <<<SQL
683 30
SELECT
684
    d.nspname AS table_schema,
685
    c.relname AS table_name,
686
    a.attname AS column_name,
687
    COALESCE(td.typname, tb.typname, t.typname) AS data_type,
688
    COALESCE(td.typtype, tb.typtype, t.typtype) AS type_type,
689
    a.attlen AS character_maximum_length,
690
    pg_catalog.col_description(c.oid, a.attnum) AS column_comment,
691 91
    a.atttypmod AS modifier,
692
    a.attnotnull = false AS is_nullable,
693
    CAST(pg_get_expr(ad.adbin, ad.adrelid) AS varchar) AS column_default,
694
    coalesce(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval',false) {$orIdentity} AS is_autoinc,
695
    pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname) AS sequence_name,
696
    CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
697
        THEN array_to_string((SELECT array_agg(enumlabel) FROM pg_enum WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid))::varchar[], ',')
698
        ELSE NULL
699
    END AS enum_values,
700
    CASE atttypid
701 91
         WHEN 21 /*int2*/ THEN 16
702
         WHEN 23 /*int4*/ THEN 32
703 91
         WHEN 20 /*int8*/ THEN 64
704 91
         WHEN 1700 /*numeric*/ THEN
705 91
              CASE WHEN atttypmod = -1
706 91
               THEN null
707 91
               ELSE ((atttypmod - 4) >> 16) & 65535
708 91
               END
709 91
         WHEN 700 /*float4*/ THEN 24 /*FLT_MANT_DIG*/
710 91
         WHEN 701 /*float8*/ THEN 53 /*DBL_MANT_DIG*/
711 91
         ELSE null
712 91
      END   AS numeric_precision,
713 91
      CASE
714 91
        WHEN atttypid IN (21, 23, 20) THEN 0
715 91
        WHEN atttypid IN (1700) THEN
716 91
        CASE
717 91
            WHEN atttypmod = -1 THEN null
718
            ELSE (atttypmod - 4) & 65535
719
        END
720
           ELSE null
721
      END AS numeric_scale,
722
    CAST(
723
             information_schema._pg_char_max_length(information_schema._pg_truetypid(a, t), information_schema._pg_truetypmod(a, t))
724 91
             AS numeric
725
    ) AS size,
726 91
    a.attnum = any (ct.conkey) as is_pkey,
727 91
    COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
728
FROM
729 59
    pg_class c
730
    LEFT JOIN pg_attribute a ON a.attrelid = c.oid
731
    LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
732 59
    LEFT JOIN pg_type t ON a.atttypid = t.oid
733
    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
734 89
    LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
735
    LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
736
    LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p'
737
WHERE
738 91
    a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
739 91
    AND c.relname = {$tableName}
740
    AND d.nspname = {$schemaName}
741
ORDER BY
742
    a.attnum;
743
SQL;
744 91
745
        /** @var array columns */
746 91
        $columns = $this->getDb()->createCommand($sql)->queryAll();
747
        $slavePdo = $this->getDb()->getSlavePdo();
748
749
        if (empty($columns)) {
750
            return false;
751
        }
752
753
        /** @var array<array-key, mixed> $column */
754
        foreach ($columns as $column) {
755
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
756
                $column = array_change_key_case($column, CASE_LOWER);
757
            }
758
759
            /** @psalm-var ColumnArray $column */
760
            $loadColumnSchema = $this->loadColumnSchema($column);
761
            $table->columns($loadColumnSchema->getName(), $loadColumnSchema);
762
763
            /** @var mixed $defaultValue */
764
            $defaultValue = $loadColumnSchema->getDefaultValue();
765
766
            if ($loadColumnSchema->isPrimaryKey()) {
767
                $table->primaryKey($loadColumnSchema->getName());
768
769
                if ($table->getSequenceName() === null) {
770
                    $table->sequenceName($loadColumnSchema->getSequenceName());
771
                }
772
773
                $loadColumnSchema->defaultValue(null);
774
            } elseif ($defaultValue) {
775
                if (
776
                    is_string($defaultValue) &&
777
                    in_array($loadColumnSchema->getType(), [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME], true) &&
778
                    in_array(
779
                        strtoupper($defaultValue),
780
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
781
                        true
782
                    )
783
                ) {
784
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
785
                } elseif ($loadColumnSchema->getType() === 'boolean') {
786
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
787
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
788
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
789
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
790
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
791
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
792
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
793
                } elseif (
794 61
                    is_string($defaultValue) &&
795
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
796 61
                ) {
797
                    if ($matches[2] === 'NULL') {
798
                        $loadColumnSchema->defaultValue(null);
799
                    } else {
800
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
801
                    }
802
                } else {
803
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
804
                }
805
            }
806
        }
807
808
        return true;
809
    }
810
811
    /**
812
     * Loads the column information into a {@see ColumnSchema} object.
813
     *
814
     * @param array{
815
     *   table_schema: string,
816
     *   table_name: string,
817
     *   column_name: string,
818
     *   data_type: string,
819
     *   type_type: string|null,
820
     *   character_maximum_length: int,
821
     *   column_comment: string|null,
822
     *   modifier: int,
823
     *   is_nullable: bool,
824 61
     *   column_default: mixed,
825
     *   is_autoinc: bool,
826
     *   sequence_name: string|null,
827
     *   enum_values: array<array-key, float|int|string>|string|null,
828
     *   numeric_precision: int|null,
829
     *   numeric_scale: int|null,
830
     *   size: string|null,
831
     *   is_pkey: bool|null,
832 61
     *   dimension: int
833 61
     * } $info column information.
834 61
     *
835 61
     * @return ColumnSchema the column schema object.
836 61
     */
837 61
    protected function loadColumnSchema(array $info): ColumnSchema
838 61
    {
839
        $column = $this->createColumnSchema();
840 61
        $column->allowNull($info['is_nullable']);
841
        $column->autoIncrement($info['is_autoinc']);
842
        $column->comment($info['column_comment']);
843
        $column->dbType($info['data_type']);
844
        $column->defaultValue($info['column_default']);
845
        $column->enumValues(($info['enum_values'] !== null)
846 61
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null);
847 61
        $column->unsigned(false); // has no meaning in PG
848
        $column->primaryKey((bool) $info['is_pkey']);
849 61
        $column->name($info['column_name']);
850 46
        $column->precision($info['numeric_precision']);
851 46
        $column->scale($info['numeric_scale']);
852 46
        $column->size($info['size'] === null ? null : (int) $info['size']);
853
        $column->dimension($info['dimension']);
854 46
855 46
        /**
856 59
         * pg_get_serial_sequence() doesn't track DEFAULT value change. GENERATED BY IDENTITY columns always have null
857 13
         * default value.
858 13
         *
859 13
         * @var mixed $defaultValue
860 13
         */
861
        $defaultValue = $column->getDefaultValue();
862 13
        $sequenceName = $info['sequence_name'] ?? null;
863 13
864 13
        if (
865 13
            isset($defaultValue) &&
866
            is_string($defaultValue) &&
867 13
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
868 13
        ) {
869
            $column->sequenceName(preg_replace(
870 13
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
871 13
                '',
872 47
                $defaultValue
873 46
            ));
874 46
        } elseif ($sequenceName !== null) {
875 46
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
876
        }
877 46
878 46
        if (isset($this->typeMap[$column->getDbType()])) {
879 10
            $column->type($this->typeMap[$column->getDbType()]);
880 10
        } else {
881 10
            $column->type(self::TYPE_STRING);
882 10
        }
883 10
884
        $column->phpType($this->getColumnPhpType($column));
885 10
886 10
        return $column;
887
    }
888
889
    /**
890
     * Executes the INSERT command, returning primary key values.
891 61
     *
892 61
     * @param string $table the table that new rows will be inserted into.
893
     * @param array $columns the column data (name => value) to be inserted into the table.
894
     *
895 61
     * @throws Exception|InvalidConfigException|Throwable
896
     *
897
     * @return array|false primary key values or false if the command fails.
898
     */
899
    public function insert(string $table, array $columns)
900
    {
901
        $params = [];
902
        $returnColumns = [];
903
        $sql = $this->getDb()->getQueryBuilder()->insert($table, $columns, $params);
904
        $tableSchema = $this->getTableSchema($table);
905 91
906
        if ($tableSchema !== null) {
907 91
            $returnColumns = $tableSchema->getPrimaryKey();
908
        }
909
910
        if (!empty($returnColumns)) {
911
            $returning = [];
912
            /** @var string $name */
913
            foreach ($returnColumns as $name) {
914
                $returning[] = $this->quoteColumnName($name);
915
            }
916
            $sql .= ' RETURNING ' . implode(', ', $returning);
917
        }
918
919
        $command = $this->getDb()->createCommand($sql, $params);
920 4
        $command->prepare(false);
921
        $result = $command->queryOne();
922 4
923
        $pdoStatement = $command->getPdoStatement();
924
925
        return $pdoStatement !== null && !$pdoStatement->rowCount() ? false : $result;
926
    }
927
928
    /**
929
     * Loads multiple types of constraints and returns the specified ones.
930
     *
931
     * @param string $tableName table name.
932
     * @param string $returnType return type:
933
     * - primaryKey
934
     * - foreignKeys
935
     * - uniques
936
     * - checks
937
     *
938
     * @throws Exception|InvalidConfigException|Throwable
939
     *
940
     * @return (CheckConstraint|Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
941
     *
942
     * @psalm-return Constraint|list<CheckConstraint|Constraint|ForeignKeyConstraint>|null
943
     */
944
    private function loadTableConstraints(string $tableName, string $returnType)
945
    {
946
        /** @var string $sql */
947
        $sql = <<<'SQL'
948
SELECT
949
    "c"."conname" AS "name",
950
    "a"."attname" AS "column_name",
951
    "c"."contype" AS "type",
952
    "ftcns"."nspname" AS "foreign_table_schema",
953
    "ftc"."relname" AS "foreign_table_name",
954
    "fa"."attname" AS "foreign_column_name",
955
    "c"."confupdtype" AS "on_update",
956
    "c"."confdeltype" AS "on_delete",
957
    pg_get_constraintdef("c"."oid") AS "check_expr"
958
FROM "pg_class" AS "tc"
959
INNER JOIN "pg_namespace" AS "tcns"
960
    ON "tcns"."oid" = "tc"."relnamespace"
961
INNER JOIN "pg_constraint" AS "c"
962
    ON "c"."conrelid" = "tc"."oid"
963
INNER JOIN "pg_attribute" AS "a"
964
    ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
965
LEFT JOIN "pg_class" AS "ftc"
966
    ON "ftc"."oid" = "c"."confrelid"
967
LEFT JOIN "pg_namespace" AS "ftcns"
968
    ON "ftcns"."oid" = "ftc"."relnamespace"
969
LEFT JOIN "pg_attribute" "fa"
970
    ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
971
WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
972
ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
973
SQL;
974
975
        /** @var array<array-key, string> $actionTypes */
976
        $actionTypes = [
977
            'a' => 'NO ACTION',
978
            'r' => 'RESTRICT',
979
            'c' => 'CASCADE',
980
            'n' => 'SET NULL',
981
            'd' => 'SET DEFAULT',
982
        ];
983
984
        $resolvedName = $this->resolveTableName($tableName);
985
986
        $constraints = $this->getDb()->createCommand($sql, [
987
            ':schemaName' => $resolvedName->getSchemaName(),
988
            ':tableName' => $resolvedName->getName(),
989
        ])->queryAll();
990
991
        /** @var array<array-key, array> $constraints */
992
        $constraints = $this->normalizePdoRowKeyCase($constraints, true);
993
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
994
995
        $result = [
996
            'primaryKey' => null,
997
            'foreignKeys' => [],
998
            'uniques' => [],
999
            'checks' => [],
1000
        ];
1001
1002
        /**
1003
         * @var string $type
1004
         * @var array $names
1005
         */
1006
        foreach ($constraints as $type => $names) {
1007
            /**
1008
             * @psalm-var object|string|null $name
1009
             * @psalm-var ConstraintArray $constraint
1010
             */
1011
            foreach ($names as $name => $constraint) {
1012
                switch ($type) {
1013
                    case 'p':
1014
                        $ct = (new Constraint())
1015
                            ->name($name)
1016
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1017
1018
                        $result['primaryKey'] = $ct;
1019
                        break;
1020
                    case 'f':
1021
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
1022
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
1023
1024
                        $fk = (new ForeignKeyConstraint())
1025
                            ->name($name)
1026
                            ->columnNames(array_values(
1027
                                array_unique(ArrayHelper::getColumn($constraint, 'column_name'))
1028
                            ))
1029
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
1030
                            ->foreignTableName($constraint[0]['foreign_table_name'])
1031
                            ->foreignColumnNames(array_values(
1032
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
1033
                            ))
1034
                            ->onDelete($onDelete)
1035
                            ->onUpdate($onUpdate);
1036
1037
                        $result['foreignKeys'][] = $fk;
1038
                        break;
1039
                    case 'u':
1040
                        $ct = (new Constraint())
1041
                            ->name($name)
1042
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1043
1044
                        $result['uniques'][] = $ct;
1045
                        break;
1046
                    case 'c':
1047
                        $ck = (new CheckConstraint())
1048
                            ->name($name)
1049
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
1050
                            ->expression($constraint[0]['check_expr']);
1051
1052
                        $result['checks'][] = $ck;
1053
                        break;
1054
                }
1055
            }
1056
        }
1057
1058
        foreach ($result as $type => $data) {
1059
            $this->setTableMetadata($tableName, $type, $data);
1060
        }
1061
1062
        return $result[$returnType];
1063
    }
1064
1065
    /**
1066
     * Creates a column schema for the database.
1067
     *
1068
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1069
     *
1070
     * @return ColumnSchema column schema instance.
1071
     */
1072
    private function createColumnSchema(): ColumnSchema
1073
    {
1074
        return new ColumnSchema();
1075
    }
1076
1077
    /**
1078
     * Create a column schema builder instance giving the type and value precision.
1079
     *
1080
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
1081
     *
1082
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
1083
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
1084
     *
1085
     * @return ColumnSchemaBuilder column schema builder instance
1086
     */
1087
    public function createColumnSchemaBuilder(string $type, $length = null): ColumnSchemaBuilder
1088
    {
1089
        return new ColumnSchemaBuilder($type, $length);
1090
    }
1091
}
1092