Passed
Push — master ( 62f807...f684cb )
by Def
03:25
created

Schema::loadTablePrimaryKey()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 2
c 2
b 0
f 0
nc 2
nop 1
dl 0
loc 5
ccs 3
cts 3
cp 1
crap 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Pgsql;
6
7
use JsonException;
8
use Throwable;
9
use Yiisoft\Db\Constraint\CheckConstraint;
10
use Yiisoft\Db\Constraint\Constraint;
11
use Yiisoft\Db\Constraint\DefaultValueConstraint;
12
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
13
use Yiisoft\Db\Constraint\IndexConstraint;
14
use Yiisoft\Db\Exception\Exception;
15
use Yiisoft\Db\Exception\InvalidConfigException;
16
use Yiisoft\Db\Exception\NotSupportedException;
17
use Yiisoft\Db\Expression\Expression;
18
use Yiisoft\Db\Helper\ArrayHelper;
19
use Yiisoft\Db\Schema\AbstractSchema;
20
use Yiisoft\Db\Schema\ColumnSchemaBuilderInterface;
21
use Yiisoft\Db\Schema\ColumnSchemaInterface;
22
use Yiisoft\Db\Schema\TableSchemaInterface;
23
24
use function array_merge;
25
use function array_unique;
26
use function array_values;
27
use function bindec;
28
use function explode;
29
use function preg_match;
30
use function preg_replace;
31
use function str_replace;
32
use function substr;
33
34
/**
35
 * The class Schema is the class for retrieving metadata from a PostgreSQL database
36
 * (version 9.6 and above).
37
 *
38
 * @psalm-type ColumnArray = array{
39
 *   table_schema: string,
40
 *   table_name: string,
41
 *   column_name: string,
42
 *   data_type: string,
43
 *   type_type: string|null,
44
 *   type_scheme: 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 213
    protected function resolveTableName(string $name): TableSchemaInterface
181
    {
182 213
        $resolvedName = new TableSchema();
183
184 213
        $parts = array_reverse(
185 213
            $this->db->getQuoter()->getTableNameParts($name)
186 213
        );
187
188 213
        $resolvedName->name($parts[0] ?? '');
189 213
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
190
191 213
        $resolvedName->fullName(
192 213
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
193 213
            implode('.', array_reverse($parts)) : $resolvedName->getName()
194 213
        );
195
196 213
        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 1
    protected function findSchemaNames(): array
212
    {
213 1
        $sql = <<<SQL
214
        SELECT "ns"."nspname"
215
        FROM "pg_namespace" AS "ns"
216
        WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
217
        ORDER BY "ns"."nspname" ASC
218 1
        SQL;
219
220 1
        return $this->db->createCommand($sql)->queryColumn();
221
    }
222
223 161
    protected function findTableComment(TableSchemaInterface $tableSchema): void
224
    {
225 161
        $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 161
        SQL;
233
234 161
        $comment = $this->db->createCommand($sql, [
235 161
            ':schemaName' => $tableSchema->getSchemaName(),
236 161
            ':tableName' => $tableSchema->getName(),
237 161
        ])->queryScalar();
238
239 161
        $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 12
    protected function findTableNames(string $schema = ''): array
257
    {
258 12
        if ($schema === '') {
259 11
            $schema = $this->defaultSchema;
260
        }
261
262 12
        $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 12
        SQL;
269
270 12
        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 161
    protected function loadTableSchema(string $name): TableSchemaInterface|null
285
    {
286 161
        $table = $this->resolveTableName($name);
287 161
        $this->findTableComment($table);
288
289 161
        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 139
            $this->findConstraints($table);
291 139
            return $table;
292
        }
293
294 39
        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 40
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
309
    {
310 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

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 40
        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 8
    protected function loadTableForeignKeys(string $tableName): array
329
    {
330 8
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
331
332 8
        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 38
    protected function loadTableIndexes(string $tableName): array
347
    {
348 38
        $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 38
        SQL;
366
367 38
        $resolvedName = $this->resolveTableName($tableName);
368
369 38
        $indexes = $this->db->createCommand($sql, [
370 38
            ':schemaName' => $resolvedName->getSchemaName(),
371 38
            ':tableName' => $resolvedName->getName(),
372 38
        ])->queryAll();
373
374
        /** @var array[] $indexes */
