Passed
Pull Request — master (#316)
by Sergei
03:56
created

Schema::findConstraints()   C

Complexity

Conditions 8
Paths 14

Size

Total Lines 511
Code Lines 478

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 170
CRAP Score 8

Importance

Changes 8
Bugs 0 Features 0
Metric Value
cc 8
eloc 478
c 8
b 0
f 0
nc 14
nop 1
dl 0
loc 511
ccs 170
cts 170
cp 1
crap 8
rs 6.7555

How to fix   Long Method   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Pgsql;
6
7
use JsonException;
8
use Throwable;
9
use Yiisoft\Db\Constraint\CheckConstraint;
10
use Yiisoft\Db\Constraint\Constraint;
11
use Yiisoft\Db\Constraint\DefaultValueConstraint;
12
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
13
use Yiisoft\Db\Constraint\IndexConstraint;
14
use Yiisoft\Db\Driver\Pdo\AbstractPdoSchema;
15
use Yiisoft\Db\Exception\Exception;
16
use Yiisoft\Db\Exception\InvalidConfigException;
17
use Yiisoft\Db\Exception\NotSupportedException;
18
use Yiisoft\Db\Expression\Expression;
19
use Yiisoft\Db\Helper\DbArrayHelper;
20
use Yiisoft\Db\Schema\Builder\ColumnInterface;
21
use Yiisoft\Db\Schema\ColumnSchemaInterface;
22
use Yiisoft\Db\Schema\TableSchemaInterface;
23
24
use function array_merge;
25
use function array_unique;
26
use function array_values;
27
use function explode;
28
use function hex2bin;
29
use function is_string;
30
use function preg_match;
31
use function preg_replace;
32
use function str_replace;
33
use function str_starts_with;
34
use function substr;
35
36
/**
37
 * Implements the PostgreSQL Server specific schema, supporting PostgreSQL Server version 9.6 and above.
38
 *
39
 * @psalm-type ColumnArray = array{
40
 *   table_schema: string,
41
 *   table_name: string,
42
 *   column_name: string,
43
 *   data_type: string,
44
 *   type_type: string|null,
45
 *   type_scheme: string|null,
46
 *   character_maximum_length: int,
47
 *   column_comment: string|null,
48
 *   modifier: int,
49
 *   is_nullable: bool,
50
 *   column_default: string|null,
51
 *   is_autoinc: bool,
52
 *   sequence_name: string|null,
53
 *   enum_values: 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
 * @psalm-type ConstraintArray = array<
61
 *   array-key,
62
 *   array {
63
 *     name: string,
64
 *     column_name: string,
65
 *     type: string,
66
 *     foreign_table_schema: string|null,
67
 *     foreign_table_name: string|null,
68
 *     foreign_column_name: string|null,
69
 *     on_update: string,
70
 *     on_delete: string,
71
 *     check_expr: string
72
 *   }
73
 * >
74
 * @psalm-type FindConstraintArray = array{
75
 *   constraint_name: string,
76
 *   column_name: string,
77
 *   foreign_table_name: string,
78
 *   foreign_table_schema: string,
79
 *   foreign_column_name: string,
80
 * }
81
 */
82
final class Schema extends AbstractPdoSchema
83
{
84
    /**
85
     * Define the abstract column type as `bit`.
86
     */
87
    public const TYPE_BIT = 'bit';
88
89
    /**
90
     * @var array The mapping from physical column types (keys) to abstract column types (values).
91
     *
92
     * @link https://www.postgresql.org/docs/current/static/datatype.html#DATATYPE-TABLE
93
     *
94
     * @psalm-var string[]
95
     */
96
    private array $typeMap = [
0 ignored issues
show
introduced by
The private property $typeMap is not used, and could be removed.
Loading history...
97
        'bit' => self::TYPE_BIT,
98
        'bit varying' => self::TYPE_BIT,
99
        'varbit' => self::TYPE_BIT,
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, // shouldn't 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
    /**
163
     * @var string|null The default schema used for the current session.
164
     */
165
    protected string|null $defaultSchema = 'public';
166
167
    /**
168
     * @var string|string[] Character used to quote schema, table, etc. names.
169
     *
170
     * An array of 2 characters can be used in case starting and ending characters are different.
171
     */
172
    protected string|array $tableQuoteCharacter = '"';
173
174 14
    public function createColumn(string $type, array|int|string $length = null): ColumnInterface
175
    {
176 14
        return new Column($type, $length);
177
    }
178
179
    /**
180
     * Resolves the table name and schema name (if any).
181
     *
182
     * @param string $name The table name.
183
     *
184
     * @return TableSchemaInterface With resolved table, schema, etc. names.
185
     *
186
     * @see TableSchemaInterface
187
     */
188 236
    protected function resolveTableName(string $name): TableSchemaInterface
189
    {
190 236
        $resolvedName = new TableSchema();
191
192 236
        $parts = array_reverse($this->db->getQuoter()->getTableNameParts($name));
193 236
        $resolvedName->name($parts[0] ?? '');
194 236
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
195
196 236
        $resolvedName->fullName(
197 236
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
198 236
            implode('.', array_reverse($parts)) : $resolvedName->getName()
199 236
        );
200
201 236
        return $resolvedName;
202
    }
203
204
    /**
205
     * Returns all schema names in the database, including the default one but not system schemas.
206
     *
207
     * This method should be overridden by child classes to support this feature because the default implementation
208
     * simply throws an exception.
209
     *
210
     * @throws Exception
211
     * @throws InvalidConfigException
212
     * @throws Throwable
213
     *
214
     * @return array All schemas name in the database, except system schemas.
215
     */
216 1
    protected function findSchemaNames(): array
217
    {
218 1
        $sql = <<<SQL
219
        SELECT "ns"."nspname"
220
        FROM "pg_namespace" AS "ns"
221
        WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
222
        ORDER BY "ns"."nspname" ASC
223 1
        SQL;
224
225 1
        return $this->db->createCommand($sql)->queryColumn();
226
    }
227
228
    /**
229
     * @throws Exception
230
     * @throws InvalidConfigException
231
     * @throws Throwable
232
     */
233 184
    protected function findTableComment(TableSchemaInterface $tableSchema): void
234
    {
235 184
        $sql = <<<SQL
236
        SELECT obj_description(pc.oid, 'pg_class')
237
        FROM pg_catalog.pg_class pc
238
        INNER JOIN pg_namespace pn ON pc.relnamespace = pn.oid
239
        WHERE
240
        pc.relname=:tableName AND
241
        pn.nspname=:schemaName
242 184
        SQL;
243
244 184
        $comment = $this->db->createCommand($sql, [
245 184
            ':schemaName' => $tableSchema->getSchemaName(),
246 184
            ':tableName' => $tableSchema->getName(),
247 184
        ])->queryScalar();
248
249 184
        $tableSchema->comment(is_string($comment) ? $comment : null);
250
    }
251
252
    /**
253
     * Returns all table names in the database.
254
     *
255
     * This method should be overridden by child classes to support this feature because the default implementation
256
     * simply throws an exception.
257
     *
258
     * @param string $schema The schema of the tables.
259
     * Defaults to empty string, meaning the current or default schema.
260
     *
261
     * @throws Exception
262
     * @throws InvalidConfigException
263
     * @throws Throwable
264
     *
265
     * @return array All tables name in the database. The names have NO schema name prefix.
266
     */
267 12
    protected function findTableNames(string $schema = ''): array
268
    {
269 12
        if ($schema === '') {
270 11
            $schema = $this->defaultSchema;
271
        }
272
273 12
        $sql = <<<SQL
274
        SELECT c.relname AS table_name
275
        FROM pg_class c
276
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
277
        WHERE ns.nspname = :schemaName AND c.relkind IN ('r','v','m','f', 'p')
278
        ORDER BY c.relname
279 12
        SQL;
280
281 12
        return $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
282
    }
283
284
    /**
285
     * Loads the metadata for the specified table.
286
     *
287
     * @param string $name The table name.
288
     *
289
     * @throws Exception
290
     * @throws InvalidConfigException
291
     * @throws Throwable
292
     *
293
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table doesn't exist.
294
     */
295 184
    protected function loadTableSchema(string $name): TableSchemaInterface|null
296
    {
297 184
        $table = $this->resolveTableName($name);
298 184
        $this->findTableComment($table);
299
300 184
        if ($this->findColumns($table)) {
0 ignored issues
show
Bug introduced by
The method findColumns() does not exist on Yiisoft\Db\Pgsql\Schema. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

300
        if ($this->/** @scrutinizer ignore-call */ findColumns($table)) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
301 162
            $this->findConstraints($table);
302 162
            return $table;
303
        }
304
305 43
        return null;
306
    }
307
308
    /**
309
     * Loads a primary key for the given table.
310
     *
311
     * @param string $tableName The table name.
312
     *
313
     * @throws Exception
314
     * @throws InvalidConfigException
315
     * @throws Throwable
316
     *
317
     * @return Constraint|null Primary key for the given table, `null` if the table has no primary key.
318
     */
319 42
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
320
    {
321 42
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
0 ignored issues
show
Bug introduced by
The method loadTableConstraints() does not exist on Yiisoft\Db\Pgsql\Schema. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

321
        /** @scrutinizer ignore-call */ 
322
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
322
323 42
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
324
    }
325
326
    /**
327
     * Loads all foreign keys for the given table.
328
     *
329
     * @param string $tableName The table name.
330
     *
331
     * @throws Exception
332
     * @throws InvalidConfigException
333
     * @throws Throwable
334
     *
335
     * @return array Foreign keys for the given table.
336
     *
337
     * @psaml-return array|ForeignKeyConstraint[]
338
     */
339 8
    protected function loadTableForeignKeys(string $tableName): array
340
    {
341 8
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
342
343 8
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
344
    }
345
346
    /**
347
     * Loads all indexes for the given table.
348
     *
349
     * @param string $tableName The table name.
350
     *
351
     * @throws Exception
352
     * @throws InvalidConfigException
353
     * @throws Throwable
354
     *
355
     * @return IndexConstraint[] Indexes for the given table.
356
     */
357 40
    protected function loadTableIndexes(string $tableName): array
358
    {
359 40
        $sql = <<<SQL
360
        SELECT
361
            "ic"."relname" AS "name",
362
            "ia"."attname" AS "column_name",
363
            "i"."indisunique" AS "index_is_unique",
364
            "i"."indisprimary" AS "index_is_primary"
365
        FROM "pg_class" AS "tc"
366
        INNER JOIN "pg_namespace" AS "tcns"
367
            ON "tcns"."oid" = "tc"."relnamespace"
368
        LEFT JOIN pg_rewrite AS rw
369
            ON tc.relkind = 'v' AND rw.ev_class = tc.oid AND rw.rulename = '_RETURN'
370
        INNER JOIN "pg_index" AS "i"
371
            ON "i"."indrelid" = "tc"."oid"
372
                OR rw.ev_action IS NOT NULL
373
                AND (SELECT regexp_matches(
374
                    rw.ev_action,
375
                    '{TARGETENTRY .*? :resorigtbl ' || "i"."indrelid" || ' :resorigcol ' || "i"."indkey"[0] || ' '
376
                )) IS NOT NULL
377
        INNER JOIN "pg_class" AS "ic"
378
            ON "ic"."oid" = "i"."indexrelid"
379
        INNER JOIN "pg_attribute" AS "ia"
380
            ON "ia"."attrelid" = "i"."indexrelid"
381
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
382
        ORDER BY "ia"."attnum" ASC
383 40
        SQL;
384
385 40
        $resolvedName = $this->resolveTableName($tableName);
386 40
        $indexes = $this->db->createCommand($sql, [
387 40
            ':schemaName' => $resolvedName->getSchemaName(),
388 40
            ':tableName' => $resolvedName->getName(),
389 40
        ])->queryAll();
390
391
        /** @psalm-var array[] $indexes */
392 40
        $indexes = $this->normalizeRowKeyCase($indexes, true);
393 40
        $indexes = DbArrayHelper::index($indexes, null, ['name']);
394 40
        $result = [];
395
396
        /**
397
         * @psalm-var object|string|null $name
398
         * @psalm-var array<
399
         *   array-key,
400
         *   array{
401
         *     name: string,
402
         *     column_name: string,
403
         *     index_is_unique: bool,
404
         *     index_is_primary: bool
405
         *   }
406
         * > $index
407
         */
408 40
        foreach ($indexes as $name => $index) {
409 37
            $ic = (new IndexConstraint())
410 37
                ->name($name)
411 37
                ->columnNames(DbArrayHelper::getColumn($index, 'column_name'))
412 37
                ->primary($index[0]['index_is_primary'])
413 37
                ->unique($index[0]['index_is_unique']);
414
415 37
            $result[] = $ic;
416
        }
417
418 40
        return $result;
419
    }
420
421
    /**
422
     * Loads all unique constraints for the given table.
423
     *
424
     * @param string $tableName The table name.
425
     *
426
     * @throws Exception
427
     * @throws InvalidConfigException
428
     * @throws Throwable
429
     *
430
     * @return array Unique constraints for the given table.
431
     *
432
     * @psalm-return array|Constraint[]
433
     */
434 17
    protected function loadTableUniques(string $tableName): array
435
    {
436 17
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
437
438 17
        return is_array($tableUniques) ? $tableUniques : [];
439
    }
440
441
    /**
442
     * Loads all check constraints for the given table.
443
     *
444
     * @param string $tableName The table name.
445
     *
446
     * @throws Exception
447
     * @throws InvalidConfigException
448
     * @throws Throwable
449
     *
450
     * @return array Check constraints for the given table.
451
     *
452
     * @psaml-return array|CheckConstraint[]
453
     */
454 17
    protected function loadTableChecks(string $tableName): array
455
    {
456 17
        $tableChecks = $this->loadTableConstraints($tableName, self::CHECKS);
457
458 17
        return is_array($tableChecks) ? $tableChecks : [];
459
    }
460
461
    /**
462
     * Loads all default value constraints for the given table.
463
     *
464
     * @param string $tableName The table name.
465
     *
466
     * @throws NotSupportedException
467
     *
468
     * @return DefaultValueConstraint[] Default value constraints for the given table.
469
     */
470 13
    protected function loadTableDefaultValues(string $tableName): array
471
    {
472 13
        throw new NotSupportedException(__METHOD__ . ' is not supported by PostgreSQL.');
473
    }
474
475
    /**
476
     * @throws Exception
477
     * @throws InvalidConfigException
478
     * @throws Throwable
479
     */
480 3
    protected function findViewNames(string $schema = ''): array
481
    {
482 3
        if ($schema === '') {
483 1
            $schema = $this->defaultSchema;
484
        }
485
486 3
        $sql = <<<SQL
487
        SELECT c.relname AS table_name
488
        FROM pg_class c
489
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
490
        WHERE ns.nspname = :schemaName AND (c.relkind = 'v' OR c.relkind = 'm')
491
        ORDER BY c.relname
492 3
        SQL;
493
494 3
        return $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
495
    }
496
497
    /**
498
     * Collects the foreign key column details for the given table.
499
     *
500
     * @param TableSchemaInterface $table The table metadata
501
     *
502
     * @throws Exception
503
     * @throws InvalidConfigException
504
     * @throws Throwable
505
     */
506 162
    protected function findConstraints(TableSchemaInterface $table): void
0 ignored issues
show
Unused Code introduced by
The parameter $table is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

506
    protected function findConstraints(/** @scrutinizer ignore-unused */ TableSchemaInterface $table): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
507
    {
508
        /**
509
         * We need to extract the constraints de hard way since:
510
         * {@see https://www.postgresql.org/message-id/[email protected]}
511
         */
512
513 162
        $sql = <<<SQL
514
        SELECT
515
            ct.conname as constraint_name,
516
            a.attname as column_name,
517
            fc.relname as foreign_table_name,
518
            fns.nspname as foreign_table_schema,
519
            fa.attname as foreign_column_name
520
            FROM
521
            (SELECT ct.conname, ct.conrelid, ct.confrelid, ct.conkey, ct.contype, ct.confkey,
522
                generate_subscripts(ct.conkey, 1) AS s
523
                FROM pg_constraint ct
524
            ) AS ct
525
            inner join pg_class c on c.oid=ct.conrelid
526
            inner join pg_namespace ns on c.relnamespace=ns.oid
527
            inner join pg_attribute a on a.attrelid=ct.conrelid and a.attnum = ct.conkey[ct.s]
528
            left join pg_class fc on fc.oid=ct.confrelid
529
            left join pg_namespace fns on fc.relnamespace=fns.oid
530
            left join pg_attribute fa on fa.attrelid=ct.confrelid and fa.attnum = ct.confkey[ct.s]
531
        WHERE
532
            ct.contype='f'
533
            and c.relname=:tableName
534
            and ns.nspname=:schemaName
535
        ORDER BY
536
            fns.nspname, fc.relname, a.attnum
537 162
        SQL;
538
539
        /** @psalm-var array{array{tableName: string, columns: array}} $constraints */
540 162
        $constraints = [];
541
542
        /** @psalm-var array<FindConstraintArray> $rows */
543 162
        $rows = $this->db->createCommand($sql, [
544 162
            ':schemaName' => $table->getSchemaName(),
545 162
            ':tableName' => $table->getName(),
546 162
        ])->queryAll();
547
548 162
        foreach ($rows as $constraint) {
549
            /** @psalm-var FindConstraintArray $constraint */
550 16
            $constraint = $this->normalizeRowKeyCase($constraint, false);
551
552 16
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
553 3
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
554
            } else {
555 16
                $foreignTable = $constraint['foreign_table_name'];
556
            }
557
558 16
            $name = $constraint['constraint_name'];
559
560 16
            if (!isset($constraints[$name])) {
561 16
                $constraints[$name] = [
562 16
                    'tableName' => $foreignTable,
563 16
                    'columns' => [],
564 16
                ];
565
            }
566
567 16
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
568
        }
569
570
        /**
571
         * @psalm-var int|string $foreingKeyName.
572
         * @psalm-var array{tableName: string, columns: array} $constraint
573
         */
574 162
        foreach ($constraints as $foreingKeyName => $constraint) {
575 16
            $table->foreignKey(
576 16
                (string) $foreingKeyName,
577 16
                array_merge([$constraint['tableName']], $constraint['columns'])
578 16
            );
579
        }
580
    }
581
582
    /**
583
     * Gets information about given table unique indexes.
584
     *
585
     * @param TableSchemaInterface $table The table metadata.
586
     *
587
     * @throws Exception
588
     * @throws InvalidConfigException
589
     * @throws Throwable
590
     *
591
     * @return array With index and column names.
592
     */
593 1
    protected function getUniqueIndexInformation(TableSchemaInterface $table): array
594
    {
595 1
        $sql = <<<'SQL'
596
        SELECT
597
            i.relname as indexname,
598
            pg_get_indexdef(idx.indexrelid, k + 1, TRUE) AS columnname
599
        FROM (
600
            SELECT *, generate_subscripts(indkey, 1) AS k
601
            FROM pg_index
602
        ) idx
603
        INNER JOIN pg_class i ON i.oid = idx.indexrelid
604
        INNER JOIN pg_class c ON c.oid = idx.indrelid
605
        INNER JOIN pg_namespace ns ON c.relnamespace = ns.oid
606
        WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
607
        AND c.relname = :tableName AND ns.nspname = :schemaName
608
        ORDER BY i.relname, k
609 1
        SQL;
610
611 1
        return $this->db->createCommand($sql, [
612 1
            ':schemaName' => $table->getSchemaName(),
613 1
            ':tableName' => $table->getName(),
614 1
        ])->queryAll();
615
    }
616
617
    /**
618
     * Returns all unique indexes for the given table.
619
     *
620
     * Each array element is of the following structure:
621
     *
622
     * ```php
623
     * [
624
     *     'IndexName1' => ['col1' [, ...]],
625
     *     'IndexName2' => ['col2' [, ...]],
626
     * ]
627
     * ```
628
     *
629
     * @param TableSchemaInterface $table The table metadata
630
     *
631
     * @throws Exception
632
     * @throws InvalidConfigException
633
     * @throws Throwable
634
     *
635
     * @return array All unique indexes for the given table.
636
     */
637 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
638
    {
639 1
        $uniqueIndexes = [];
640
641
        /** @psalm-var array{indexname: string, columnname: string} $row */
642 1
        foreach ($this->getUniqueIndexInformation($table) as $row) {
643
            /** @psalm-var array{indexname: string, columnname: string} $row */
644 1
            $row = $this->normalizeRowKeyCase($row, false);
645
646 1
            $column = $row['columnname'];
647
648 1
            if (str_starts_with($column, '"') && str_ends_with($column, '"')) {
649
                /**
650
                 * postgres will quote names that aren't lowercase-only.
651
                 *
652
                 * {@see https://github.com/yiisoft/yii2/issues/10613}
653
                 */
654 1
                $column = substr($column, 1, -1);
655
            }
656
657 1
            $uniqueIndexes[$row['indexname']][] = $column;
658
        }
659
660 1
        return $uniqueIndexes;
661
    }
662
663
    /**
664
     * Collects the metadata of table columns.
665
     *
666
     * @param TableSchemaInterface $table The table metadata.
667
     *
668
     * @throws Exception
669
     * @throws InvalidConfigException
670
     * @throws JsonException
671
     * @throws Throwable
672
     *
673
     * @return bool Whether the table exists in the database.
674
     */
675 184
    protected function findColumns(TableSchemaInterface $table): bool
676
    {
677 184
        $orIdentity = '';
678
679 184
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
680 177
            $orIdentity = 'OR a.attidentity != \'\'';
681
        }
682
683 184
        $sql = <<<SQL
684 184
        SELECT
685
            d.nspname AS table_schema,
686
            c.relname AS table_name,
687
            a.attname AS column_name,
688
            COALESCE(td.typname, tb.typname, t.typname) AS data_type,
689
            COALESCE(td.typtype, tb.typtype, t.typtype) AS type_type,
690
            (SELECT nspname FROM pg_namespace WHERE oid = COALESCE(td.typnamespace, tb.typnamespace, t.typnamespace)) AS type_scheme,
691
            a.attlen AS character_maximum_length,
692
            pg_catalog.col_description(c.oid, a.attnum) AS column_comment,
693
            information_schema._pg_truetypmod(a, t) AS modifier,
694
            NOT (a.attnotnull OR t.typnotnull) AS is_nullable,
695
            COALESCE(t.typdefault, pg_get_expr(ad.adbin, ad.adrelid)) AS column_default,
696 184
            COALESCE(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval', false) $orIdentity AS is_autoinc,
697
            pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname)
698
            AS sequence_name,
699
            CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
700
                THEN array_to_string(
701
                    (
702
                        SELECT array_agg(enumlabel)
703
                        FROM pg_enum
704
                        WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid)
705
                    )::varchar[],
706
                ',')
707
                ELSE NULL
708
            END AS enum_values,
709
            information_schema._pg_numeric_precision(
710
                COALESCE(td.oid, tb.oid, a.atttypid),
711
                information_schema._pg_truetypmod(a, t)
712
            ) AS numeric_precision,
713
            information_schema._pg_numeric_scale(
714
                COALESCE(td.oid, tb.oid, a.atttypid),
715
                information_schema._pg_truetypmod(a, t)
716
            ) AS numeric_scale,
717
            information_schema._pg_char_max_length(
718
                COALESCE(td.oid, tb.oid, a.atttypid),
719
                information_schema._pg_truetypmod(a, t)
720
            ) AS size,
721
            ct.oid IS NOT NULL AS is_pkey,
722
            COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
723
        FROM
724
            pg_class c
725
            LEFT JOIN pg_attribute a ON a.attrelid = c.oid
726
            LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
727
            LEFT JOIN pg_type t ON a.atttypid = t.oid
728
            LEFT JOIN pg_type tb ON (a.attndims > 0 OR t.typcategory='A') AND t.typelem > 0 AND t.typelem = tb.oid
729
                                        OR t.typbasetype > 0 AND t.typbasetype = tb.oid
730
            LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
731
            LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
732
            LEFT JOIN pg_rewrite rw ON c.relkind = 'v' AND rw.ev_class = c.oid AND rw.rulename = '_RETURN'
733
            LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p' AND a.attnum = ANY (ct.conkey)
734
                OR rw.ev_action IS NOT NULL AND ct.contype = 'p'
735
                AND (ARRAY(
736
                    SELECT regexp_matches(rw.ev_action, '{TARGETENTRY .*? :resorigtbl (\d+) :resorigcol (\d+) ', 'g')
737
                ))[a.attnum:a.attnum] <@ (ct.conrelid || ct.conkey)::text[]
738
        WHERE
739
            a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
740
            AND c.relname = :tableName
741
            AND d.nspname = :schemaName
742
        ORDER BY
743
            a.attnum;
744 184
        SQL;
745
746 184
        $columns = $this->db->createCommand($sql, [
747 184
            ':schemaName' => $table->getSchemaName(),
748 184
            ':tableName' => $table->getName(),
749 184
        ])->queryAll();
750
751 184
        if (empty($columns)) {
752 43
            return false;
753
        }
754
755
        /** @psalm-var ColumnArray $info */
756 162
        foreach ($columns as $info) {
757
            /** @psalm-var ColumnArray $info */
758 162
            $info = $this->normalizeRowKeyCase($info, false);
759
760
            /** @psalm-var ColumnSchema $column */
761 162
            $column = $this->loadColumnSchema($info);
762
763 162
            $table->column($column->getName(), $column);
764
765 162
            if ($column->isPrimaryKey()) {
766 102
                $table->primaryKey($column->getName());
767
768 102
                if ($table->getSequenceName() === null) {
769 102
                    $table->sequenceName($column->getSequenceName());
770
                }
771
            }
772
        }
773
774 162
        return true;
775
    }
