Passed
Branch master (ed5873)
by Wilmer
02:52
created

Schema   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 1017
Duplicated Lines 0 %

Test Coverage

Coverage 97.41%

Importance

Changes 11
Bugs 2 Features 1
Metric Value
eloc 653
c 11
b 2
f 1
dl 0
loc 1017
ccs 226
cts 232
cp 0.9741
rs 9.467
wmc 36

18 Methods

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

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

310
        /** @scrutinizer ignore-call */ 
311
        $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...
311
312 34
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
313
    }
314
315
    /**
316
     * Loads all foreign keys for the given table.
317
     *
318
     * @param string $tableName table name.
319
     *
320
     * @throws Exception
321
     * @throws InvalidConfigException
322
     * @throws Throwable
323
     *
324
     * @return array foreign keys for the given table.
325
     *
326
     * @psaml-return array|ForeignKeyConstraint[]
327
     */
328 9
    protected function loadTableForeignKeys(string $tableName): array
329
    {
330 9
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
331
332 9
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
333
    }
334
335
    /**
336
     * Loads all indexes for the given table.
337
     *
338
     * @param string $tableName table name.
339
     *
340
     * @throws Exception
341
     * @throws InvalidConfigException
342
     * @throws Throwable
343
     *
344
     * @return IndexConstraint[] indexes for the given table.
345
     */
346 32
    protected function loadTableIndexes(string $tableName): array
347
    {
348 32
        $sql = <<<SQL
349
        SELECT
350
            "ic"."relname" AS "name",
351
            "ia"."attname" AS "column_name",
352
            "i"."indisunique" AS "index_is_unique",
353
            "i"."indisprimary" AS "index_is_primary"
354
        FROM "pg_class" AS "tc"
355
        INNER JOIN "pg_namespace" AS "tcns"
356
            ON "tcns"."oid" = "tc"."relnamespace"
357
        INNER JOIN "pg_index" AS "i"
358
            ON "i"."indrelid" = "tc"."oid"
359
        INNER JOIN "pg_class" AS "ic"
360
            ON "ic"."oid" = "i"."indexrelid"
361
        INNER JOIN "pg_attribute" AS "ia"
362
            ON "ia"."attrelid" = "i"."indexrelid"
363
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
364
        ORDER BY "ia"."attnum" ASC
365
        SQL;
366
367 32
        $resolvedName = $this->resolveTableName($tableName);
368
369 32
        $indexes = $this->db->createCommand($sql, [
370 32
            ':schemaName' => $resolvedName->getSchemaName(),
371 32
            ':tableName' => $resolvedName->getName(),
372 32
        ])->queryAll();
373
374
        /** @var array[] $indexes */
375 32
        $indexes = $this->normalizeRowKeyCase($indexes, true);
376 32
        $indexes = ArrayHelper::index($indexes, null, 'name');
377 32
        $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 32
        foreach ($indexes as $name => $index) {
392 29
            $ic = (new IndexConstraint())
393 29
                ->name($name)
394 29
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
395 29
                ->primary($index[0]['index_is_primary'])
396 29
                ->unique($index[0]['index_is_unique']);
397
398 29
            $result[] = $ic;
399
        }
400
401 32
        return $result;
402
    }
403
404
    /**
405
     * Loads all unique constraints for the given table.
406
     *
407
     * @param string $tableName 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 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 15
    protected function loadTableChecks(string $tableName): array
438
    {
439 15
        $tableChecks = $this->loadTableConstraints($tableName, self::CHECKS);
440
441 15
        return is_array($tableChecks) ? $tableChecks : [];
442
    }
443
444
    /**
445
     * Loads all default value constraints for the given table.
446
     *
447
     * @param string $tableName table name.
448
     *
449
     * @throws NotSupportedException
450
     *
451
     * @return DefaultValueConstraint[] default value constraints for the given table.
452
     */
453 12
    protected function loadTableDefaultValues(string $tableName): array
454
    {
455 12
        throw new NotSupportedException(__METHOD__ . ' is not supported by PostgreSQL.');
456
    }
457
458
    /**
459
     * @throws Exception
460
     * @throws InvalidConfigException
461
     * @throws Throwable
462
     */
