Test Failed
Pull Request — master (#241)
by Def
06:13 queued 02:49
created

Schema::loadTableChecks()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

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

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

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

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