Passed
Push — master ( f403f7...a7fd5d )
by Def
12:25
created

Schema::findColumns()   D

Complexity

Conditions 21
Paths 52

Size

Total Lines 157
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 21.034

Importance

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