Passed
Pull Request — master (#303)
by
unknown
03:40
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: array<array-key, float|int|string>|string|null,
54
 *   numeric_precision: int|null,
55
 *   numeric_scale: int|null,
56
 *   size: string|null,
57
 *   is_pkey: bool|null,
58
 *   dimension: int
59
 * }
60
 * @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
     * Define the abstract column type as `composite`.
90
     */
91
    public const TYPE_COMPOSITE = 'composite';
92
93
    /**
94
     * @var array The mapping from physical column types (keys) to abstract column types (values).
95
     *
96
     * @link https://www.postgresql.org/docs/current/static/datatype.html#DATATYPE-TABLE
97
     *
98
     * @psalm-var string[]
99
     */
100
    private array $typeMap = [
0 ignored issues
show
introduced by
The private property $typeMap is not used, and could be removed.
Loading history...
101
        'bit' => self::TYPE_BIT,
102
        'bit varying' => self::TYPE_BIT,
103
        'varbit' => self::TYPE_BIT,
104
        'bool' => self::TYPE_BOOLEAN,
105
        'boolean' => self::TYPE_BOOLEAN,
106
        'box' => self::TYPE_STRING,
107
        'circle' => self::TYPE_STRING,
108
        'point' => self::TYPE_STRING,
109
        'line' => self::TYPE_STRING,
110
        'lseg' => self::TYPE_STRING,
111
        'polygon' => self::TYPE_STRING,
112
        'path' => self::TYPE_STRING,
113
        'character' => self::TYPE_CHAR,
114
        'char' => self::TYPE_CHAR,
115
        'bpchar' => self::TYPE_CHAR,
116
        'character varying' => self::TYPE_STRING,
117
        'varchar' => self::TYPE_STRING,
118
        'text' => self::TYPE_TEXT,
119
        'bytea' => self::TYPE_BINARY,
120
        'cidr' => self::TYPE_STRING,
121
        'inet' => self::TYPE_STRING,
122
        'macaddr' => self::TYPE_STRING,
123
        'real' => self::TYPE_FLOAT,
124
        'float4' => self::TYPE_FLOAT,
125
        'double precision' => self::TYPE_DOUBLE,
126
        'float8' => self::TYPE_DOUBLE,
127
        'decimal' => self::TYPE_DECIMAL,
128
        'numeric' => self::TYPE_DECIMAL,
129
        'money' => self::TYPE_MONEY,
130
        'smallint' => self::TYPE_SMALLINT,
131
        'int2' => self::TYPE_SMALLINT,
132
        'int4' => self::TYPE_INTEGER,
133
        'int' => self::TYPE_INTEGER,
134
        'integer' => self::TYPE_INTEGER,
135
        'bigint' => self::TYPE_BIGINT,
136
        'int8' => self::TYPE_BIGINT,
137
        'oid' => self::TYPE_BIGINT, // shouldn't be used. it's pg internal!
138
        'smallserial' => self::TYPE_SMALLINT,
139
        'serial2' => self::TYPE_SMALLINT,
140
        'serial4' => self::TYPE_INTEGER,
141
        'serial' => self::TYPE_INTEGER,
142
        'bigserial' => self::TYPE_BIGINT,
143
        'serial8' => self::TYPE_BIGINT,
144
        'pg_lsn' => self::TYPE_BIGINT,
145
        'date' => self::TYPE_DATE,
146
        'interval' => self::TYPE_STRING,
147
        'time without time zone' => self::TYPE_TIME,
148
        'time' => self::TYPE_TIME,
149
        'time with time zone' => self::TYPE_TIME,
150
        'timetz' => self::TYPE_TIME,
151
        'timestamp without time zone' => self::TYPE_TIMESTAMP,
152
        'timestamp' => self::TYPE_TIMESTAMP,
153
        'timestamp with time zone' => self::TYPE_TIMESTAMP,
154
        'timestamptz' => self::TYPE_TIMESTAMP,
155
        'abstime' => self::TYPE_TIMESTAMP,
156
        'tsquery' => self::TYPE_STRING,
157
        'tsvector' => self::TYPE_STRING,
158
        'txid_snapshot' => self::TYPE_STRING,
159
        'unknown' => self::TYPE_STRING,
160
        'uuid' => self::TYPE_STRING,
161
        'json' => self::TYPE_JSON,
162
        'jsonb' => self::TYPE_JSON,
163
        'xml' => self::TYPE_STRING,
164
    ];
165
166
    /**
167
     * @var string|null The default schema used for the current session.
168
     */
169
    protected string|null $defaultSchema = 'public';
170
171
    /**
172
     * @var string|string[] Character used to quote schema, table, etc. names.
173
     *
174
     * An array of 2 characters can be used in case starting and ending characters are different.
175
     */