776
777
    /**
778
     * Loads the column information into a {@see ColumnSchemaInterface} object.
779
     *
780
     * @psalm-param ColumnArray $info Column information.
781
     *
782
     * @return ColumnSchemaInterface The column schema object.
783
     */
784 162
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
785
    {
786 162
        $column = $this->createColumnSchema($info['column_name']);
787 162
        $column->allowNull($info['is_nullable']);
788 162
        $column->autoIncrement($info['is_autoinc']);
789 162
        $column->comment($info['column_comment']);
790
791 162
        if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)) {
792 1
            $column->dbType($info['type_scheme'] . '.' . $info['data_type']);
793
        } else {
794 162
            $column->dbType($info['data_type']);
795
        }
796
797 162
        $column->enumValues($info['enum_values'] !== null
798 1
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values']))
799 162
            : null);
800 162
        $column->unsigned(false); // has no meaning in PG
801 162
        $column->primaryKey((bool) $info['is_pkey']);
802 162
        $column->precision($info['numeric_precision']);
803 162
        $column->scale($info['numeric_scale']);
804 162
        $column->size($info['size'] === null ? null : (int) $info['size']);
805 162
        $column->dimension($info['dimension']);
806
807
        /**
808
         * pg_get_serial_sequence() doesn't track DEFAULT value change.
809
         * GENERATED BY IDENTITY columns always have a null default value.
810
         */