375 38
        $indexes = $this->normalizeRowKeyCase($indexes, true);
376 38
        $indexes = ArrayHelper::index($indexes, null, ['name']);
377 38
        $result = [];
378
379
        /**
380
         * @psalm-var object|string|null $name
381
         * @psalm-var array<
382
         *   array-key,
383
         *   array{
384
         *     name: string,
385
         *     column_name: string,
386
         *     index_is_unique: bool,
387
         *     index_is_primary: bool
388
         *   }
389
         * > $index
390
         */
391 38
        foreach ($indexes as $name => $index) {
392 35
            $ic = (new IndexConstraint())
393 35
                ->name($name)
394 35
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
395 35
                ->primary($index[0]['index_is_primary'])
396 35
                ->unique($index[0]['index_is_unique']);
397
398 35
            $result[] = $ic;
399
        }
400
401 38
        return $result;
402
    }
403
404
    /**
405
     * Loads all unique constraints for the given table.
406
     *
407
     * @param string $tableName 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 17
    protected function loadTableChecks(string $tableName): array
438
    {
439 17
        $tableChecks = $this->loadTableConstraints($tableName, self::CHECKS);
440
441 17
        return is_array($tableChecks) ? $tableChecks : [];
442
    }
443
444
    /**
445
     * Loads all default value constraints for the given table.
446
     *
447
     * @param string $tableName table name.
448
     *
449
     * @throws NotSupportedException
450
     *
451
     * @return DefaultValueConstraint[] default value constraints for the given table.
452
     */
453 13
    protected function loadTableDefaultValues(string $tableName): array
454
    {
455 13
        throw new NotSupportedException(__METHOD__ . ' is not supported by PostgreSQL.');
456
    }
457
458
    /**
459
     * @throws Exception
460
     * @throws InvalidConfigException
461
     * @throws Throwable
462
     */
463 3
    protected function findViewNames(string $schema = ''): array
464
    {
465 3
        if ($schema === '') {
466 1
            $schema = $this->defaultSchema;
467
        }
468
469 3
        $sql = <<<SQL
470
        SELECT c.relname AS table_name
471
        FROM pg_class c
472
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
473
        WHERE ns.nspname = :schemaName AND (c.relkind = 'v' OR c.relkind = 'm')
474
        ORDER BY c.relname
475 3
        SQL;
476
477 3
        return $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
478
    }
479
480
    /**
481
     * Collects the foreign key column details for the given table.
482
     *
483
     * @param TableSchemaInterface $table the table metadata
484
     *
485
     * @throws Exception
486
     * @throws InvalidConfigException
487
     * @throws Throwable
488
     */
489 139
    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 139
        $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 139
        SQL;
521
522
        /** @var array{array{tableName: string, columns: array}} $constraints */
523 139
        $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 139
        $rows = $this->db->createCommand($sql, [
537 139
            ':schemaName' => $table->getSchemaName(),
538 139
            ':tableName' => $table->getName(),
539 139
        ])->queryAll();
540
541 139
        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 15
            $constraint = $this->normalizeRowKeyCase($constraint, false);
550
551 15
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
552 3
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
553
            } else {
554 15
                $foreignTable = $constraint['foreign_table_name'];
555
            }
556
557 15
            $name = $constraint['constraint_name'];
558
559 15
            if (!isset($constraints[$name])) {
560 15
                $constraints[$name] = [
561 15
                    'tableName' => $foreignTable,
562 15
                    'columns' => [],
563 15
                ];
564
            }
565
566 15
            $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 139
        foreach ($constraints as $foreingKeyName => $constraint) {
574 15
            $table->foreignKey(
575 15
                (string) $foreingKeyName,
576 15
                array_merge([$constraint['tableName']], $constraint['columns'])
577 15
            );
578
        }
579
    }
580
581
    /**
582
     * Gets information about given table unique indexes.
583
     *
584
     * @param TableSchemaInterface $table the table metadata.
585
     *
586
     * @throws Exception
587
     * @throws InvalidConfigException
588
     * @throws Throwable
589
     *
590
     * @return array with index and column names.
591
     */
