Test Failed
Pull Request — master (#251)
by Def
11:02 queued 08:22
created

Schema   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 998
Duplicated Lines 0 %

Test Coverage

Coverage 99.26%

Importance

Changes 13
Bugs 2 Features 1
Metric Value
eloc 647
c 13
b 2
f 1
dl 0
loc 998
ccs 267
cts 269
cp 0.9926
rs 9.6329
wmc 34

17 Methods

Rating   Name   Duplication   Size   Complexity  
C findConstraints() 0 552 8
A getCacheTag() 0 3 1
A createColumn() 0 3 1
A loadTableForeignKeys() 0 5 2
A createColumnSchema() 0 3 1
A loadTableUniques() 0 5 2
A findSchemaNames() 0 10 1
A resolveTableName() 0 14 2
A loadTableIndexes() 0 55 2
A loadTableChecks() 0 5 2
A loadTableSchema() 0 11 2
A loadTablePrimaryKey() 0 5 2
A findTableComment() 0 17 2
A loadTableDefaultValues() 0 3 1
A findTableNames() 0 15 2
A findViewNames() 0 15 2
A getCacheKey() 0 3 1
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;
0 ignored issues
show
Bug introduced by
The type Yiisoft\Db\Schema\Builder\ColumnInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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
    public function createColumn(string $type, array|int|string $length = null): ColumnInterface
170
    {
171
        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 218
     *
179
     * @return TableSchemaInterface With resolved table, schema, etc. names.
180 218
     *
181
     * @see TableSchemaInterface
182 218
     */
183 218
    protected function resolveTableName(string $name): TableSchemaInterface
184 218
    {
185
        $resolvedName = new TableSchema();
186 218
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
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
193
            implode('.', array_reverse($parts)) : $resolvedName->getName()
194
        );
195
196
        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 1
     * @throws InvalidConfigException
207
     * @throws Throwable
208 1
     *
209
     * @return array All schemas name in the database, except system schemas.
210
     */
211
    protected function findSchemaNames(): array
212
    {
213 1
        $sql = <<<SQL
214
        SELECT "ns"."nspname"
215 1
        FROM "pg_namespace" AS "ns"
216
        WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
217
        ORDER BY "ns"."nspname" ASC
218
        SQL;
219
220
        return $this->db->createCommand($sql)->queryColumn();
221
    }
222
223 166
    /**
224
     * @throws Exception
225 166
     * @throws InvalidConfigException
226
     * @throws Throwable
227
     */
228
    protected function findTableComment(TableSchemaInterface $tableSchema): void
229
    {
230
        $sql = <<<SQL
231
        SELECT obj_description(pc.oid, 'pg_class')
232 166
        FROM pg_catalog.pg_class pc
233
        INNER JOIN pg_namespace pn ON pc.relnamespace = pn.oid
234 166
        WHERE
235 166
        pc.relname=:tableName AND
236 166
        pn.nspname=:schemaName
237 166
        SQL;
238
239 166
        $comment = $this->db->createCommand($sql, [
240
            ':schemaName' => $tableSchema->getSchemaName(),
241
            ':tableName' => $tableSchema->getName(),
242
        ])->queryScalar();
243
244
        $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 12
     * @throws InvalidConfigException
258
     * @throws Throwable
259 12
     *
260 11
     * @return array All tables name in the database. The names have NO schema name prefix.
261
     */
262
    protected function findTableNames(string $schema = ''): array
263 12
    {
264
        if ($schema === '') {
265
            $schema = $this->defaultSchema;
266
        }
267
268
        $sql = <<<SQL
269 12
        SELECT c.relname AS table_name
270
        FROM pg_class c
271 12
        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
        SQL;
275
276
        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 166
     * @throws InvalidConfigException
286
     * @throws Throwable
287 166
     *
288 166
     * @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 144
    {
292 144
        $table = $this->resolveTableName($name);
293
        $this->findTableComment($table);
294
295 40
        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
            $this->findConstraints($table);
297
            return $table;
298
        }
299
300
        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 40
     * @throws InvalidConfigException
310
     * @throws Throwable
311 40
     *
312
     * @return Constraint|null Primary key for the given table, `null` if the table has no primary key.
313 40
     */
314
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
315
    {
316
        $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
        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 8
     *
330
     * @return array Foreign keys for the given table.
331 8
     *
332
     * @psaml-return array|ForeignKeyConstraint[]
333 8
     */
334
    protected function loadTableForeignKeys(string $tableName): array
335
    {
336
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
337
338
        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 38
     * @throws InvalidConfigException
348
     * @throws Throwable
349 38
     *
350
     * @return IndexConstraint[] Indexes for the given table.
351
     */
352
    protected function loadTableIndexes(string $tableName): array
353
    {
354
        $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 38
            ON "ic"."oid" = "i"."indexrelid"
367
        INNER JOIN "pg_attribute" AS "ia"
368 38
            ON "ia"."attrelid" = "i"."indexrelid"
369 38
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
370 38
        ORDER BY "ia"."attnum" ASC
371 38
        SQL;
372 38
373
        $resolvedName = $this->resolveTableName($tableName);
374
        $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
        $indexes = $this->normalizeRowKeyCase($indexes, true);
381
        $indexes = ArrayHelper::index($indexes, null, ['name']);
382
        $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 38
         *     index_is_unique: bool,
392 35
         *     index_is_primary: bool
393 35
         *   }
394 35
         * > $index
395 35
         */
396 35
        foreach ($indexes as $name => $index) {
397
            $ic = (new IndexConstraint())
398 35
                ->name($name)
399
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
400
                ->primary($index[0]['index_is_primary'])
401 38
                ->unique($index[0]['index_is_unique']);
402
403
            $result[] = $ic;
404
        }
405
406
        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 17
     *
418
     * @return array Unique constraints for the given table.
419 17
     *
420
     * @psalm-return array|Constraint[]
421 17
     */
422
    protected function loadTableUniques(string $tableName): array
423
    {
424
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
425
426
        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 17
     *
438
     * @return array Check constraints for the given table.
439 17
     *
440
     * @psaml-return array|CheckConstraint[]
441 17
     */
442
    protected function loadTableChecks(string $tableName): array
443
    {
444
        $tableChecks = $this->loadTableConstraints($tableName, self::CHECKS);
445
446
        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 13
     *
454
     * @throws NotSupportedException
455 13
     *
456
     * @return DefaultValueConstraint[] Default value constraints for the given table.
457
     */
458
    protected function loadTableDefaultValues(string $tableName): array
459
    {
460
        throw new NotSupportedException(__METHOD__ . ' is not supported by PostgreSQL.');
461
    }
462
463 3
    /**
464
     * @throws Exception
465 3
     * @throws InvalidConfigException
466 1
     * @throws Throwable
467
     */
468
    protected function findViewNames(string $schema = ''): array
469 3
    {
470
        if ($schema === '') {
471
            $schema = $this->defaultSchema;
472
        }
473
474
        $sql = <<<SQL
475 3
        SELECT c.relname AS table_name
476
        FROM pg_class c
477 3
        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
        SQL;
481
482
        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 144
     *
490
     * @throws Exception
491
     * @throws InvalidConfigException
492
     * @throws Throwable
493
     */
494
    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 144
        /**
497
         * We need to extract the constraints de hard way since:
498
         * {@see https://www.postgresql.org/message-id/[email protected]}
499
         */
500
501
        $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 144
            ct.contype='f'
521
            and c.relname=:tableName
522
            and ns.nspname=:schemaName
523 144
        ORDER BY
524
            fns.nspname, fc.relname, a.attnum
525
        SQL;
526
527
        /** @psalm-var array{array{tableName: string, columns: array}} $constraints */
528
        $constraints = [];
529
530
        /**
531
         * @psalm-var array<
532
         *   array{
533
         *     constraint_name: string,
534
         *     column_name: string,
535
         *     foreign_table_name: string,
536 144
         *     foreign_table_schema: string,
537 144
         *     foreign_column_name: string,
538 144
         *   }
539 144
         * > $rows
540
         */
541 144
        $rows = $this->db->createCommand($sql, [
542
            ':schemaName' => $table->getSchemaName(),
543
            ':tableName' => $table->getName(),
544
        ])->queryAll();
545
546
        foreach ($rows as $constraint) {
547
            /** @psalm-var array{
548
             *     constraint_name: string,
549 16
             *     column_name: string,
550
             *     foreign_table_name: string,
551 16
             *     foreign_table_schema: string,
552 3
             *     foreign_column_name: string,
553
             *   } $constraint */
554 16
            $constraint = $this->normalizeRowKeyCase($constraint, false);
555
556
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
557 16
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
558
            } else {
559 16
                $foreignTable = $constraint['foreign_table_name'];
560 16
            }
561 16
562 16
            $name = $constraint['constraint_name'];
563 16
564
            if (!isset($constraints[$name])) {
565
                $constraints[$name] = [
566 16
                    'tableName' => $foreignTable,
567
                    'columns' => [],
568
                ];
569
            }
570
571
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
572
        }
573 144
574 16
        /**
575 16
         * @psalm-var int|string $foreingKeyName.
576 16
         * @psalm-var array{tableName: string, columns: array} $constraint
577 16
         */
578
        foreach ($constraints as $foreingKeyName => $constraint) {
579
            $table->foreignKey(
580
                (string) $foreingKeyName,
581
                array_merge([$constraint['tableName']], $constraint['columns'])
582
            );
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 1
     * @throws InvalidConfigException
593
     * @throws Throwable
594 1
     *
595
     * @return array With index and column names.
596
     */
597
    protected function getUniqueIndexInformation(TableSchemaInterface $table): array
598
    {
599
        $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 1
        INNER JOIN pg_class c ON c.oid = idx.indrelid
609
        INNER JOIN pg_namespace ns ON c.relnamespace = ns.oid
610 1
        WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
611 1
        AND c.relname = :tableName AND ns.nspname = :schemaName
612 1
        ORDER BY i.relname, k
613 1
        SQL;
614
615
        return $this->db->createCommand($sql, [
616
            ':schemaName' => $table->getSchemaName(),
617
            ':tableName' => $table->getName(),
618
        ])->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 1
     * @throws InvalidConfigException
637
     * @throws Throwable
638 1
     *
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 1
        /** @psalm-var array{indexname: string, columnname: string} $row */
646
        foreach ($this->getUniqueIndexInformation($table) as $row) {
647 1
            /** @psalm-var array{indexname: string, columnname: string} $row */
648
            $row = $this->normalizeRowKeyCase($row, false);
649
650
            $column = $row['columnname'];
651
652
            if (str_starts_with($column, '"') && str_ends_with($column, '"')) {
653 1
                /**
654
                 * postgres will quote names that aren't lowercase-only.
655
                 *
656 1
                 * {@see https://github.com/yiisoft/yii2/issues/10613}
657
                 */
658
                $column = substr($column, 1, -1);
659 1
            }
660
661
            $uniqueIndexes[$row['indexname']][] = $column;
662
        }
663
664
        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 166
     * @throws JsonException
675
     * @throws Throwable
676 166
     *
677
     * @return bool Whether the table exists in the database.
678 166
     */
679 159
    protected function findColumns(TableSchemaInterface $table): bool
680
    {
681
        $orIdentity = '';
682 166
683 166
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
684
            $orIdentity = 'OR a.attidentity != \'\'';
685
        }
686
687
        $sql = <<<SQL
688
        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 166
            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
            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 166
                a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
755
                AND c.relname = :tableName
756 166
                AND d.nspname = :schemaName
757 166
            ORDER BY
758 166
                a.attnum;
759 166
        SQL;
760
761 166
        $columns = $this->db->createCommand($sql, [
762 40
            ':schemaName' => $table->getSchemaName(),
763
            ':tableName' => $table->getName(),
764
        ])->queryAll();
765
766 144
        if (empty($columns)) {
767
            return false;
768 144
        }
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 90
780
            /** @psalm-var mixed $defaultValue */
781 90
            $defaultValue = $loadColumnSchema->getDefaultValue();
782 90
783
            if ($loadColumnSchema->isPrimaryKey()) {
784
                $table->primaryKey($loadColumnSchema->getName());
785 90
786 142
                if ($table->getSequenceName() === null) {
787
                    $table->sequenceName($loadColumnSchema->getSequenceName());
788 67
                }
789 67
790 67
                $loadColumnSchema->defaultValue(null);
791 67
            } elseif ($defaultValue) {
792 67
                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
                    in_array(
800 32
                        strtoupper($defaultValue),
801 67
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
802 62
                        true
803 38
                    )
804
                ) {
805 38
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
806 32
                } elseif ($loadColumnSchema->getType() === 'boolean') {
807 38
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
808 35
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
809
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
810 35
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
811 35
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
812
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
813 35
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
814 5
                } elseif (
815
                    is_string($defaultValue) &&
816 35
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
817
                ) {
818
                    if ($matches[2] === 'NULL') {
819
                        $loadColumnSchema->defaultValue(null);
820
                    } else {
821
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
822
                    }
823
                } else {
824 144
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
825
                }
826
            }
827
        }
828
829
        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 144
     *   dimension: int
855
     * } $info Column information.
856 144
     *
857 144
     * @return ColumnSchemaInterface The column schema object.
858 144
     */
859 144
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
860
    {
861 144
        $column = $this->createColumnSchema();
862
        $column->allowNull($info['is_nullable']);
863 1
        $column->autoIncrement($info['is_autoinc']);
864
        $column->comment($info['column_comment']);
865 144
866
        if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)
867
        ) {
868 144
            $column->dbType($info['type_scheme'] . '.' . $info['data_type']);
869 144
        } else {
870 144
            $column->dbType($info['data_type']);
871 144
        }
872 144
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
        $column->name($info['column_name']);
879
        $column->precision($info['numeric_precision']);
880
        $column->scale($info['numeric_scale']);
881
        $column->size($info['size'] === null ? null : (int) $info['size']);
882
        $column->dimension($info['dimension']);
883
884
        /**
885
         * pg_get_serial_sequence() doesn't track DEFAULT value change.
886 144
         *
887 144
         * GENERATED BY IDENTITY columns always have a null default value.
888
         *
889
         * @psalm-var mixed $defaultValue
890 144
         */
891 144
        $defaultValue = $column->getDefaultValue();
892 144
        $sequenceName = $info['sequence_name'] ?? null;
893
894 78
        if (
895 78
            isset($defaultValue) &&
896 78
            is_string($defaultValue) &&
897 78
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
898 78
        ) {
899 144
            $column->sequenceName(preg_replace(
900 5
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
901
                '',
902
                $defaultValue
903 144
            ));
904 144
        } elseif ($sequenceName !== null) {
905
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
906 5
        }
907
908
        if (isset($this->typeMap[$column->getDbType()])) {
909 144
            $column->type($this->typeMap[$column->getDbType()]);
910
        } else {
911 144
            $column->type(self::TYPE_STRING);
912
        }
913
914
        $column->phpType($this->getColumnPhpType($column));
915
916
        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 82
     *
933
     * @return array|Constraint|null Constraints.
934 82
     *
935
     * @psalm-return CheckConstraint[]|Constraint[]|ForeignKeyConstraint[]|Constraint|null
936
     */
937
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
938
    {
939
        $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 82
            ON "ftcns"."oid" = "ftc"."relnamespace"
961
        LEFT JOIN "pg_attribute" "fa"
962
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
963 82
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
964 82
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
965 82
        SQL;
966 82
967 82
        /** @psalm-var string[] $actionTypes */
968 82
        $actionTypes = [
969 82
            'a' => 'NO ACTION',
970
            'r' => 'RESTRICT',
971 82
            'c' => 'CASCADE',
972 82
            'n' => 'SET NULL',
973 82
            'd' => 'SET DEFAULT',
974 82
        ];
975 82
976
        $resolvedName = $this->resolveTableName($tableName);
977
        $constraints = $this->db->createCommand($sql, [
978 82
            ':schemaName' => $resolvedName->getSchemaName(),
979 82
            ':tableName' => $resolvedName->getName(),
980
        ])->queryAll();
981 82
982 82
        /** @psalm-var array[][] $constraints */
983 82
        $constraints = $this->normalizeRowKeyCase($constraints, true);
984 82
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
985 82
986 82
        $result = [
987
            self::PRIMARY_KEY => null,
988
            self::FOREIGN_KEYS => [],
989
            self::UNIQUES => [],
990
            self::CHECKS => [],
991
        ];
992 82
993
        /**
994
         * @psalm-var string $type
995
         * @psalm-var array $names
996
         */
997 82
        foreach ($constraints as $type => $names) {
998
            /**
999 82
             * @psalm-var object|string|null $name
1000 57
             * @psalm-var ConstraintArray $constraint
1001 57
             */
1002 57
            foreach ($names as $name => $constraint) {
1003 57
                switch ($type) {
1004 74
                    case 'p':
1005 19
                        $result[self::PRIMARY_KEY] = (new Constraint())
1006 19
                            ->name($name)
1007
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1008 19
                        break;
1009 19
                    case 'f':
1010 19
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
1011 19
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
1012 19
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 61
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
1022 58
                            ))
1023 58
                            ->onDelete($onDelete)
1024 58
                            ->onUpdate($onUpdate);
1025 58
                        break;
1026 15
                    case 'u':
1027 15
                        $result[self::UNIQUES][] = (new Constraint())
1028 15
                            ->name($name)
1029 15
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1030 15
                        break;
1031 15
                    case 'c':
1032
                        $result[self::CHECKS][] = (new CheckConstraint())
1033
                            ->name($name)
1034
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
1035
                            ->expression($constraint[0]['check_expr']);
1036 82
                        break;
1037 82
                }
1038
            }
1039
        }
1040 82
1041
        foreach ($result as $type => $data) {
1042
            $this->setTableMetadata($tableName, $type, $data);
1043
        }
1044
1045
        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 144
    /**
1049
     * Creates a column schema for the database.
1050 144
     *
1051
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1052
     */
1053 6
    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
        return new ColumnSchema();
1056
    }
1057 6
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
    protected function getCacheKey(string $name): array
1066
    {
1067 256
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
1068
    }
1069 256
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
    protected function getCacheTag(): string
1078
    {
1079 257
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
1080
    }
1081
}
1082