811 162
        $defaultValue = $info['column_default'];
812
813
        if (
814 162
            $defaultValue !== null
815 162
            && preg_match("/^nextval\('([^']+)/", $defaultValue, $matches) === 1
816
        ) {
817 81
            $column->sequenceName($matches[1]);
818 162
        } elseif ($info['sequence_name'] !== null) {
819 5
            $column->sequenceName($this->resolveTableName($info['sequence_name'])->getFullName());
820
        }
821
822 162
        $column->type($this->typeMap[(string) $column->getDbType()] ?? self::TYPE_STRING);
823 162
        $column->phpType($this->getColumnPhpType($column));
824 162
        $column->defaultValue($this->normalizeDefaultValue($defaultValue, $column));
825
826 162
        return $column;
827
    }
828
829
    /**
830
     * Extracts the PHP type from an abstract DB type.
831
     *
832
     * @param ColumnSchemaInterface $column The column schema information.
833
     *
834
     * @return string The PHP type name.
835
     */
836 162
    protected function getColumnPhpType(ColumnSchemaInterface $column): string
837
    {
838 162
        if ($column->getType() === self::TYPE_BIT) {
839 34
            return self::PHP_TYPE_INTEGER;
840
        }
841
842 162
        return parent::getColumnPhpType($column);
843
    }