463 2
    protected function findViewNames(string $schema = ''): array
464
    {
465 2
        if ($schema === '') {
466
            $schema = $this->defaultSchema;
467
        }
468
469 2
        $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
        SQL;
476
477 2
        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 123
    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 http://www.postgresql.org/message-id/[email protected]}
494
         */
495
496 123
        $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
        SQL;
521
522
        /** @var array{array{tableName: string, columns: array}} $constraints */
523 123
        $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 123
        $rows = $this->db->createCommand($sql, [
537 123
            ':schemaName' => $table->getSchemaName(),
538 123
            ':tableName' => $table->getName(),
539 123
        ])->queryAll();
540
541 123
        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 12
            $constraint = $this->normalizeRowKeyCase($constraint, false);
550
551 12
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
552
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
553
            } else {
554 12
                $foreignTable = $constraint['foreign_table_name'];
555
            }
556
557 12
            $name = $constraint['constraint_name'];
558
559 12
            if (!isset($constraints[$name])) {
560 12
                $constraints[$name] = [
561
                    'tableName' => $foreignTable,
562
                    'columns' => [],
563
                ];
564
            }
565
566 12
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
567
        }
568
569
        /**
570
         * @var int|string $foreingKeyName.
571
         * @var array{tableName: string, columns: array} $constraint
572
         */