592 1
    protected function getUniqueIndexInformation(TableSchemaInterface $table): array
593
    {
594 1
        $sql = <<<'SQL'
595
        SELECT
596
            i.relname as indexname,
597
            pg_get_indexdef(idx.indexrelid, k + 1, TRUE) AS columnname
598
        FROM (
599
            SELECT *, generate_subscripts(indkey, 1) AS k
600
            FROM pg_index
601
        ) idx
602
        INNER JOIN pg_class i ON i.oid = idx.indexrelid
603
        INNER JOIN pg_class c ON c.oid = idx.indrelid
604
        INNER JOIN pg_namespace ns ON c.relnamespace = ns.oid
605
        WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
606
        AND c.relname = :tableName AND ns.nspname = :schemaName
607
        ORDER BY i.relname, k
608 1
        SQL;
609
610 1
        return $this->db->createCommand($sql, [
611 1
            ':schemaName' => $table->getSchemaName(),
612 1
            ':tableName' => $table->getName(),
613 1
        ])->queryAll();
614
    }
615
616
    /**
617
     * Returns all unique indexes for the given table.
618
     *
619
     * Each array element is of the following structure:
620
     *
621
     * ```php
622
     * [
623
     *     'IndexName1' => ['col1' [, ...]],
624
     *     'IndexName2' => ['col2' [, ...]],
625
     * ]
626
     * ```
627
     *
628
     * @param TableSchemaInterface $table the table metadata
629
     *
630
     * @throws Exception
631
     * @throws InvalidConfigException
632
     * @throws Throwable
633
     *
634
     * @return array all unique indexes for the given table.
635
     */