844
845
    /**
846
     * Converts column's default value according to {@see ColumnSchema::phpType} after retrieval from the database.
847
     *
848
     * @param string|null $defaultValue The default value retrieved from the database.
849
     * @param ColumnSchemaInterface $column The column schema object.
850
     *
851
     * @return mixed The normalized default value.
852
     */
853 162
    private function normalizeDefaultValue(string|null $defaultValue, ColumnSchemaInterface $column): mixed
854
    {
855
        if (
856 162
            $defaultValue === null
857 123
            || $column->isPrimaryKey()
858 162
            || str_starts_with($defaultValue, 'NULL::')
859
        ) {
860 158
            return null;
861
        }
862
863 94
        if ($column->getType() === self::TYPE_BOOLEAN && in_array($defaultValue, ['true', 'false'], true)) {
864 66
            return $defaultValue === 'true';
865
        }
866
867
        if (
868 93
            in_array($column->getType(), [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME], true)
869 93
            && in_array(strtoupper($defaultValue), ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'], true)
870
        ) {
871 34
            return new Expression($defaultValue);
872
        }
873
874 93
        $value = preg_replace("/^B?['(](.*?)[)'](?:::[^:]+)?$/s", '$1', $defaultValue);
875 93
        $value = str_replace("''", "'", $value);
876
877 93
        if ($column->getType() === self::TYPE_BINARY && str_starts_with($value, '\\x')) {
878 34
            return hex2bin(substr($value, 2));
879
        }
880
881 93
        return $column->phpTypecast($value);
882
    }
883
884
    /**
885
     * Loads multiple types of constraints and returns the specified ones.
886
     *
887
     * @param string $tableName The table name.
888
     * @param string $returnType The return type:
889
     * - primaryKey
890
     * - foreignKeys
891
     * - uniques
892
     * - checks
893
     *
894
     * @throws Exception
895
     * @throws InvalidConfigException
896
     * @throws Throwable
897
     *
898
     * @return array|Constraint|null Constraints.
899
     *
900
     * @psalm-return CheckConstraint[]|Constraint[]|ForeignKeyConstraint[]|Constraint|null
901
     */
902 84
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
903
    {
904 84
        $sql = <<<SQL
905
        SELECT
906
            "c"."conname" AS "name",
907
            "a"."attname" AS "column_name",
908
            "c"."contype" AS "type",
909
            "ftcns"."nspname" AS "foreign_table_schema",
910
            "ftc"."relname" AS "foreign_table_name",
911
            "fa"."attname" AS "foreign_column_name",
912
            "c"."confupdtype" AS "on_update",
913
            "c"."confdeltype" AS "on_delete",
914
            pg_get_constraintdef("c"."oid") AS "check_expr"
915
        FROM "pg_class" AS "tc"
916
        INNER JOIN "pg_namespace" AS "tcns"
917
            ON "tcns"."oid" = "tc"."relnamespace"
918
        INNER JOIN "pg_attribute" AS "a"
919
            ON "a"."attrelid" = "tc"."oid"
920
        LEFT JOIN pg_rewrite AS rw
921
            ON "tc"."relkind" = 'v' AND "rw"."ev_class" = "tc"."oid" AND "rw"."rulename" = '_RETURN'
922
        INNER JOIN "pg_constraint" AS "c"
923
            ON "c"."conrelid" = "tc"."oid" AND "a"."attnum" = ANY ("c"."conkey")
924
                OR "rw"."ev_action" IS NOT NULL AND "c"."conrelid" != 0
925
                AND (ARRAY(
926
                    SELECT regexp_matches("rw"."ev_action", '{TARGETENTRY .*? :resorigtbl (\d+) :resorigcol (\d+) ', 'g')
927
                ))["a"."attnum":"a"."attnum"] <@ ("c"."conrelid" || "c"."conkey")::text[]
928
        LEFT JOIN "pg_class" AS "ftc"
929
            ON "ftc"."oid" = "c"."confrelid"
930
        LEFT JOIN "pg_namespace" AS "ftcns"
931
            ON "ftcns"."oid" = "ftc"."relnamespace"
932
        LEFT JOIN "pg_attribute" "fa"
933
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
934
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
935
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
936 84
        SQL;
937
938
        /** @psalm-var string[] $actionTypes */
939 84
        $actionTypes = [
940 84
            'a' => 'NO ACTION',
941 84
            'r' => 'RESTRICT',
942 84
            'c' => 'CASCADE',
943 84
            'n' => 'SET NULL',
944 84
            'd' => 'SET DEFAULT',
945 84
        ];
946
947 84
        $resolvedName = $this->resolveTableName($tableName);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $tableName does not exist. Did you maybe mean $table?
Loading history...
948 84
        $constraints = $this->db->createCommand($sql, [
949 84
            ':schemaName' => $resolvedName->getSchemaName(),
950 84
            ':tableName' => $resolvedName->getName(),
951 84
        ])->queryAll();
952
953
        /** @psalm-var array[][] $constraints */
954 84
        $constraints = $this->normalizeRowKeyCase($constraints, true);
955 84
        $constraints = DbArrayHelper::index($constraints, null, ['type', 'name']);
956
957 84
        $result = [
958 84
            self::PRIMARY_KEY => null,
959 84
            self::FOREIGN_KEYS => [],
960 84
            self::UNIQUES => [],
961 84
            self::CHECKS => [],
962 84
        ];
963
964
        /**
965
         * @psalm-var string $type
966
         * @psalm-var array $names
967
         */
968 84
        foreach ($constraints as $type => $names) {
969
            /**
970
             * @psalm-var object|string|null $name
971
             * @psalm-var ConstraintArray $constraint
972
             */
973 84
            foreach ($names as $name => $constraint) {
974
                switch ($type) {
975 84
                    case 'p':
976 59
                        $result[self::PRIMARY_KEY] = (new Constraint())
977 59
                            ->name($name)
978 59
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
979 59
                        break;
980 74
                    case 'f':
981 19
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
982 19
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
983
984 19
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
985 19
                            ->name($name)
986 19
                            ->columnNames(array_values(
987 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'column_name'))
988 19
                            ))
989 19
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
990 19
                            ->foreignTableName($constraint[0]['foreign_table_name'])
991 19
                            ->foreignColumnNames(array_values(
992 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'foreign_column_name'))
993 19
                            ))
994 19
                            ->onDelete($onDelete)
995 19
                            ->onUpdate($onUpdate);
996 19
                        break;
997 61
                    case 'u':
998 58
                        $result[self::UNIQUES][] = (new Constraint())
999 58
                            ->name($name)
1000 58
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
1001 58
                        break;
1002 15
                    case 'c':
1003 15
                        $result[self::CHECKS][] = (new CheckConstraint())
1004 15
                            ->name($name)
1005 15
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
1006 15
                            ->expression($constraint[0]['check_expr']);
1007 15
                        break;
1008
                }