573 123
        foreach ($constraints as $foreingKeyName => $constraint) {
574 12
            $table->foreignKey(
575
                (string) $foreingKeyName,
576 12
                array_merge([$constraint['tableName']], $constraint['columns'])
577
            );
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
        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
        /** @var array{indexname: string, columnname: string} $row */
641 1
        foreach ($this->getUniqueIndexInformation($table) as $row) {
642
            /** @var array{indexname: string, columnname: string} $row */
643 1
            $row = $this->normalizeRowKeyCase($row, false);
644
645 1
            $column = $row['columnname'];
646
647 1
            if (!empty($column) && $column[0] === '"') {
648
                /**
649
                 * postgres will quote names that are not 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 143
    protected function findColumns(TableSchemaInterface $table): bool
675
    {
676 143
        $orIdentity = '';
677
678 143
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
679 136
            $orIdentity = 'OR a.attidentity != \'\'';
680
        }
681
682 143
        $sql = <<<SQL
683
        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
            a.attlen AS character_maximum_length,
690
            pg_catalog.col_description(c.oid, a.attnum) AS column_comment,
691
            a.atttypmod AS modifier,
692
            a.attnotnull = false AS is_nullable,
693
            CAST(pg_get_expr(ad.adbin, ad.adrelid) AS varchar) AS column_default,
694
            coalesce(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval',false) $orIdentity AS is_autoinc,
695
            pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname)
696
            AS sequence_name,
697
            CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
698
                THEN array_to_string(
699
                    (
700
                        SELECT array_agg(enumlabel)
701
                        FROM pg_enum
702
                        WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid)
703
                    )::varchar[],
704
                ',')
705
                ELSE NULL
706
            END AS enum_values,
707
            CASE atttypid
708
                WHEN 21 /*int2*/ THEN 16
709
                WHEN 23 /*int4*/ THEN 32
710
                WHEN 20 /*int8*/ THEN 64
711
                WHEN 1700 /*numeric*/ THEN
712
                    CASE WHEN atttypmod = -1
713
                        THEN null
714
                        ELSE ((atttypmod - 4) >> 16) & 65535
715
                        END
716
                WHEN 700 /*float4*/ THEN 24 /*FLT_MANT_DIG*/
717
                WHEN 701 /*float8*/ THEN 53 /*DBL_MANT_DIG*/
718
                    ELSE null
719
                    END   AS numeric_precision,
720
            CASE
721
                WHEN atttypid IN (21, 23, 20) THEN 0
722
                WHEN atttypid IN (1700) THEN
723
            CASE
724
                WHEN atttypmod = -1 THEN null
725
                    ELSE (atttypmod - 4) & 65535
726
                    END
727
                    ELSE null
728
                    END AS numeric_scale,
729
                    CAST(
730
                        information_schema._pg_char_max_length(
731
                        information_schema._pg_truetypid(a, t),
732
                        information_schema._pg_truetypmod(a, t)
733
                        ) AS numeric
734
                    ) AS size,
735
                    a.attnum = any (ct.conkey) as is_pkey,
736
                    COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
737
            FROM
738
                pg_class c
739
                LEFT JOIN pg_attribute a ON a.attrelid = c.oid
740
                LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
741
                LEFT JOIN pg_type t ON a.atttypid = t.oid
742
                LEFT JOIN pg_type tb ON (a.attndims > 0 OR t.typcategory='A') AND t.typelem > 0 AND t.typelem = tb.oid
743
                                            OR t.typbasetype > 0 AND t.typbasetype = tb.oid
744
                LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
745
                LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
746
                LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p'
747
            WHERE
748
                a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
749
                AND c.relname = :tableName
750
                AND d.nspname = :schemaName
751
            ORDER BY
752
                a.attnum;
753
        SQL;
754
755 143
        $columns = $this->db->createCommand($sql, [
756 143
            ':schemaName' => $table->getSchemaName(),
757 143
            ':tableName' => $table->getName(),
758 143
        ])->queryAll();
759
760 143
        if (empty($columns)) {
761 32
            return false;
762
        }
763
764
        /** @var array $column */
765 123
        foreach ($columns as $column) {
766
            /** @psalm-var ColumnArray $column */
767 123
            $column = $this->normalizeRowKeyCase($column, false);
768
769
            /** @psalm-var ColumnSchema $loadColumnSchema */
770 123
            $loadColumnSchema = $this->loadColumnSchema($column);
771
772 123
            $table->columns($loadColumnSchema->getName(), $loadColumnSchema);
773
774
            /** @psalm-var mixed $defaultValue */
775 123
            $defaultValue = $loadColumnSchema->getDefaultValue();
776
777 123
            if ($loadColumnSchema->isPrimaryKey()) {
778 78
                $table->primaryKey($loadColumnSchema->getName());
779
780 78
                if ($table->getSequenceName() === null) {
781 78
                    $table->sequenceName($loadColumnSchema->getSequenceName());
782
                }
783
784 78
                $loadColumnSchema->defaultValue(null);
785 120
            } elseif ($defaultValue) {
786
                if (
787 64
                    is_string($defaultValue) &&
788 64
                    in_array(
789 64
                        $loadColumnSchema->getType(),
790
                        [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME],
791
                        true
792
                    ) &&
793 30
                    in_array(
794 30
                        strtoupper($defaultValue),
795
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
796
                        true
797
                    )
798
                ) {
799 28
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
800 64
                } elseif ($loadColumnSchema->getType() === 'boolean') {
801 60
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
802 33
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
803
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
804 33
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
805 28
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
806 33
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
807 30
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
808
                } elseif (
809 31
                    is_string($defaultValue) &&
810 31
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
811
                ) {
812 31
                    if ($matches[2] === 'NULL') {
813 5
                        $loadColumnSchema->defaultValue(null);
814
                    } else {
815 31
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
816
                    }
817
                } else {
818
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
819
                }
820
            }
821
        }
822
823 123
        return true;
824
    }
825
826
    /**
827
     * Loads the column information into a {@see ColumnSchemaInterface} object.
828
     *
829
     * @psalm-param array{
830
     *   table_schema: string,
831
     *   table_name: string,
832
     *   column_name: string,
833
     *   data_type: string,
834
     *   type_type: string|null,
835
     *   character_maximum_length: int,
836
     *   column_comment: string|null,
837
     *   modifier: int,
838
     *   is_nullable: bool,
839
     *   column_default: mixed,
840
     *   is_autoinc: bool,
841
     *   sequence_name: string|null,
842
     *   enum_values: array<array-key, float|int|string>|string|null,
843
     *   numeric_precision: int|null,
844
     *   numeric_scale: int|null,
845
     *   size: string|null,
846
     *   is_pkey: bool|null,
847
     *   dimension: int
848
     * } $info column information.
849
     *
850
     * @return ColumnSchemaInterface the column schema object.
851
     */
852 123
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
853
    {
854 123
        $column = $this->createColumnSchema();
855 123
        $column->allowNull($info['is_nullable']);
856 123
        $column->autoIncrement($info['is_autoinc']);
857 123
        $column->comment($info['column_comment']);
858 123
        $column->dbType($info['data_type']);
859 123
        $column->defaultValue($info['column_default']);
860 123
        $column->enumValues(($info['enum_values'] !== null)
861 123
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null);
862 123
        $column->unsigned(false); // has no meaning in PG
863 123
        $column->primaryKey((bool) $info['is_pkey']);
864 123
        $column->name($info['column_name']);
865 123
        $column->precision($info['numeric_precision']);
866 123
        $column->scale($info['numeric_scale']);
867 123
        $column->size($info['size'] === null ? null : (int) $info['size']);
868 123
        $column->dimension($info['dimension']);
869
870
        /**
871
         * pg_get_serial_sequence() doesn't track DEFAULT value change. GENERATED BY IDENTITY columns always have null
872
         * default value.
873
         *
874
         * @psalm-var mixed $defaultValue
875
         */
876 123
        $defaultValue = $column->getDefaultValue();
877 123
        $sequenceName = $info['sequence_name'] ?? null;
878
879
        if (
880
            isset($defaultValue) &&
881 123
            is_string($defaultValue) &&
882 123
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
883
        ) {
884 72
            $column->sequenceName(preg_replace(
885
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
886
                '',
887
                $defaultValue
888
            ));
889 121
        } elseif ($sequenceName !== null) {
890
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
891
        }
892
893 123
        if (isset($this->typeMap[$column->getDbType()])) {
894 123
            $column->type($this->typeMap[$column->getDbType()]);
895
        } else {
896
            $column->type(self::TYPE_STRING);
897
        }
898
899 123
        $column->phpType($this->getColumnPhpType($column));
900
901 123
        return $column;
902
    }
903
904
    /**
905
     * Loads multiple types of constraints and returns the specified ones.
906
     *
907
     * @param string $tableName table name.
908
     * @param string $returnType return type:
909
     * - primaryKey
910
     * - foreignKeys
911
     * - uniques
912
     * - checks
913
     *
914
     * @throws Exception
915
     * @throws InvalidConfigException
916
     * @throws Throwable
917
     *
918
     * @return array|Constraint|null (CheckConstraint|Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
919
     */
920 75
    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...
921
    {
922 75
        $sql = <<<SQL
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $sql seems to be never defined.
Loading history...
923
        SELECT
924
            "c"."conname" AS "name",
925
            "a"."attname" AS "column_name",
926
            "c"."contype" AS "type",
927
            "ftcns"."nspname" AS "foreign_table_schema",
928
            "ftc"."relname" AS "foreign_table_name",
929
            "fa"."attname" AS "foreign_column_name",
930
            "c"."confupdtype" AS "on_update",
931
            "c"."confdeltype" AS "on_delete",
932
            pg_get_constraintdef("c"."oid") AS "check_expr"
933
        FROM "pg_class" AS "tc"
934
        INNER JOIN "pg_namespace" AS "tcns"
935
            ON "tcns"."oid" = "tc"."relnamespace"
936
        INNER JOIN "pg_constraint" AS "c"
937
            ON "c"."conrelid" = "tc"."oid"
938
        INNER JOIN "pg_attribute" AS "a"
939
            ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
940
        LEFT JOIN "pg_class" AS "ftc"
941
            ON "ftc"."oid" = "c"."confrelid"
942
        LEFT JOIN "pg_namespace" AS "ftcns"
943
            ON "ftcns"."oid" = "ftc"."relnamespace"
944
        LEFT JOIN "pg_attribute" "fa"
945
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
946
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
947
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
948
        SQL;
949
950
        /** @psalm-var array<array-key, string> $actionTypes */
951 75
        $actionTypes = [
952
            'a' => 'NO ACTION',
953
            'r' => 'RESTRICT',
954
            'c' => 'CASCADE',
955
            'n' => 'SET NULL',
956
            'd' => 'SET DEFAULT',
957
        ];
958
959 75
        $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...
960
961 75
        $constraints = $this->db->createCommand($sql, [
962 75
            ':schemaName' => $resolvedName->getSchemaName(),
963 75
            ':tableName' => $resolvedName->getName(),
964 75
        ])->queryAll();
965
966
        /** @var array<array-key, array> $constraints */
967 75
        $constraints = $this->normalizeRowKeyCase($constraints, true);
968 75
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
969
970 75
        $result = [
971
            self::PRIMARY_KEY => null,
972
            self::FOREIGN_KEYS => [],
973
            self::UNIQUES => [],
974
            self::CHECKS => [],
975
        ];
976
977
        /**
978
         * @var string $type
979
         * @var array $names
980
         */
981 75
        foreach ($constraints as $type => $names) {
982
            /**
983
             * @psalm-var object|string|null $name
984
             * @psalm-var ConstraintArray $constraint
985
             */
986 75
            foreach ($names as $name => $constraint) {
987
                switch ($type) {
988 75
                    case 'p':
989 53
                        $result[self::PRIMARY_KEY] = (new Constraint())
990 53
                            ->name($name)
991 53
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
992 53
                        break;
993 71
                    case 'f':
994 21
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
995 21
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
996
997 21
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
998 21
                            ->name($name)
999 21
                            ->columnNames(array_values(
1000 21
                                array_unique(ArrayHelper::getColumn($constraint, 'column_name'))
1001
                            ))
1002 21
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
1003 21
                            ->foreignTableName($constraint[0]['foreign_table_name'])
1004 21
                            ->foreignColumnNames(array_values(
1005 21
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
1006
                            ))
1007 21
                            ->onDelete($onDelete)
1008 21
                            ->onUpdate($onUpdate);
1009 21
                        break;
1010 58
                    case 'u':
1011 56
                        $result[self::UNIQUES][] = (new Constraint())
1012 56
                            ->name($name)
1013 56
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1014 56
                        break;
1015 15
                    case 'c':
1016 15
                        $result[self::CHECKS][] = (new CheckConstraint())
1017 15
                            ->name($name)
1018 15
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
1019 15
                            ->expression($constraint[0]['check_expr']);
1020 15
                        break;
1021
                }
1022
            }
1023
        }
1024
1025 75
        foreach ($result as $type => $data) {
1026 75
            $this->setTableMetadata($tableName, $type, $data);
1027
        }
1028
1029 75
        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...
1030
    }
1031
1032
    /**
1033
     * Creates a column schema for the database.
1034
     *
1035
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1036
     *
1037
     * @return ColumnSchema column schema instance.
1038
     */
1039 123
    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...
1040
    {
1041 123
        return new ColumnSchema();
1042
    }
1043
1044
    /**
1045
     * Create a column schema builder instance giving the type and value precision.
1046
     *
1047
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
1048
     *
1049
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
1050
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
1051
     *
1052
     * @return ColumnSchemaBuilder column schema builder instance
1053
     *
1054
     * @psalm-param int|string|string[]|null $length
1055
     */
1056 4
    public function createColumnSchemaBuilder(string $type, int|string|array|null $length = null): ColumnSchemaBuilder
1057
    {
1058 4
        return new ColumnSchemaBuilder($type, $length);
1059
    }
1060
1061
    /**
1062
     * Returns the cache key for the specified table name.
1063
     *
1064
     * @param string $name the table name.
1065
     *
1066
     * @return array the cache key.
1067
     */
1068 208
    protected function getCacheKey(string $name): array
1069
    {
1070 208
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
1071
    }
1072
1073
    /**
1074
     * Returns the cache tag name.
1075
     *
1076
     * This allows {@see refresh()} to invalidate all cached table schemas.
1077
     *
1078
     * @return string the cache tag name.
1079
     */
1080 208
    protected function getCacheTag(): string
1081
    {
1082 208
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
1083
    }
1084
1085
    /**
1086
     * Changes row's array key case to lower.
1087
     *
1088
     * @param array $row row's array or an array of row's arrays.
1089
     * @param bool $multiple whether multiple rows or a single row passed.
1090
     *
1091
     * @return array normalized row or rows.
1092
     */
1093 182
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
1094
    {
1095 182
        if ($multiple) {
1096 89
            return array_map(static fn (array $row) => array_change_key_case($row, CASE_LOWER), $row);
1097
        }
1098
1099 123
        return array_change_key_case($row, CASE_LOWER);
1100
    }
1101
}
1102