176
    protected string|array $tableQuoteCharacter = '"';
177
178 14
    public function createColumn(string $type, array|int|string $length = null): ColumnInterface
179
    {
180 14
        return new Column($type, $length);
181
    }
182
183
    /**
184
     * Resolves the table name and schema name (if any).
185
     *
186
     * @param string $name The table name.
187
     *
188
     * @return TableSchemaInterface With resolved table, schema, etc. names.
189
     *
190
     * @see TableSchemaInterface
191
     */
192 234
    protected function resolveTableName(string $name): TableSchemaInterface
193
    {
194 234
        $resolvedName = new TableSchema();
195
196 234
        $parts = array_reverse($this->db->getQuoter()->getTableNameParts($name));
197 234
        $resolvedName->name($parts[0] ?? '');
198 234
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
199
200 234
        $resolvedName->fullName(
201 234
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
202 234
            implode('.', array_reverse($parts)) : $resolvedName->getName()
203 234
        );
204
205 234
        return $resolvedName;
206
    }
207
208
    /**
209
     * Returns all schema names in the database, including the default one but not system schemas.
210
     *
211
     * This method should be overridden by child classes to support this feature because the default implementation
212
     * simply throws an exception.
213
     *
214
     * @throws Exception
215
     * @throws InvalidConfigException
216
     * @throws Throwable
217
     *
218
     * @return array All schemas name in the database, except system schemas.
219
     */
220 1
    protected function findSchemaNames(): array
221
    {
222 1
        $sql = <<<SQL
223
        SELECT "ns"."nspname"
224
        FROM "pg_namespace" AS "ns"
225
        WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
226
        ORDER BY "ns"."nspname" ASC
227 1
        SQL;
228
229 1
        return $this->db->createCommand($sql)->queryColumn();
230
    }
231
232
    /**
233
     * @throws Exception
234
     * @throws InvalidConfigException
235
     * @throws Throwable
236
     */
237 182
    protected function findTableComment(TableSchemaInterface $tableSchema): void
238
    {
239 182
        $sql = <<<SQL
240
        SELECT obj_description(pc.oid, 'pg_class')
241
        FROM pg_catalog.pg_class pc
242
        INNER JOIN pg_namespace pn ON pc.relnamespace = pn.oid
243
        WHERE
244
        pc.relname=:tableName AND
245
        pn.nspname=:schemaName
246 182
        SQL;
247
248 182
        $comment = $this->db->createCommand($sql, [
249 182
            ':schemaName' => $tableSchema->getSchemaName(),
250 182
            ':tableName' => $tableSchema->getName(),
251 182
        ])->queryScalar();
252
253 182
        $tableSchema->comment(is_string($comment) ? $comment : null);
254
    }
255
256
    /**
257
     * Returns all table names in the database.
258
     *
259
     * This method should be overridden by child classes to support this feature because the default implementation
260
     * simply throws an exception.
261
     *
262
     * @param string $schema The schema of the tables.
263
     * Defaults to empty string, meaning the current or default schema.
264
     *
265
     * @throws Exception
266
     * @throws InvalidConfigException
267
     * @throws Throwable
268
     *
269
     * @return array All tables name in the database. The names have NO schema name prefix.
270
     */
271 12
    protected function findTableNames(string $schema = ''): array
272
    {
273 12
        if ($schema === '') {
274 11
            $schema = $this->defaultSchema;
275
        }
276
277 12
        $sql = <<<SQL
278
        SELECT c.relname AS table_name
279
        FROM pg_class c
280
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
281
        WHERE ns.nspname = :schemaName AND c.relkind IN ('r','v','m','f', 'p')
282
        ORDER BY c.relname
283 12
        SQL;
284
285 12
        return $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
286
    }
287
288
    /**
289
     * Loads the metadata for the specified table.
290
     *
291
     * @param string $name The table name.
292
     *
293
     * @throws Exception
294
     * @throws InvalidConfigException
295
     * @throws Throwable
296
     *
297
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table doesn't exist.
298
     */
299 182
    protected function loadTableSchema(string $name): TableSchemaInterface|null
300
    {
301 182
        $table = $this->resolveTableName($name);
302 182
        $this->findTableComment($table);
303
304 182
        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

304
        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...
305 160
            $this->findConstraints($table);
306 160
            return $table;
307
        }
308
309 43
        return null;
310
    }
311
312
    /**
313
     * Loads a primary key for the given table.
314
     *
315
     * @param string $tableName The table name.
316
     *
317
     * @throws Exception
318
     * @throws InvalidConfigException
319
     * @throws Throwable
320
     *
321
     * @return Constraint|null Primary key for the given table, `null` if the table has no primary key.
322
     */
323 40
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
324
    {
325 40
        $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

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

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