Passed
Push — dev ( a54f58...65e869 )
by Def
09:39 queued 06:21
created

Schema::findColumns()   D

Complexity

Conditions 21
Paths 52

Size

Total Lines 155
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 21.034

Importance

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