Passed
Pull Request — master (#316)
by Sergei
04:42 queued 28s
created

Schema::findTableNames()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 2
eloc 10
c 3
b 0
f 0
nc 2
nop 1
dl 0
loc 15
ccs 6
cts 6
cp 1
crap 2
rs 9.9332
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
735
                AND (ARRAY(SELECT regexp_matches(
736
                    rw.ev_action,
737
                    '{TARGETENTRY .*? :resorigtbl ' || ct.conrelid || ' :resorigcol (\d+) ',
738
                    'g'
739
                )))[a.attnum][1]::smallint = ANY (ct.conkey)
740
        WHERE
741
            a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
742
            AND c.relname = :tableName
743
            AND d.nspname = :schemaName
744
        ORDER BY
745
            a.attnum;
746 184
        SQL;
747
748 184
        $columns = $this->db->createCommand($sql, [
749 184
            ':schemaName' => $table->getSchemaName(),
750 184
            ':tableName' => $table->getName(),
751 184
        ])->queryAll();
752
753 184
        if (empty($columns)) {
754 43
            return false;
755
        }
756
757
        /** @psalm-var ColumnArray $info */
758 162
        foreach ($columns as $info) {
759
            /** @psalm-var ColumnArray $info */
760 162
            $info = $this->normalizeRowKeyCase($info, false);
761
762
            /** @psalm-var ColumnSchema $column */
763 162
            $column = $this->loadColumnSchema($info);
764
765 162
            $table->column($column->getName(), $column);
766
767 162
            if ($column->isPrimaryKey()) {
768 102
                $table->primaryKey($column->getName());
769
770 102
                if ($table->getSequenceName() === null) {
771 102
                    $table->sequenceName($column->getSequenceName());
772
                }
773
            }
774
        }
775
776 162
        return true;
777
    }
778
779
    /**
780
     * Loads the column information into a {@see ColumnSchemaInterface} object.
781
     *
782
     * @psalm-param ColumnArray $info Column information.
783
     *
784
     * @return ColumnSchemaInterface The column schema object.
785
     */
786 162
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
787
    {
788 162
        $column = $this->createColumnSchema($info['column_name']);
789 162
        $column->allowNull($info['is_nullable']);
790 162
        $column->autoIncrement($info['is_autoinc']);
791 162
        $column->comment($info['column_comment']);
792
793 162
        if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)) {
794 1
            $column->dbType($info['type_scheme'] . '.' . $info['data_type']);
795
        } else {
796 162
            $column->dbType($info['data_type']);
797
        }
798
799 162
        $column->enumValues($info['enum_values'] !== null
800 1
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values']))
801 162
            : null);
802 162
        $column->unsigned(false); // has no meaning in PG
803 162
        $column->primaryKey((bool) $info['is_pkey']);
804 162
        $column->precision($info['numeric_precision']);
805 162
        $column->scale($info['numeric_scale']);
806 162
        $column->size($info['size'] === null ? null : (int) $info['size']);
807 162
        $column->dimension($info['dimension']);
808
809
        /**
810
         * pg_get_serial_sequence() doesn't track DEFAULT value change.
811
         * GENERATED BY IDENTITY columns always have a null default value.
812
         */
813 162
        $defaultValue = $info['column_default'];
814
815
        if (
816 162
            $defaultValue !== null
817 162
            && preg_match("/^nextval\('([^']+)/", $defaultValue, $matches) === 1
818
        ) {
819 81
            $column->sequenceName($matches[1]);
820 162
        } elseif ($info['sequence_name'] !== null) {
821 5
            $column->sequenceName($this->resolveTableName($info['sequence_name'])->getFullName());
822
        }
823
824 162
        $column->type($this->typeMap[(string) $column->getDbType()] ?? self::TYPE_STRING);
825 162
        $column->phpType($this->getColumnPhpType($column));
826 162
        $column->defaultValue($this->normalizeDefaultValue($defaultValue, $column));
827
828 162
        return $column;
829
    }
830
831
    /**
832
     * Extracts the PHP type from an abstract DB type.
833
     *
834
     * @param ColumnSchemaInterface $column The column schema information.
835
     *
836
     * @return string The PHP type name.
837
     */
