Passed
Push — master ( 54ca29...399a47 )
by Def
16:05 queued 12:35
created

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

295
        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...
296 144
            $this->findConstraints($table);
297 144
            return $table;
298
        }
299
300 40
        return null;
301
    }
302
303
    /**
304
     * Loads a primary key for the given table.
305
     *
306
     * @param string $tableName The table name.
307
     *
308
     * @throws Exception
309
     * @throws InvalidConfigException
310
     * @throws Throwable
311
     *
312
     * @return Constraint|null Primary key for the given table, `null` if the table has no primary key.
313
     */
314 40
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
315
    {
316 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

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

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