636 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
637
    {
638 1
        $uniqueIndexes = [];
639
640
        /** @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 (str_starts_with($column, '"') && str_ends_with($column, '"')) {
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 161
    protected function findColumns(TableSchemaInterface $table): bool
675
    {
676 161
        $orIdentity = '';
677
678 161
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
679 154
            $orIdentity = 'OR a.attidentity != \'\'';
680
        }
681
682 161
        $sql = <<<SQL
683 161
        SELECT
684
            d.nspname AS table_schema,
685
            c.relname AS table_name,
686
            a.attname AS column_name,
687
            COALESCE(td.typname, tb.typname, t.typname) AS data_type,
688
            COALESCE(td.typtype, tb.typtype, t.typtype) AS type_type,
689
            (SELECT nspname FROM pg_namespace WHERE oid = COALESCE(td.typnamespace, tb.typnamespace, t.typnamespace)) AS type_scheme,
690
            a.attlen AS character_maximum_length,
691
            pg_catalog.col_description(c.oid, a.attnum) AS column_comment,
692
            a.atttypmod AS modifier,
693
            a.attnotnull = false AS is_nullable,
694
            CAST(pg_get_expr(ad.adbin, ad.adrelid) AS varchar) AS column_default,
695 161
            coalesce(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval',false) $orIdentity AS is_autoinc,
696
            pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname)
697
            AS sequence_name,
698
            CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
699
                THEN array_to_string(
700
                    (
701
                        SELECT array_agg(enumlabel)
702
                        FROM pg_enum
703
                        WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid)
704
                    )::varchar[],
705
                ',')
706
                ELSE NULL
707
            END AS enum_values,
708
            CASE atttypid
709
                WHEN 21 /*int2*/ THEN 16
710
                WHEN 23 /*int4*/ THEN 32
711
                WHEN 20 /*int8*/ THEN 64
712
                WHEN 1700 /*numeric*/ THEN
713
                    CASE WHEN atttypmod = -1
714
                        THEN null
715
                        ELSE ((atttypmod - 4) >> 16) & 65535
716
                        END
717
                WHEN 700 /*float4*/ THEN 24 /*FLT_MANT_DIG*/
718
                WHEN 701 /*float8*/ THEN 53 /*DBL_MANT_DIG*/
719
                    ELSE null
720
                    END   AS numeric_precision,
721
            CASE
722
                WHEN atttypid IN (21, 23, 20) THEN 0
723
                WHEN atttypid IN (1700) THEN
724
            CASE
725
                WHEN atttypmod = -1 THEN null
726
                    ELSE (atttypmod - 4) & 65535
727
                    END
728
                    ELSE null
729
                    END AS numeric_scale,
730
                    CAST(
731
                        information_schema._pg_char_max_length(
732
                        information_schema._pg_truetypid(a, t),
733
                        information_schema._pg_truetypmod(a, t)
734
                        ) AS numeric
735
                    ) AS size,
736
                    a.attnum = any (ct.conkey) as is_pkey,
737
                    COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
738
            FROM
739
                pg_class c
740
                LEFT JOIN pg_attribute a ON a.attrelid = c.oid
741
                LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
742
                LEFT JOIN pg_type t ON a.atttypid = t.oid
743
                LEFT JOIN pg_type tb ON (a.attndims > 0 OR t.typcategory='A') AND t.typelem > 0 AND t.typelem = tb.oid
744
                                            OR t.typbasetype > 0 AND t.typbasetype = tb.oid
745
                LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
746
                LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
747
                LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p'
748
            WHERE
749
                a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
750
                AND c.relname = :tableName
751
                AND d.nspname = :schemaName
752
            ORDER BY
753
                a.attnum;
754 161
        SQL;
755
756 161
        $columns = $this->db->createCommand($sql, [
757 161
            ':schemaName' => $table->getSchemaName(),
758 161
            ':tableName' => $table->getName(),
759 161
        ])->queryAll();
760
761 161
        if (empty($columns)) {
762 39
            return false;
763
        }
764
765
        /** @var array $column */
766 139
        foreach ($columns as $column) {
767
            /** @psalm-var ColumnArray $column */
768 139
            $column = $this->normalizeRowKeyCase($column, false);
769
770
            /** @psalm-var ColumnSchema $loadColumnSchema */
771 139
            $loadColumnSchema = $this->loadColumnSchema($column);
772
773 139
            $table->columns($loadColumnSchema->getName(), $loadColumnSchema);
774
775
            /** @psalm-var mixed $defaultValue */
776 139
            $defaultValue = $loadColumnSchema->getDefaultValue();
777
778 139
            if ($loadColumnSchema->isPrimaryKey()) {
779 86
                $table->primaryKey($loadColumnSchema->getName());
780
781 86
                if ($table->getSequenceName() === null) {
782 86
                    $table->sequenceName($loadColumnSchema->getSequenceName());
783
                }
784
785 86
                $loadColumnSchema->defaultValue(null);
786 137
            } elseif ($defaultValue) {
787
                if (
788 67
                    is_string($defaultValue) &&
789 67
                    in_array(
790 67
                        $loadColumnSchema->getType(),
791 67
                        [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME],
792 67
                        true
793 67
                    ) &&
794 67
                    in_array(
795 67
                        strtoupper($defaultValue),
796 67
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
797 67
                        true
798 67
                    )
799
                ) {
800 32
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
801 67
                } elseif ($loadColumnSchema->getType() === 'boolean') {
802 62
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
803 38
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
804
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
805 38
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
806 32
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
807 38
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
808 35
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
809
                } elseif (
810 35
                    is_string($defaultValue) &&
811 35
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
812
                ) {
813 35
                    if ($matches[2] === 'NULL') {
814 5
                        $loadColumnSchema->defaultValue(null);
815
                    } else {
816 35
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
817
                    }
818
                } else {
819
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
820
                }
821
            }
822
        }
823
824 139
        return true;
825
    }
826
827
    /**
828
     * Loads the column information into a {@see ColumnSchemaInterface} object.
829
     *
830
     * @psalm-param array{
831
     *   table_schema: string,
832
     *   table_name: string,
833
     *   column_name: string,
834
     *   data_type: string,
835
     *   type_type: string|null,
836
     *   type_scheme: string|null,
837
     *   character_maximum_length: int,
838
     *   column_comment: string|null,
839
     *   modifier: int,
840
     *   is_nullable: bool,
841
     *   column_default: mixed,
842
     *   is_autoinc: bool,
843
     *   sequence_name: string|null,
844
     *   enum_values: array<array-key, float|int|string>|string|null,
845
     *   numeric_precision: int|null,
846
     *   numeric_scale: int|null,
847
     *   size: string|null,
848
     *   is_pkey: bool|null,
849
     *   dimension: int
850
     * } $info column information.
851
     *
852
     * @return ColumnSchemaInterface the column schema object.
853
     */