1009
            }
1010
        }
1011
1012 84
        foreach ($result as $type => $data) {
1013 84
            $this->setTableMetadata($tableName, $type, $data);
1014
        }
1015
1016 84
        return $result[$returnType];
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result[$returnType] could return the type array which is incompatible with the type-hinted return void. Consider adding an additional type-check to rule them out.
Loading history...
Comprehensibility Best Practice introduced by
The variable $returnType seems to be never defined.
Loading history...
1017
    }
1018
1019
    /**
1020
     * Creates a column schema for the database.
1021
     *
1022
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1023
     *
1024
     * @param string $name Name of the column.
1025
     *
1026
     * @return ColumnSchema
1027
     */
1028 162
    private function createColumnSchema(string $name): ColumnSchema
0 ignored issues
show
Unused Code introduced by
The method createColumnSchema() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
1029
    {
1030 162
        return new ColumnSchema($name);
1031
    }
1032
1033
    /**
1034
     * Returns the cache key for the specified table name.
1035
     *
1036
     * @param string $name The table name.
1037
     *
1038
     * @return array The cache key.
1039
     */
1040 275
    protected function getCacheKey(string $name): array
1041
    {
1042 275
        return array_merge([self::class], $this->generateCacheKey(), [$this->getRawTableName($name)]);
1043
    }
1044
1045
    /**
1046
     * Returns the cache tag name.
1047
     *
1048
     * This allows {@see refresh()} to invalidate all cached table schemas.
1049
     *
1050
     * @return string The cache tag name.
1051
     */
1052 237
    protected function getCacheTag(): string
1053
    {
1054 237
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
1055
    }
1056
}
1057