838 162
    protected function getColumnPhpType(ColumnSchemaInterface $column): string
839
    {
840 162
        if ($column->getType() === self::TYPE_BIT) {
841 34
            return self::PHP_TYPE_INTEGER;
842
        }
843
844 162
        return parent::getColumnPhpType($column);
845
    }
846
847
    /**
848
     * Converts column's default value according to {@see ColumnSchema::phpType} after retrieval from the database.
849
     *
850
     * @param string|null $defaultValue The default value retrieved from the database.
851
     * @param ColumnSchemaInterface $column The column schema object.
852
     *
853
     * @return mixed The normalized default value.
854
     */
855 162
    private function normalizeDefaultValue(string|null $defaultValue, ColumnSchemaInterface $column): mixed
856
    {
857
        if (
858 162
            $defaultValue === null
859 123
            || $column->isPrimaryKey()
860 162
            || str_starts_with($defaultValue, 'NULL::')
861
        ) {
862 158
            return null;
863
        }
864
865 94
        if ($column->getType() === self::TYPE_BOOLEAN && in_array($defaultValue, ['true', 'false'], true)) {
866 66
            return $defaultValue === 'true';
867
        }
868
869
        if (
870 93
            in_array($column->getType(), [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME], true)
871 93
            && in_array(strtoupper($defaultValue), ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'], true)
872
        ) {
873 34
            return new Expression($defaultValue);
874
        }
875
876 93
        $value = preg_replace("/^B?['(](.*?)[)'](?:::[^:]+)?$/s", '$1', $defaultValue);
877 93
        $value = str_replace("''", "'", $value);
878
879 93
        if ($column->getType() === self::TYPE_BINARY && str_starts_with($value, '\\x')) {
880 34
            return hex2bin(substr($value, 2));
881
        }
882
883 93
        return $column->phpTypecast($value);
884
    }
885
886
    /**
887
     * Loads multiple types of constraints and returns the specified ones.
888
     *
889
     * @param string $tableName The table name.
890
     * @param string $returnType The return type:
891
     * - primaryKey
892
     * - foreignKeys
893
     * - uniques
894
     * - checks
895
     *
896
     * @throws Exception
897
     * @throws InvalidConfigException
898
     * @throws Throwable
899
     *
900
     * @return array|Constraint|null Constraints.
901
     *
902
     * @psalm-return CheckConstraint[]|Constraint[]|ForeignKeyConstraint[]|Constraint|null
903
     */
904 84
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
905
    {
906 84
        $sql = <<<SQL
907
        SELECT
908
            "c"."conname" AS "name",
909
            "a"."attname" AS "column_name",
910
            "c"."contype" AS "type",
911
            "ftcns"."nspname" AS "foreign_table_schema",
912
            "ftc"."relname" AS "foreign_table_name",
913
            "fa"."attname" AS "foreign_column_name",
914
            "c"."confupdtype" AS "on_update",
915
            "c"."confdeltype" AS "on_delete",
916
            pg_get_constraintdef("c"."oid") AS "check_expr"
917
        FROM "pg_class" AS "tc"
918
        INNER JOIN "pg_namespace" AS "tcns"
919
            ON "tcns"."oid" = "tc"."relnamespace"
920
        INNER JOIN "pg_attribute" AS "a"
921
            ON "a"."attrelid" = "tc"."oid"
922
        LEFT JOIN pg_rewrite AS rw ON tc.relkind = 'v' AND rw.ev_class = tc.oid AND rw.rulename = '_RETURN'
923
        INNER JOIN "pg_constraint" AS "c"
924
            ON "c"."conrelid" = "tc"."oid" AND "a"."attnum" = ANY ("c"."conkey")
925
                OR rw.ev_action IS NOT NULL
926
                AND (ARRAY(SELECT regexp_matches(
927
                    rw.ev_action,
928
                    '{TARGETENTRY .*? :resorigtbl ' || c.conrelid || ' :resorigcol (\d+) ',
929
                    'g'
930
                )))[a.attnum][1]::smallint = ANY (c.conkey)
931
        LEFT JOIN "pg_class" AS "ftc"
932
            ON "ftc"."oid" = "c"."confrelid"
933
        LEFT JOIN "pg_namespace" AS "ftcns"
934
            ON "ftcns"."oid" = "ftc"."relnamespace"
935
        LEFT JOIN "pg_attribute" "fa"
936
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
937
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
938
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
939 84
        SQL;
940
941
        /** @psalm-var string[] $actionTypes */
942 84
        $actionTypes = [
943 84
            'a' => 'NO ACTION',
944 84
            'r' => 'RESTRICT',
945 84
            'c' => 'CASCADE',
946 84
            'n' => 'SET NULL',
947 84
            'd' => 'SET DEFAULT',
948 84
        ];
949
950 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...
951 84
        $constraints = $this->db->createCommand($sql, [
952 84
            ':schemaName' => $resolvedName->getSchemaName(),
953 84
            ':tableName' => $resolvedName->getName(),
954 84
        ])->queryAll();
955
956
        /** @psalm-var array[][] $constraints */
957 84
        $constraints = $this->normalizeRowKeyCase($constraints, true);
958 84
        $constraints = DbArrayHelper::index($constraints, null, ['type', 'name']);
959
960 84
        $result = [
961 84
            self::PRIMARY_KEY => null,
962 84
            self::FOREIGN_KEYS => [],
963 84
            self::UNIQUES => [],
964 84
            self::CHECKS => [],
965 84
        ];
966
967
        /**
968
         * @psalm-var string $type
969
         * @psalm-var array $names
970
         */
971 84
        foreach ($constraints as $type => $names) {
972
            /**
973
             * @psalm-var object|string|null $name
974
             * @psalm-var ConstraintArray $constraint
975
             */
976 84
            foreach ($names as $name => $constraint) {
977
                switch ($type) {
978 84
                    case 'p':
979 59
                        $result[self::PRIMARY_KEY] = (new Constraint())
980 59
                            ->name($name)
981 59
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
982 59
                        break;
983 74
                    case 'f':
984 19
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
985 19
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
986
987 19
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
988 19
                            ->name($name)
989 19
                            ->columnNames(array_values(
990 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'column_name'))
991 19
                            ))
992 19
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
993 19
                            ->foreignTableName($constraint[0]['foreign_table_name'])
994 19
                            ->foreignColumnNames(array_values(
995 19
                                array_unique(DbArrayHelper::getColumn($constraint, 'foreign_column_name'))
996 19
                            ))
997 19
                            ->onDelete($onDelete)
998 19
                            ->onUpdate($onUpdate);
999 19
                        break;
1000 61
                    case 'u':
1001 58
                        $result[self::UNIQUES][] = (new Constraint())
1002 58
                            ->name($name)
1003 58
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
1004 58
                        break;
1005 15
                    case 'c':
1006 15
                        $result[self::CHECKS][] = (new CheckConstraint())
1007 15
                            ->name($name)
1008 15
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
1009 15
                            ->expression($constraint[0]['check_expr']);
1010 15
                        break;
1011
                }
1012
            }
1013
        }
1014
1015 84
        foreach ($result as $type => $data) {
1016 84
            $this->setTableMetadata($tableName, $type, $data);
1017
        }
1018
1019 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...
1020
    }
1021
1022
    /**
1023
     * Creates a column schema for the database.
1024
     *
1025
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1026
     *
1027
     * @param string $name Name of the column.
1028
     *
1029
     * @return ColumnSchema
1030
     */
1031 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...
1032
    {
1033 162
        return new ColumnSchema($name);
1034
    }
1035
1036
    /**
1037
     * Returns the cache key for the specified table name.
1038
     *
1039
     * @param string $name The table name.
1040
     *
1041
     * @return array The cache key.
1042
     */
1043 275
    protected function getCacheKey(string $name): array
1044
    {
1045 275
        return array_merge([self::class], $this->generateCacheKey(), [$this->getRawTableName($name)]);
1046
    }
1047
1048
    /**
1049
     * Returns the cache tag name.
1050
     *
1051
     * This allows {@see refresh()} to invalidate all cached table schemas.
1052
     *
1053
     * @return string The cache tag name.
1054
     */
1055 237
    protected function getCacheTag(): string
1056
    {
1057 237
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
1058
    }
1059
}
1060