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

Schema::findSchemaNames()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 7
c 2
b 0
f 0
nc 1
nop 0
dl 0
loc 10
ccs 4
cts 4
cp 1
crap 1
rs 10
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 242
    protected function resolveTableName(string $name): TableSchemaInterface
189
    {
190 242
        $resolvedName = new TableSchema();
191
192 242
        $parts = array_reverse($this->db->getQuoter()->getTableNameParts($name));
193 242
        $resolvedName->name($parts[0] ?? '');
194 242
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
195
196 242
        $resolvedName->fullName(
197 242
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
198 242
            implode('.', array_reverse($parts)) : $resolvedName->getName()
199 242
        );
200
201 242
        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 187
    protected function findTableComment(TableSchemaInterface $tableSchema): void
234
    {
235 187
        $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 187
        SQL;
243
244 187
        $comment = $this->db->createCommand($sql, [
245 187
            ':schemaName' => $tableSchema->getSchemaName(),
246 187
            ':tableName' => $tableSchema->getName(),
247 187
        ])->queryScalar();
248
249 187
        $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 187
    protected function loadTableSchema(string $name): TableSchemaInterface|null
296
    {
297 187
        $table = $this->resolveTableName($name);
298 187
        $this->findTableComment($table);
299
300 187
        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 165
            $this->findConstraints($table);
302 165
            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 43
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
320
    {
321 43
        $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 43
        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 41
    protected function loadTableIndexes(string $tableName): array
358
    {
359 41
        $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 41
        SQL;
384
385 41
        $resolvedName = $this->resolveTableName($tableName);
386 41
        $indexes = $this->db->createCommand($sql, [
387 41
            ':schemaName' => $resolvedName->getSchemaName(),
388 41
            ':tableName' => $resolvedName->getName(),
389 41
        ])->queryAll();
390
391
        /** @psalm-var array[] $indexes */
392 41
        $indexes = $this->normalizeRowKeyCase($indexes, true);
393 41
        $indexes = DbArrayHelper::index($indexes, null, ['name']);
394 41
        $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 41
        foreach ($indexes as $name => $index) {
409 38
            $ic = (new IndexConstraint())
410 38
                ->name($name)
411 38
                ->columnNames(DbArrayHelper::getColumn($index, 'column_name'))
412 38
                ->primary($index[0]['index_is_primary'])
413 38
                ->unique($index[0]['index_is_unique']);
414
415 38
            $result[] = $ic;
416
        }
417
418 41
        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 165
    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 165
        $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 165
        SQL;
538
539
        /** @psalm-var array{array{tableName: string, columns: array}} $constraints */
540 165
        $constraints = [];
541
542
        /** @psalm-var array<FindConstraintArray> $rows */
543 165
        $rows = $this->db->createCommand($sql, [
544 165
            ':schemaName' => $table->getSchemaName(),
545 165
            ':tableName' => $table->getName(),
546 165
        ])->queryAll();
547
548 165
        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 165
        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 187
    protected function findColumns(TableSchemaInterface $table): bool
676
    {
677 187
        $orIdentity = '';
678
679 187
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
680 180
            $orIdentity = 'OR a.attidentity != \'\'';
681
        }
682
683 187
        $sql = <<<SQL
684 187
        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 187
            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::text || 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 187
        SQL;
745
746 187
        $columns = $this->db->createCommand($sql, [
747 187
            ':schemaName' => $table->getSchemaName(),
748 187
            ':tableName' => $table->getName(),
749 187
        ])->queryAll();
750
751 187
        if (empty($columns)) {
752 43
            return false;
753
        }
754
755
        /** @psalm-var ColumnArray $info */
756 165
        foreach ($columns as $info) {
757
            /** @psalm-var ColumnArray $info */
758 165
            $info = $this->normalizeRowKeyCase($info, false);
759
760
            /** @psalm-var ColumnSchema $column */
761 165
            $column = $this->loadColumnSchema($info);
762
763 165
            $table->column($column->getName(), $column);
764
765 165
            if ($column->isPrimaryKey()) {
766 100
                $table->primaryKey($column->getName());
767
768 100
                if ($table->getSequenceName() === null) {
769 100
                    $table->sequenceName($column->getSequenceName());
770
                }
771
            }
772
        }
773
774 165
        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 165
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
785
    {
786 165
        $column = $this->createColumnSchema($info['column_name']);
787 165
        $column->allowNull($info['is_nullable']);
788 165
        $column->autoIncrement($info['is_autoinc']);
789 165
        $column->comment($info['column_comment']);
790
791 165
        if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)) {
792 1
            $column->dbType($info['type_scheme'] . '.' . $info['data_type']);
793
        } else {
794 165
            $column->dbType($info['data_type']);
795
        }
796
797 165
        $column->enumValues($info['enum_values'] !== null
798 1
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values']))
799 165
            : null);
800 165
        $column->unsigned(false); // has no meaning in PG
801 165
        $column->primaryKey((bool) $info['is_pkey']);
802 165
        $column->precision($info['numeric_precision']);
803 165
        $column->scale($info['numeric_scale']);
804 165
        $column->size($info['size'] === null ? null : (int) $info['size']);
805 165
        $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 165
        $defaultValue = $info['column_default'];
812
813
        if (
814 165
            $defaultValue !== null
815 165
            && preg_match("/^nextval\('([^']+)/", $defaultValue, $matches) === 1
816
        ) {
817 79
            $column->sequenceName($matches[1]);
818 165
        } elseif ($info['sequence_name'] !== null) {
819 5
            $column->sequenceName($this->resolveTableName($info['sequence_name'])->getFullName());
820
        }
821
822 165
        $column->type($this->typeMap[(string) $column->getDbType()] ?? self::TYPE_STRING);
823 165
        $column->phpType($this->getColumnPhpType($column));
824 165
        $column->defaultValue($this->normalizeDefaultValue($defaultValue, $column));
825
826 165
        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 165
    protected function getColumnPhpType(ColumnSchemaInterface $column): string
837
    {
838 165
        if ($column->getType() === self::TYPE_BIT) {
839 39
            return self::PHP_TYPE_INTEGER;
840
        }
841
842 165
        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 165
    private function normalizeDefaultValue(string|null $defaultValue, ColumnSchemaInterface $column): mixed
854
    {
855
        if (
856 165
            $defaultValue === null
857 126
            || $column->isPrimaryKey()
858 165
            || str_starts_with($defaultValue, 'NULL::')
859
        ) {
860 161
            return null;
861
        }
862
863 97
        if ($column->getType() === self::TYPE_BOOLEAN && in_array($defaultValue, ['true', 'false'], true)) {
864 71
            return $defaultValue === 'true';
865
        }
866
867
        if (
868 96
            in_array($column->getType(), [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME], true)
869 96
            && in_array(strtoupper($defaultValue), ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'], true)
870
        ) {
871 39
            return new Expression($defaultValue);
872
        }
873
874 96
        $value = preg_replace("/^B?['(](.*?)[)'](?:::[^:]+)?$/s", '$1', $defaultValue);
875 96
        $value = str_replace("''", "'", $value);
876
877 96
        if ($column->getType() === self::TYPE_BINARY && str_starts_with($value, '\\x')) {
878 39
            return hex2bin(substr($value, 2));
879
        }
880
881 96
        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 85
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
903
    {
904 85
        $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"::text || "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 85
        SQL;
937
938
        /** @psalm-var string[] $actionTypes */
939 85
        $actionTypes = [
940 85
            'a' => 'NO ACTION',
941 85
            'r' => 'RESTRICT',
942 85
            'c' => 'CASCADE',
943 85
            'n' => 'SET NULL',
944 85
            'd' => 'SET DEFAULT',
945 85
        ];
946
947 85
        $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 85
        $constraints = $this->db->createCommand($sql, [
949 85
            ':schemaName' => $resolvedName->getSchemaName(),
950 85
            ':tableName' => $resolvedName->getName(),
951 85
        ])->queryAll();
952
953
        /** @psalm-var array[][] $constraints */
954 85
        $constraints = $this->normalizeRowKeyCase($constraints, true);
955 85
        $constraints = DbArrayHelper::index($constraints, null, ['type', 'name']);
956
957 85
        $result = [
958 85
            self::PRIMARY_KEY => null,
959 85
            self::FOREIGN_KEYS => [],
960 85
            self::UNIQUES => [],
961 85
            self::CHECKS => [],
962 85
        ];
963
964
        /**
965
         * @psalm-var string $type
966
         * @psalm-var array $names
967
         */
968 85
        foreach ($constraints as $type => $names) {
969
            /**
970
             * @psalm-var object|string|null $name
971
             * @psalm-var ConstraintArray $constraint
972
             */
973 85
            foreach ($names as $name => $constraint) {
974
                switch ($type) {
975 85
                    case 'p':
976 60
                        $result[self::PRIMARY_KEY] = (new Constraint())
977 60
                            ->name($name)
978 60
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
979 60
                        break;
980 75
                    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 62
                    case 'u':
998 59
                        $result[self::UNIQUES][] = (new Constraint())
999 59
                            ->name($name)
1000 59
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
1001 59
                        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 85
        foreach ($result as $type => $data) {
1013 85
            $this->setTableMetadata($tableName, $type, $data);
1014
        }
1015
1016 85
        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 165
    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 165
        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 277
    protected function getCacheKey(string $name): array
1041
    {
1042 277
        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 243
    protected function getCacheTag(): string
1053
    {
1054 243
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
1055
    }
1056
}
1057