Passed
Push — remove-test-schema-trait ( e59452...602a82 )
by Wilmer
32:04 queued 28:35
created

Schema::findColumns()   D

Complexity

Conditions 20
Paths 26

Size

Total Lines 150
Code Lines 64

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 41
CRAP Score 20.0402

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 20
eloc 64
c 5
b 1
f 0
nc 26
nop 1
dl 0
loc 150
ccs 41
cts 43
cp 0.9535
crap 20.0402
rs 4.1666

How to fix   Long Method    Complexity   

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