Passed
Push — better-naming-schema ( 902573 )
by Wilmer
04:19
created

Schema   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 1000
Duplicated Lines 0 %

Test Coverage

Coverage 99.26%

Importance

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

17 Methods

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

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

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

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