854 139
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
855
    {
856 139
        $column = $this->createColumnSchema();
857 139
        $column->allowNull($info['is_nullable']);
858 139
        $column->autoIncrement($info['is_autoinc']);
859 139
        $column->comment($info['column_comment']);
860
861 139
        if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)
862
        ) {
863 1
            $column->dbType($info['type_scheme'] . '.' . $info['data_type']);
864
        } else {
865 139
            $column->dbType($info['data_type']);
866
        }
867
868 139
        $column->defaultValue($info['column_default']);
869 139
        $column->enumValues(($info['enum_values'] !== null)
870 139
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null);
871 139
        $column->unsigned(false); // has no meaning in PG
872 139
        $column->primaryKey((bool) $info['is_pkey']);
873 139
        $column->name($info['column_name']);
874 139
        $column->precision($info['numeric_precision']);
875 139
        $column->scale($info['numeric_scale']);
876 139
        $column->size($info['size'] === null ? null : (int) $info['size']);
877 139
        $column->dimension($info['dimension']);
878
879
        /**
880
         * pg_get_serial_sequence() doesn't track DEFAULT value change. GENERATED BY IDENTITY columns always have null
881
         * default value.
882
         *
883
         * @psalm-var mixed $defaultValue
884
         */
885 139
        $defaultValue = $column->getDefaultValue();
886 139
        $sequenceName = $info['sequence_name'] ?? null;
887
888
        if (
889 139
            isset($defaultValue) &&
890 139
            is_string($defaultValue) &&
891 139
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
892
        ) {
893 76
            $column->sequenceName(preg_replace(
894 76
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
895 76
                '',
896 76
                $defaultValue
897 76
            ));
898 139
        } elseif ($sequenceName !== null) {
899 5
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
900
        }
901
902 139
        if (isset($this->typeMap[$column->getDbType()])) {
903 139
            $column->type($this->typeMap[$column->getDbType()]);
904
        } else {
905 5
            $column->type(self::TYPE_STRING);
906
        }
907
908 139
        $column->phpType($this->getColumnPhpType($column));
909
910 139
        return $column;
911
    }
912
913
    /**
914
     * Loads multiple types of constraints and returns the specified ones.
915
     *
916
     * @param string $tableName table name.
917
     * @param string $returnType return type:
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $returnType seems to be never defined.
Loading history...
918
     * - primaryKey
919
     * - foreignKeys
920
     * - uniques
921
     * - checks
922
     *
923
     * @throws Exception
924
     * @throws InvalidConfigException
925
     * @throws Throwable
926
     *
927
     * @return array|Constraint|null (CheckConstraint|Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
928
     */
