Passed
Push — fix-error-typo ( f268a9 )
by Wilmer
42:52 queued 29:25
created

Schema   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 1000
Duplicated Lines 0 %

Test Coverage

Coverage 99.26%

Importance

Changes 12
Bugs 2 Features 1
Metric Value
eloc 647
c 12
b 2
f 1
dl 0
loc 1000
ccs 268
cts 270
cp 0.9926
rs 9.6329
wmc 34

17 Methods

Rating   Name   Duplication   Size   Complexity  
A loadTableUniques() 0 5 2
A loadTableChecks() 0 5 2
A loadTableDefaultValues() 0 3 1
A getCacheTag() 0 3 1
A getCacheKey() 0 3 1
A createColumnSchema() 0 3 1
A createColumnSchemaBuilder() 0 5 1
A findTableComment() 0 17 2
A findViewNames() 0 15 2
C findConstraints() 0 552 8
A loadTableForeignKeys() 0 5 2
A findSchemaNames() 0 10 1
A resolveTableName() 0 14 2
A loadTableIndexes() 0 55 2
A loadTableSchema() 0 11 2
A loadTablePrimaryKey() 0 5 2
A findTableNames() 0 15 2
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\ColumnSchemaBuilderInterface;
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
    /**
170
     * Resolves the table name and schema name (if any).
171
     *
172
     * @param string $name The table name.
173
     *
174
     * @return TableSchemaInterface With resolved table, schema, etc. names.
175
     *
176
     * @see TableSchemaInterface
177
     */
178 216
    protected function resolveTableName(string $name): TableSchemaInterface
179
    {
180 216
        $resolvedName = new TableSchema();
181
182 216
        $parts = array_reverse($this->db->getQuoter()->getTableNameParts($name));
183 216
        $resolvedName->name($parts[0] ?? '');
184 216
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
185
186 216
        $resolvedName->fullName(
187 216
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
188 216
            implode('.', array_reverse($parts)) : $resolvedName->getName()
189 216
        );
190
191 216
        return $resolvedName;
192
    }
193
194
    /**
195
     * Returns all schema names in the database, including the default one but not system schemas.
196
     *
197
     * This method should be overridden by child classes to support this feature because the default implementation
198
     * simply throws an exception.
199
     *
200
     * @throws Exception
201
     * @throws InvalidConfigException
202
     * @throws Throwable
203
     *
204
     * @return array All schemas name in the database, except system schemas.
205
     */
206 1
    protected function findSchemaNames(): array
207
    {
208 1
        $sql = <<<SQL
209
        SELECT "ns"."nspname"
210
        FROM "pg_namespace" AS "ns"
211
        WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
212
        ORDER BY "ns"."nspname" ASC
213 1
        SQL;
214
215 1
        return $this->db->createCommand($sql)->queryColumn();
216
    }
217
218
    /**
219
     * @throws Exception
220
     * @throws InvalidConfigException
221
     * @throws Throwable
222
     */
223 164
    protected function findTableComment(TableSchemaInterface $tableSchema): void
224
    {
225 164
        $sql = <<<SQL
226
        SELECT obj_description(pc.oid, 'pg_class')
227
        FROM pg_catalog.pg_class pc
228
        INNER JOIN pg_namespace pn ON pc.relnamespace = pn.oid
229
        WHERE
230
        pc.relname=:tableName AND
231
        pn.nspname=:schemaName
232 164
        SQL;
233
234 164
        $comment = $this->db->createCommand($sql, [
235 164
            ':schemaName' => $tableSchema->getSchemaName(),
236 164
            ':tableName' => $tableSchema->getName(),
237 164
        ])->queryScalar();
238
239 164
        $tableSchema->comment(is_string($comment) ? $comment : null);
240
    }
241
242
    /**
243
     * Returns all table names in the database.
244
     *
245
     * This method should be overridden by child classes to support this feature because the default implementation
246
     * simply throws an exception.
247
     *
248
     * @param string $schema The schema of the tables.
249
     * Defaults to empty string, meaning the current or default schema.
250
     *
251
     * @throws Exception
252
     * @throws InvalidConfigException
253
     * @throws Throwable
254
     *
255
     * @return array All tables name in the database. The names have NO schema name prefix.
256
     */
257 12
    protected function findTableNames(string $schema = ''): array
258
    {
259 12
        if ($schema === '') {
260 11
            $schema = $this->defaultSchema;
261
        }
262
263 12
        $sql = <<<SQL
264
        SELECT c.relname AS table_name
265
        FROM pg_class c
266
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
267
        WHERE ns.nspname = :schemaName AND c.relkind IN ('r','v','m','f', 'p')
268
        ORDER BY c.relname
269 12
        SQL;
270
271 12
        return $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
272
    }
273
274
    /**
275
     * Loads the metadata for the specified table.
276
     *
277
     * @param string $name The table name.
278
     *
279
     * @throws Exception
280
     * @throws InvalidConfigException
281
     * @throws Throwable
282
     *
283
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table doesn't exist.
284
     */
285 164
    protected function loadTableSchema(string $name): TableSchemaInterface|null
286
    {
287 164
        $table = $this->resolveTableName($name);
288 164
        $this->findTableComment($table);
289
290 164
        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

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

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

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