929 82
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $tableName does not exist. Did you maybe mean $table?
Loading history...
930
    {
931 82
        $sql = <<<SQL
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $sql seems to be never defined.
Loading history...
932
        SELECT
933
            "c"."conname" AS "name",
934
            "a"."attname" AS "column_name",
935
            "c"."contype" AS "type",
936
            "ftcns"."nspname" AS "foreign_table_schema",
937
            "ftc"."relname" AS "foreign_table_name",
938
            "fa"."attname" AS "foreign_column_name",
939
            "c"."confupdtype" AS "on_update",
940
            "c"."confdeltype" AS "on_delete",
941
            pg_get_constraintdef("c"."oid") AS "check_expr"
942
        FROM "pg_class" AS "tc"
943
        INNER JOIN "pg_namespace" AS "tcns"
944
            ON "tcns"."oid" = "tc"."relnamespace"
945
        INNER JOIN "pg_constraint" AS "c"
946
            ON "c"."conrelid" = "tc"."oid"
947
        INNER JOIN "pg_attribute" AS "a"
948
            ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
949
        LEFT JOIN "pg_class" AS "ftc"
950
            ON "ftc"."oid" = "c"."confrelid"
951
        LEFT JOIN "pg_namespace" AS "ftcns"
952
            ON "ftcns"."oid" = "ftc"."relnamespace"
953
        LEFT JOIN "pg_attribute" "fa"
954
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
955
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
956
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
957 82
        SQL;
958
959
        /** @psalm-var array<array-key, string> $actionTypes */
960 82
        $actionTypes = [
961 82
            'a' => 'NO ACTION',
962 82
            'r' => 'RESTRICT',
963 82
            'c' => 'CASCADE',
964 82
            'n' => 'SET NULL',
965 82
            'd' => 'SET DEFAULT',
966 82
        ];
967
968 82
        $resolvedName = $this->resolveTableName($tableName);
969
970 82
        $constraints = $this->db->createCommand($sql, [
971 82
            ':schemaName' => $resolvedName->getSchemaName(),
972 82
            ':tableName' => $resolvedName->getName(),
973 82
        ])->queryAll();
974
975
        /** @var array<array-key, array> $constraints */
976 82
        $constraints = $this->normalizeRowKeyCase($constraints, true);
977 82
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
978
979 82
        $result = [
980 82
            self::PRIMARY_KEY => null,
981 82
            self::FOREIGN_KEYS => [],
982 82
            self::UNIQUES => [],
983 82
            self::CHECKS => [],
984 82
        ];
985
986
        /**
987
         * @var string $type
988
         * @var array $names
989
         */
990 82
        foreach ($constraints as $type => $names) {
991
            /**
992
             * @psalm-var object|string|null $name
993
             * @psalm-var ConstraintArray $constraint
994
             */
995 82
            foreach ($names as $name => $constraint) {
996
                switch ($type) {
997 82
                    case 'p':
998 57
                        $result[self::PRIMARY_KEY] = (new Constraint())
999 57
                            ->name($name)
1000 57
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1001 57
                        break;
1002 74
                    case 'f':
1003 19
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
1004 19
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
1005
1006 19
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
1007 19
                            ->name($name)
1008 19
                            ->columnNames(array_values(
1009 19
                                array_unique(ArrayHelper::getColumn($constraint, 'column_name'))
1010 19
                            ))
1011 19
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
1012 19
                            ->foreignTableName($constraint[0]['foreign_table_name'])
1013 19
                            ->foreignColumnNames(array_values(
1014 19
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
1015 19
                            ))
1016 19
                            ->onDelete($onDelete)
1017 19
                            ->onUpdate($onUpdate);
1018 19
                        break;
1019 61
                    case 'u':
1020 58
                        $result[self::UNIQUES][] = (new Constraint())
1021 58
                            ->name($name)
1022 58
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1023 58
                        break;
1024 15
                    case 'c':
1025 15
                        $result[self::CHECKS][] = (new CheckConstraint())
1026 15
                            ->name($name)
1027 15
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
1028 15
                            ->expression($constraint[0]['check_expr']);
1029 15
                        break;
1030
                }
1031
            }
1032
        }
1033
1034 82
        foreach ($result as $type => $data) {
1035 82
            $this->setTableMetadata($tableName, $type, $data);
1036
        }
1037
1038 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...
1039
    }
1040
1041
    /**
1042
     * Creates a column schema for the database.
1043
     *
1044
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1045
     *
1046
     * @return ColumnSchema column schema instance.
1047
     */
1048 139
    private function createColumnSchema(): ColumnSchema
0 ignored issues
show
Unused Code introduced by
The method createColumnSchema() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
1049
    {
1050 139
        return new ColumnSchema();
1051
    }
1052
1053 9
    public function createColumnSchemaBuilder(
1054
        string $type,
1055
        int|string|array|null $length = null
1056
    ): ColumnSchemaBuilderInterface {
1057 9
        return new ColumnSchemaBuilder($type, $length);
1058
    }
1059
1060
    /**
1061
     * Returns the cache key for the specified table name.
1062
     *
1063
     * @param string $name the table name.
1064
     *
1065
     * @return array the cache key.
1066
     */
1067 251
    protected function getCacheKey(string $name): array
1068
    {
1069 251
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
1070
    }
1071
1072
    /**
1073
     * Returns the cache tag name.
1074
     *
1075
     * This allows {@see refresh()} to invalidate all cached table schemas.
1076
     *
1077
     * @return string the cache tag name.
1078
     */
1079 252
    protected function getCacheTag(): string
1080
    {
1081 252
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
1082
    }
1083
}
1084