Passed
Push — dev ( 0e9e8a...f61394 )
by Def
05:41 queued 03:06
created

Schema::findColumns()   D

Complexity

Conditions 21
Paths 52

Size

Total Lines 155
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 21.034

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 21
eloc 68
c 4
b 0
f 0
nc 52
nop 1
dl 0
loc 155
ccs 45
cts 47
cp 0.9574
crap 21.034
rs 4.1666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Pgsql;
6
7
use JsonException;
8
use Throwable;
9
use Yiisoft\Arrays\ArrayHelper;
10
use Yiisoft\Db\Cache\SchemaCache;
11
use Yiisoft\Db\Connection\ConnectionInterface;
12
use Yiisoft\Db\Constraint\CheckConstraint;
13
use Yiisoft\Db\Constraint\Constraint;
14
use Yiisoft\Db\Constraint\DefaultValueConstraint;
15
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
16
use Yiisoft\Db\Constraint\IndexConstraint;
17
use Yiisoft\Db\Exception\Exception;
18
use Yiisoft\Db\Exception\InvalidConfigException;
19
use Yiisoft\Db\Exception\NotSupportedException;
20
use Yiisoft\Db\Expression\Expression;
21
use Yiisoft\Db\Schema\ColumnSchemaBuilder;
22
use Yiisoft\Db\Schema\Schema as AbstractSchema;
23
use Yiisoft\Db\View\ViewInterface;
24
25
use function array_change_key_case;
26
use function array_merge;
27
use function array_unique;
28
use function array_values;
29
use function bindec;
30
use function explode;
31
use function preg_match;
32
use function preg_replace;
33
use function str_replace;
34
use function substr;
35
36
/**
37
 * The class Schema is the class for retrieving metadata from a PostgreSQL database
38
 * (version 9.6 and above).
39
 *
40
 * @psalm-type ColumnArray = array{
41
 *   table_schema: string,
42
 *   table_name: string,
43
 *   column_name: string,
44
 *   data_type: string,
45
 *   type_type: 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 implements ViewInterface
85
{
86
    public const TYPE_JSONB = 'jsonb';
87
88
    /**
89
     * @var array The mapping from physical column types (keys) to abstract column types (values).
90
     *
91
     * {@see http://www.postgresql.org/docs/current/static/datatype.html#DATATYPE-TABLE}
92
     *
93
     * @psalm-var string[]
94
     */
95
    private array $typeMap = [
96
        'bit' => self::TYPE_INTEGER,
97
        'bit varying' => self::TYPE_INTEGER,
98
        'varbit' => self::TYPE_INTEGER,
99
        'bool' => self::TYPE_BOOLEAN,
100
        'boolean' => self::TYPE_BOOLEAN,
101
        'box' => self::TYPE_STRING,
102
        'circle' => self::TYPE_STRING,
103
        'point' => self::TYPE_STRING,
104
        'line' => self::TYPE_STRING,
105
        'lseg' => self::TYPE_STRING,
106
        'polygon' => self::TYPE_STRING,
107
        'path' => self::TYPE_STRING,
108
        'character' => self::TYPE_CHAR,
109
        'char' => self::TYPE_CHAR,
110
        'bpchar' => self::TYPE_CHAR,
111
        'character varying' => self::TYPE_STRING,
112
        'varchar' => self::TYPE_STRING,
113
        'text' => self::TYPE_TEXT,
114
        'bytea' => self::TYPE_BINARY,
115
        'cidr' => self::TYPE_STRING,
116
        'inet' => self::TYPE_STRING,
117
        'macaddr' => self::TYPE_STRING,
118
        'real' => self::TYPE_FLOAT,
119
        'float4' => self::TYPE_FLOAT,
120
        'double precision' => self::TYPE_DOUBLE,
121
        'float8' => self::TYPE_DOUBLE,
122
        'decimal' => self::TYPE_DECIMAL,
123
        'numeric' => self::TYPE_DECIMAL,
124
        'money' => self::TYPE_MONEY,
125
        'smallint' => self::TYPE_SMALLINT,
126
        'int2' => self::TYPE_SMALLINT,
127
        'int4' => self::TYPE_INTEGER,
128
        'int' => self::TYPE_INTEGER,
129
        'integer' => self::TYPE_INTEGER,
130
        'bigint' => self::TYPE_BIGINT,
131
        'int8' => self::TYPE_BIGINT,
132
        'oid' => self::TYPE_BIGINT, // should not be used. it's pg internal!
133
        'smallserial' => self::TYPE_SMALLINT,
134
        'serial2' => self::TYPE_SMALLINT,
135
        'serial4' => self::TYPE_INTEGER,
136
        'serial' => self::TYPE_INTEGER,
137
        'bigserial' => self::TYPE_BIGINT,
138
        'serial8' => self::TYPE_BIGINT,
139
        'pg_lsn' => self::TYPE_BIGINT,
140
        'date' => self::TYPE_DATE,
141
        'interval' => self::TYPE_STRING,
142
        'time without time zone' => self::TYPE_TIME,
143
        'time' => self::TYPE_TIME,
144
        'time with time zone' => self::TYPE_TIME,
145
        'timetz' => self::TYPE_TIME,
146
        'timestamp without time zone' => self::TYPE_TIMESTAMP,
147
        'timestamp' => self::TYPE_TIMESTAMP,
148
        'timestamp with time zone' => self::TYPE_TIMESTAMP,
149
        'timestamptz' => self::TYPE_TIMESTAMP,
150
        'abstime' => self::TYPE_TIMESTAMP,
151
        'tsquery' => self::TYPE_STRING,
152
        'tsvector' => self::TYPE_STRING,
153
        'txid_snapshot' => self::TYPE_STRING,
154
        'unknown' => self::TYPE_STRING,
155
        'uuid' => self::TYPE_STRING,
156
        'json' => self::TYPE_JSON,
157
        'jsonb' => self::TYPE_JSON,
158
        'xml' => self::TYPE_STRING,
159
    ];
160
161
    private array $viewNames = [];
162
163 428
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
164
    {
165 428
        parent::__construct($schemaCache);
166
    }
167
168
    /**
169
     * @var string|null the default schema used for the current session.
170
     */
171
    protected ?string $defaultSchema = 'public';
172
173
    /**
174
     * @var string|string[] character used to quote schema, table, etc. names. An array of 2 characters can be used in
175
     * case starting and ending characters are different.
176
     */
177
    protected string|array $tableQuoteCharacter = '"';
178
179
    /**
180
     * Resolves the table name and schema name (if any).
181
     *
182
     * @param string $name the table name.
183
     *
184
     * @return TableSchema with resolved table, schema, etc. names.
185
     *
186
     * {@see TableSchema}
187
     */
188 81
    protected function resolveTableName(string $name): TableSchema
189
    {
190 81
        $resolvedName = new TableSchema();
191
192 81
        $parts = explode('.', str_replace('"', '', $name));
193
194 81
        if (isset($parts[1])) {
195 5
            $resolvedName->schemaName($parts[0]);
196 5
            $resolvedName->name($parts[1]);
197
        } else {
198 76
            $resolvedName->schemaName($this->defaultSchema);
199 76
            $resolvedName->name($name);
200
        }
201
202 81
        $resolvedName->fullName(
203
            (
204 81
                $resolvedName->getSchemaName() !== $this->defaultSchema ?
205
                    (string) $resolvedName->getSchemaName() . '.' :
206 81
                    ''
207 81
            ) . $resolvedName->getName()
208
        );
209
210 81
        return $resolvedName;
211
    }
212
213
    /**
214
     * Returns all schema names in the database, including the default one but not system schemas.
215
     *
216
     * This method should be overridden by child classes in order to support this feature because the default
217
     * implementation simply throws an exception.
218
     *
219
     * @throws Exception|InvalidConfigException|Throwable
220
     *
221
     * @return array all schema names in the database, except system schemas.
222
     */
223 2
    protected function findSchemaNames(): array
224
    {
225 2
        $sql = <<<SQL
226
        SELECT "ns"."nspname"
227
        FROM "pg_namespace" AS "ns"
228
        WHERE "ns"."nspname" != 'information_schema' AND "ns"."nspname" NOT LIKE 'pg_%'
229
        ORDER BY "ns"."nspname" ASC
230
        SQL;
231
232 2
        $schemaNames = $this->db->createCommand($sql)->queryColumn();
233 2
        if (!$schemaNames) {
234
            return [];
235
        }
236
237 2
        return $schemaNames;
238
    }
239
240
    /**
241
     * Returns all table names in the database.
242
     *
243
     * This method should be overridden by child classes in order to support this feature because the default
244
     * implementation simply throws an exception.
245
     *
246
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
247
     *
248
     * @throws Exception|InvalidConfigException|Throwable
249
     *
250
     * @return array all table names in the database. The names have NO schema name prefix.
251
     */
252 10
    protected function findTableNames(string $schema = ''): array
253
    {
254 10
        if ($schema === '') {
255 10
            $schema = $this->defaultSchema;
256
        }
257
258 10
        $sql = <<<SQL
259
        SELECT c.relname AS table_name
260
        FROM pg_class c
261
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
262
        WHERE ns.nspname = :schemaName AND c.relkind IN ('r','v','m','f', 'p')
263
        ORDER BY c.relname
264
        SQL;
265
266 10
        $tableNames = $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
267 10
        if (!$tableNames) {
268
            return [];
269
        }
270
271 10
        return $tableNames;
272
    }
273
274
    /**
275
     * Loads the metadata for the specified table.
276
     *
277
     * @param string $name table name.
278
     *
279
     * @throws Exception|InvalidConfigException|Throwable
280
     *
281
     * @return TableSchema|null DBMS-dependent table metadata, `null` if the table does not exist.
282
     */
283 107
    protected function loadTableSchema(string $name): ?TableSchema
284
    {
285 107
        $table = new TableSchema();
286
287 107
        $this->resolveTableNames($table, $name);
288
289 107
        if ($this->findColumns($table)) {
290 101
            $this->findConstraints($table);
291 101
            return $table;
292
        }
293
294 17
        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|InvalidConfigException|Throwable
303
     *
304
     * @return Constraint|null primary key for the given table, `null` if the table has no primary key.
305
     */
306 32
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
307
    {
308 32
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
309
310 32
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
311
    }
312
313
    /**
314
     * Loads all foreign keys for the given table.
315
     *
316
     * @param string $tableName table name.
317
     *
318
     * @throws Exception|InvalidConfigException|Throwable
319
     *
320
     * @return array foreign keys for the given table.
321
     *
322
     * @psaml-return array|ForeignKeyConstraint[]
323
     */
324 5
    protected function loadTableForeignKeys(string $tableName): array
325
    {
326 5
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
327
328 5
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
329
    }
330
331
    /**
332
     * Loads all indexes for the given table.
333
     *
334
     * @param string $tableName table name.
335
     *
336
     * @throws Exception|InvalidConfigException|Throwable
337
     *
338
     * @return IndexConstraint[] indexes for the given table.
339
     */
340 29
    protected function loadTableIndexes(string $tableName): array
341
    {
342 29
        $sql = <<<SQL
343
        SELECT
344
            "ic"."relname" AS "name",
345
            "ia"."attname" AS "column_name",
346
            "i"."indisunique" AS "index_is_unique",
347
            "i"."indisprimary" AS "index_is_primary"
348
        FROM "pg_class" AS "tc"
349
        INNER JOIN "pg_namespace" AS "tcns"
350
            ON "tcns"."oid" = "tc"."relnamespace"
351
        INNER JOIN "pg_index" AS "i"
352
            ON "i"."indrelid" = "tc"."oid"
353
        INNER JOIN "pg_class" AS "ic"
354
            ON "ic"."oid" = "i"."indexrelid"
355
        INNER JOIN "pg_attribute" AS "ia"
356
            ON "ia"."attrelid" = "i"."indexrelid"
357
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
358
        ORDER BY "ia"."attnum" ASC
359
        SQL;
360
361 29
        $resolvedName = $this->resolveTableName($tableName);
362
363 29
        $indexes = $this->db->createCommand($sql, [
364 29
            ':schemaName' => $resolvedName->getSchemaName(),
365 29
            ':tableName' => $resolvedName->getName(),
366 29
        ])->queryAll();
367
368
        /** @var array[] @indexes */
369 29
        $indexes = $this->normalizeRowKeyCase($indexes, true);
370 29
        $indexes = ArrayHelper::index($indexes, null, 'name');
371 29
        $result = [];
372
373
        /**
374
         * @var object|string|null $name
375
         * @var array<
376
         *   array-key,
377
         *   array{
378
         *     name: string,
379
         *     column_name: string,
380
         *     index_is_unique: bool,
381
         *     index_is_primary: bool
382
         *   }
383
         * > $index
384
         */
385 29
        foreach ($indexes as $name => $index) {
386 26
            $ic = (new IndexConstraint())
387 26
                ->name($name)
388 26
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
389 26
                ->primary($index[0]['index_is_primary'])
390 26
                ->unique($index[0]['index_is_unique']);
391
392 26
            $result[] = $ic;
393
        }
394
395 29
        return $result;
396
    }
397
398
    /**
399
     * Loads all unique constraints for the given table.
400
     *
401
     * @param string $tableName table name.
402
     *
403
     * @throws Exception|InvalidConfigException|Throwable
404
     *
405
     * @return array unique constraints for the given table.
406
     *
407
     * @psalm-return array|Constraint[]
408
     */
409 14
    protected function loadTableUniques(string $tableName): array
410
    {
411 14
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
412
413 14
        return is_array($tableUniques) ? $tableUniques : [];
414
    }
415
416
    /**
417
     * Loads all check constraints for the given table.
418
     *
419
     * @param string $tableName table name.
420
     *
421
     * @throws Exception|InvalidConfigException|Throwable
422
     *
423
     * @return array check constraints for the given table.
424
     *
425
     * @psaml-return array|CheckConstraint[]
426
     */
427 14
    protected function loadTableChecks(string $tableName): array
428
    {
429 14
        $tableChecks = $this->loadTableConstraints($tableName, self::CHECKS);
430
431 14
        return is_array($tableChecks) ? $tableChecks : [];
432
    }
433
434
    /**
435
     * Loads all default value constraints for the given table.
436
     *
437
     * @param string $tableName table name.
438
     *
439
     * @throws NotSupportedException
440
     *
441
     * @return DefaultValueConstraint[] default value constraints for the given table.
442
     */
443 12
    protected function loadTableDefaultValues(string $tableName): array
444
    {
445 12
        throw new NotSupportedException('PostgreSQL does not support default value constraints.');
446
    }
447
448
    /**
449
     * Resolves the table name and schema name (if any).
450
     *
451
     * @param TableSchema $table the table metadata object.
452
     * @param string $name the table name
453
     */
454 107
    protected function resolveTableNames(TableSchema $table, string $name): void
455
    {
456 107
        $parts = explode('.', str_replace('"', '', $name));
457
458 107
        if (isset($parts[1])) {
459
            $table->schemaName($parts[0]);
460
            $table->name($parts[1]);
461
        } else {
462 107
            $table->schemaName($this->defaultSchema);
463 107
            $table->name($parts[0]);
464
        }
465
466 107
        if ($table->getSchemaName() !== $this->defaultSchema) {
467
            $name = (string) $table->getSchemaName() . '.' . $table->getName();
468
        } else {
469 107
            $name = $table->getName();
470
        }
471
472 107
        $table->fullName($name);
473
    }
474
475
    /**
476
     * @throws Exception|InvalidConfigException|Throwable
477
     */
478 1
    public function findViewNames(string $schema = ''): array
479
    {
480 1
        if ($schema === '') {
481
            $schema = $this->defaultSchema;
482
        }
483
484 1
        $sql = <<<SQL
485
        SELECT c.relname AS table_name
486
        FROM pg_class c
487
        INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
488
        WHERE ns.nspname = :schemaName AND (c.relkind = 'v' OR c.relkind = 'm')
489
        ORDER BY c.relname
490
        SQL;
491
492 1
        $viewNames = $this->db->createCommand($sql, [':schemaName' => $schema])->queryColumn();
493 1
        if (!$viewNames) {
494
            return [];
495
        }
496
497 1
        return $viewNames;
498
    }
499
500
    /**
501
     * Collects the foreign key column details for the given table.
502
     *
503
     * @param TableSchema $table the table metadata
504
     *
505
     * @throws Exception|InvalidConfigException|Throwable
506
     */
507 101
    protected function findConstraints(TableSchema $table): void
508
    {
509 101
        $tableName = $table->getName();
510 101
        $tableSchema = $table->getSchemaName();
511
512
        /** @var mixed */
513 101
        $tableName = $this->db->getQuoter()->quoteValue($tableName);
514
515 101
        if ($tableSchema !== null) {
516
            /** @var mixed */
517 101
            $tableSchema = $this->db->getQuoter()->quoteValue($tableSchema);
518
        }
519
520
        /**
521
         * We need to extract the constraints de hard way since:
522
         * {@see http://www.postgresql.org/message-id/[email protected]}
523
         */
524
525 101
        $sql = <<<SQL
526
        SELECT
527
            ct.conname as constraint_name,
528
            a.attname as column_name,
529
            fc.relname as foreign_table_name,
530
            fns.nspname as foreign_table_schema,
531
            fa.attname as foreign_column_name
532
            FROM
533
            (SELECT ct.conname, ct.conrelid, ct.confrelid, ct.conkey, ct.contype, ct.confkey,
534
                generate_subscripts(ct.conkey, 1) AS s
535
                FROM pg_constraint ct
536
            ) AS ct
537
            inner join pg_class c on c.oid=ct.conrelid
538
            inner join pg_namespace ns on c.relnamespace=ns.oid
539
            inner join pg_attribute a on a.attrelid=ct.conrelid and a.attnum = ct.conkey[ct.s]
540
            left join pg_class fc on fc.oid=ct.confrelid
541
            left join pg_namespace fns on fc.relnamespace=fns.oid
542
            left join pg_attribute fa on fa.attrelid=ct.confrelid and fa.attnum = ct.confkey[ct.s]
543
        WHERE
544
            ct.contype='f'
545
            and c.relname=$tableName
546
            and ns.nspname=$tableSchema
547
        ORDER BY
548
            fns.nspname, fc.relname, a.attnum
549
        SQL;
550
551
        /** @var array{array{tableName: string, columns: array}} $constraints */
552 101
        $constraints = [];
553
554
        /**
555
         * @psalm-var array<
556
         *   array{
557
         *     constraint_name: string,
558
         *     column_name: string,
559
         *     foreign_table_name: string,
560
         *     foreign_table_schema: string,
561
         *     foreign_column_name: string,
562
         *   }
563
         * > $rows
564
         */
565 101
        $rows = $this->db->createCommand($sql)->queryAll();
566
567 101
        foreach ($rows as $constraint) {
568
            /** @psalm-var array{
569
             *     constraint_name: string,
570
             *     column_name: string,
571
             *     foreign_table_name: string,
572
             *     foreign_table_schema: string,
573
             *     foreign_column_name: string,
574
             *   } $constraint */
575 9
            $constraint = $this->normalizeRowKeyCase($constraint, false);
576
577 9
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
578
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
579
            } else {
580 9
                $foreignTable = $constraint['foreign_table_name'];
581
            }
582
583 9
            $name = $constraint['constraint_name'];
584
585 9
            if (!isset($constraints[$name])) {
586 9
                $constraints[$name] = [
587
                    'tableName' => $foreignTable,
588
                    'columns' => [],
589
                ];
590
            }
591
592 9
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
593
        }
594
595
        /**
596
         * @var int|string $foreingKeyName.
597
         * @var array{tableName: string, columns: array} $constraint
598
         */
599 101
        foreach ($constraints as $foreingKeyName => $constraint) {
600 9
            $table->foreignKey(
601 9
                (string) $foreingKeyName,
602 9
                array_merge([$constraint['tableName']], $constraint['columns'])
603
            );
604
        }
605
    }
606
607
    /**
608
     * Gets information about given table unique indexes.
609
     *
610
     * @param TableSchema $table the table metadata.
611
     *
612
     * @throws Exception|InvalidConfigException|Throwable
613
     *
614
     * @return array with index and column names.
615
     */
616 1
    protected function getUniqueIndexInformation(TableSchema $table): array
617
    {
618 1
        $sql = <<<'SQL'
619
        SELECT
620
            i.relname as indexname,
621
            pg_get_indexdef(idx.indexrelid, k + 1, TRUE) AS columnname
622
        FROM (
623
            SELECT *, generate_subscripts(indkey, 1) AS k
624
            FROM pg_index
625
        ) idx
626
        INNER JOIN pg_class i ON i.oid = idx.indexrelid
627
        INNER JOIN pg_class c ON c.oid = idx.indrelid
628
        INNER JOIN pg_namespace ns ON c.relnamespace = ns.oid
629
        WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
630
        AND c.relname = :tableName AND ns.nspname = :schemaName
631
        ORDER BY i.relname, k
632
        SQL;
633
634 1
        return $this->db->createCommand($sql, [
635 1
            ':schemaName' => $table->getSchemaName(),
636 1
            ':tableName' => $table->getName(),
637 1
        ])->queryAll();
638
    }
639
640
    /**
641
     * Returns all unique indexes for the given table.
642
     *
643
     * Each array element is of the following structure:
644
     *
645
     * ```php
646
     * [
647
     *     'IndexName1' => ['col1' [, ...]],
648
     *     'IndexName2' => ['col2' [, ...]],
649
     * ]
650
     * ```
651
     *
652
     * @param TableSchema $table the table metadata
653
     *
654
     * @throws Exception|InvalidConfigException|Throwable
655
     *
656
     * @return array all unique indexes for the given table.
657
     */
658 1
    public function findUniqueIndexes(TableSchema $table): array
659
    {
660 1
        $uniqueIndexes = [];
661
662
        /** @var array{indexname: string, columnname: string} $row */
663 1
        foreach ($this->getUniqueIndexInformation($table) as $row) {
664
            /** @var array{indexname: string, columnname: string} $row */
665 1
            $row = $this->normalizeRowKeyCase($row, false);
666
667 1
            $column = $row['columnname'];
668
669 1
            if (!empty($column) && $column[0] === '"') {
670
                /**
671
                 * postgres will quote names that are not lowercase-only.
672
                 *
673
                 * {@see https://github.com/yiisoft/yii2/issues/10613}
674
                 */
675 1
                $column = substr($column, 1, -1);
676
            }
677
678 1
            $uniqueIndexes[$row['indexname']][] = $column;
679
        }
680
681 1
        return $uniqueIndexes;
682
    }
683
684
    /**
685
     * Collects the metadata of table columns.
686
     *
687
     * @param TableSchema $table the table metadata.
688
     *
689
     * @throws Exception|InvalidConfigException|JsonException|Throwable
690
     *
691
     * @return bool whether the table exists in the database.
692
     */
693 107
    protected function findColumns(TableSchema $table): bool
694
    {
695 107
        $tableName = $table->getName();
696 107
        $schemaName = $table->getSchemaName();
697 107
        $orIdentity = '';
698
699
        /** @var mixed */
700 107
        $tableName = $this->db->getQuoter()->quoteValue($tableName);
701
702 107
        if ($schemaName !== null) {
703
            /** @var mixed */
704 107
            $schemaName = $this->db->getQuoter()->quoteValue($schemaName);
705
        }
706
707 107
        if (version_compare($this->db->getServerVersion(), '12.0', '>=')) {
708 107
            $orIdentity = 'OR a.attidentity != \'\'';
709
        }
710
711 107
        $sql = <<<SQL
712
        SELECT
713
            d.nspname AS table_schema,
714
            c.relname AS table_name,
715
            a.attname AS column_name,
716
            COALESCE(td.typname, tb.typname, t.typname) AS data_type,
717
            COALESCE(td.typtype, tb.typtype, t.typtype) AS type_type,
718
            a.attlen AS character_maximum_length,
719
            pg_catalog.col_description(c.oid, a.attnum) AS column_comment,
720
            a.atttypmod AS modifier,
721
            a.attnotnull = false AS is_nullable,
722
            CAST(pg_get_expr(ad.adbin, ad.adrelid) AS varchar) AS column_default,
723
            coalesce(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval',false) $orIdentity AS is_autoinc,
724
            pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname)
725
            AS sequence_name,
726
            CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
727
                THEN array_to_string(
728
                    (
729
                        SELECT array_agg(enumlabel)
730
                        FROM pg_enum
731
                        WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid)
732
                    )::varchar[],
733
                ',')
734
                ELSE NULL
735
            END AS enum_values,
736
            CASE atttypid
737
                WHEN 21 /*int2*/ THEN 16
738
                WHEN 23 /*int4*/ THEN 32
739
                WHEN 20 /*int8*/ THEN 64
740
                WHEN 1700 /*numeric*/ THEN
741
                    CASE WHEN atttypmod = -1
742
                        THEN null
743
                        ELSE ((atttypmod - 4) >> 16) & 65535
744
                        END
745
                WHEN 700 /*float4*/ THEN 24 /*FLT_MANT_DIG*/
746
                WHEN 701 /*float8*/ THEN 53 /*DBL_MANT_DIG*/
747
                    ELSE null
748
                    END   AS numeric_precision,
749
            CASE
750
                WHEN atttypid IN (21, 23, 20) THEN 0
751
                WHEN atttypid IN (1700) THEN
752
            CASE
753
                WHEN atttypmod = -1 THEN null
754
                    ELSE (atttypmod - 4) & 65535
755
                    END
756
                    ELSE null
757
                    END AS numeric_scale,
758
                    CAST(
759
                        information_schema._pg_char_max_length(
760
                        information_schema._pg_truetypid(a, t),
761
                        information_schema._pg_truetypmod(a, t)
762
                        ) AS numeric
763
                    ) AS size,
764
                    a.attnum = any (ct.conkey) as is_pkey,
765
                    COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
766
            FROM
767
                pg_class c
768
                LEFT JOIN pg_attribute a ON a.attrelid = c.oid
769
                LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
770
                LEFT JOIN pg_type t ON a.atttypid = t.oid
771
                LEFT JOIN pg_type tb ON (a.attndims > 0 OR t.typcategory='A') AND t.typelem > 0 AND t.typelem = tb.oid
772
                                            OR t.typbasetype > 0 AND t.typbasetype = tb.oid
773
                LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
774
                LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
775
                LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p'
776
            WHERE
777
                a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
778
                AND c.relname = $tableName
779
                AND d.nspname = $schemaName
780
            ORDER BY
781
                a.attnum;
782
        SQL;
783
784 107
        $columns = $this->db->createCommand($sql)->queryAll();
785
786 107
        if (empty($columns)) {
787 17
            return false;
788
        }
789
790
        /** @var array $column */
791 101
        foreach ($columns as $column) {
792 101
            $column = $this->normalizeRowKeyCase($column, false);
793
794
            /** @psalm-var ColumnArray $column */
795 101
            $loadColumnSchema = $this->loadColumnSchema($column);
796 101
            $table->columns($loadColumnSchema->getName(), $loadColumnSchema);
797
798
            /** @var mixed */
799 101
            $defaultValue = $loadColumnSchema->getDefaultValue();
800
801 101
            if ($loadColumnSchema->isPrimaryKey()) {
802 69
                $table->primaryKey($loadColumnSchema->getName());
803
804 69
                if ($table->getSequenceName() === null) {
805 69
                    $table->sequenceName($loadColumnSchema->getSequenceName());
806
                }
807
808 69
                $loadColumnSchema->defaultValue(null);
809 98
            } elseif ($defaultValue) {
810
                if (
811 57
                    is_string($defaultValue) &&
812 57
                    in_array(
813 57
                        $loadColumnSchema->getType(),
814 57
                        [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME],
815
                        true
816
                    ) &&
817 30
                    in_array(
818 30
                        strtoupper($defaultValue),
819 30
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
820
                        true
821
                    )
822
                ) {
823 28
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
824 57
                } elseif ($loadColumnSchema->getType() === 'boolean') {
825 53
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
826 33
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
827
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
828 33
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
829 28
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
830 33
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
831 30
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
832
                } elseif (
833 31
                    is_string($defaultValue) &&
834 31
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
835
                ) {
836 31
                    if ($matches[2] === 'NULL') {
837 5
                        $loadColumnSchema->defaultValue(null);
838
                    } else {
839 31
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
840
                    }
841
                } else {
842
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
843
                }
844
            }
845
        }
846
847 101
        return true;
848
    }
849
850
    /**
851
     * Loads the column information into a {@see ColumnSchema} object.
852
     *
853
     * @psalm-param array{
854
     *   table_schema: string,
855
     *   table_name: string,
856
     *   column_name: string,
857
     *   data_type: string,
858
     *   type_type: string|null,
859
     *   character_maximum_length: int,
860
     *   column_comment: string|null,
861
     *   modifier: int,
862
     *   is_nullable: bool,
863
     *   column_default: mixed,
864
     *   is_autoinc: bool,
865
     *   sequence_name: string|null,
866
     *   enum_values: array<array-key, float|int|string>|string|null,
867
     *   numeric_precision: int|null,
868
     *   numeric_scale: int|null,
869
     *   size: string|null,
870
     *   is_pkey: bool|null,
871
     *   dimension: int
872
     * } $info column information.
873
     *
874
     * @return ColumnSchema the column schema object.
875
     */
876 101
    protected function loadColumnSchema(array $info): ColumnSchema
877
    {
878 101
        $column = $this->createColumnSchema();
879 101
        $column->allowNull($info['is_nullable']);
880 101
        $column->autoIncrement($info['is_autoinc']);
881 101
        $column->comment($info['column_comment']);
882 101
        $column->dbType($info['data_type']);
883 101
        $column->defaultValue($info['column_default']);
884 101
        $column->enumValues(($info['enum_values'] !== null)
885 101
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null);
886 101
        $column->unsigned(false); // has no meaning in PG
887 101
        $column->primaryKey((bool) $info['is_pkey']);
888 101
        $column->name($info['column_name']);
889 101
        $column->precision($info['numeric_precision']);
890 101
        $column->scale($info['numeric_scale']);
891 101
        $column->size($info['size'] === null ? null : (int) $info['size']);
892 101
        $column->dimension($info['dimension']);
893
894
        /**
895
         * pg_get_serial_sequence() doesn't track DEFAULT value change. GENERATED BY IDENTITY columns always have null
896
         * default value.
897
         *
898
         * @var mixed $defaultValue
899
         */
900 101
        $defaultValue = $column->getDefaultValue();
901 101
        $sequenceName = $info['sequence_name'] ?? null;
902
903
        if (
904 101
            isset($defaultValue) &&
905 101
            is_string($defaultValue) &&
906 101
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
907
        ) {
908 64
            $column->sequenceName(preg_replace(
909 64
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
910
                '',
911
                $defaultValue
912
            ));
913 99
        } elseif ($sequenceName !== null) {
914 5
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
915
        }
916
917 101
        if (isset($this->typeMap[$column->getDbType()])) {
918 101
            $column->type($this->typeMap[$column->getDbType()]);
919
        } else {
920
            $column->type(self::TYPE_STRING);
921
        }
922
923 101
        $column->phpType($this->getColumnPhpType($column));
924
925 101
        return $column;
926
    }
927
928
    /**
929
     * Loads multiple types of constraints and returns the specified ones.
930
     *
931
     * @param string $tableName table name.
932
     * @param string $returnType return type:
933
     * - primaryKey
934
     * - foreignKeys
935
     * - uniques
936
     * - checks
937
     *
938
     * @throws Exception|InvalidConfigException|Throwable
939
     *
940
     * @return array|Constraint|null (CheckConstraint|Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
941
     */
942 65
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
943
    {
944 65
        $sql = <<<SQL
945
        SELECT
946
            "c"."conname" AS "name",
947
            "a"."attname" AS "column_name",
948
            "c"."contype" AS "type",
949
            "ftcns"."nspname" AS "foreign_table_schema",
950
            "ftc"."relname" AS "foreign_table_name",
951
            "fa"."attname" AS "foreign_column_name",
952
            "c"."confupdtype" AS "on_update",
953
            "c"."confdeltype" AS "on_delete",
954
            pg_get_constraintdef("c"."oid") AS "check_expr"
955
        FROM "pg_class" AS "tc"
956
        INNER JOIN "pg_namespace" AS "tcns"
957
            ON "tcns"."oid" = "tc"."relnamespace"
958
        INNER JOIN "pg_constraint" AS "c"
959
            ON "c"."conrelid" = "tc"."oid"
960
        INNER JOIN "pg_attribute" AS "a"
961
            ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
962
        LEFT JOIN "pg_class" AS "ftc"
963
            ON "ftc"."oid" = "c"."confrelid"
964
        LEFT JOIN "pg_namespace" AS "ftcns"
965
            ON "ftcns"."oid" = "ftc"."relnamespace"
966
        LEFT JOIN "pg_attribute" "fa"
967
            ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
968
        WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
969
        ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
970
        SQL;
971
972
        /** @var array<array-key, string> $actionTypes */
973 65
        $actionTypes = [
974
            'a' => 'NO ACTION',
975
            'r' => 'RESTRICT',
976
            'c' => 'CASCADE',
977
            'n' => 'SET NULL',
978
            'd' => 'SET DEFAULT',
979
        ];
980
981 65
        $resolvedName = $this->resolveTableName($tableName);
982
983 65
        $constraints = $this->db->createCommand($sql, [
984 65
            ':schemaName' => $resolvedName->getSchemaName(),
985 65
            ':tableName' => $resolvedName->getName(),
986 65
        ])->queryAll();
987
988
        /** @var array<array-key, array> $constraints */
989 65
        $constraints = $this->normalizeRowKeyCase($constraints, true);
990 65
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
991
992 65
        $result = [
993
            self::PRIMARY_KEY => null,
994 65
            self::FOREIGN_KEYS => [],
995 65
            self::UNIQUES => [],
996 65
            self::CHECKS => [],
997
        ];
998
999
        /**
1000
         * @var string $type
1001
         * @var array $names
1002
         */
1003 65
        foreach ($constraints as $type => $names) {
1004
            /**
1005
             * @psalm-var object|string|null $name
1006
             * @psalm-var ConstraintArray $constraint
1007
             */
1008 65
            foreach ($names as $name => $constraint) {
1009 65
                switch ($type) {
1010 65
                    case 'p':
1011 50
                        $result[self::PRIMARY_KEY] = (new Constraint())
1012 50
                            ->name($name)
1013 50
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1014 50
                        break;
1015 63
                    case 'f':
1016 17
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
1017 17
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
1018
1019 17
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
1020 17
                            ->name($name)
1021 17
                            ->columnNames(array_values(
1022 17
                                array_unique(ArrayHelper::getColumn($constraint, 'column_name'))
1023
                            ))
1024 17
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
1025 17
                            ->foreignTableName($constraint[0]['foreign_table_name'])
1026 17
                            ->foreignColumnNames(array_values(
1027 17
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
1028
                            ))
1029 17
                            ->onDelete($onDelete)
1030 17
                            ->onUpdate($onUpdate);
1031 17
                        break;
1032 51
                    case 'u':
1033 50
                        $result[self::UNIQUES][] = (new Constraint())
1034 50
                            ->name($name)
1035 50
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1036 50
                        break;
1037 14
                    case 'c':
1038 14
                        $result[self::CHECKS][] = (new CheckConstraint())
1039 14
                            ->name($name)
1040 14
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
1041 14
                            ->expression($constraint[0]['check_expr']);
1042 14
                        break;
1043
                }
1044
            }
1045
        }
1046
1047 65
        foreach ($result as $type => $data) {
1048 65
            $this->setTableMetadata($tableName, $type, $data);
1049
        }
1050
1051 65
        return $result[$returnType];
1052
    }
1053
1054
    /**
1055
     * Creates a column schema for the database.
1056
     *
1057
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1058
     *
1059
     * @return ColumnSchema column schema instance.
1060
     */
1061 101
    private function createColumnSchema(): ColumnSchema
1062
    {
1063 101
        return new ColumnSchema();
1064
    }
1065
1066
    /**
1067
     * Create a column schema builder instance giving the type and value precision.
1068
     *
1069
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
1070
     *
1071
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
1072
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
1073
     *
1074
     * @return ColumnSchemaBuilder column schema builder instance
1075
     *
1076
     * @psalm-param int|string|string[]|null $length
1077
     */
1078 4
    public function createColumnSchemaBuilder(string $type, int|string|array|null $length = null): ColumnSchemaBuilder
1079
    {
1080 4
        return new ColumnSchemaBuilder($type, $length);
1081
    }
1082
1083
    /**
1084
     * Returns the actual name of a given table name.
1085
     *
1086
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
1087
     * {@see ConnectionInterface::tablePrefix}.
1088
     *
1089
     * @param string $name the table name to be converted.
1090
     *
1091
     * @return string the real name of the given table name.
1092
     */
1093 172
    public function getRawTableName(string $name): string
1094
    {
1095 172
        if (str_contains($name, '{{')) {
1096 23
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
1097
1098 23
            return str_replace('%', $this->db->getTablePrefix(), $name);
1099
        }
1100
1101 172
        return $name;
1102
    }
1103
1104
    /**
1105
     * Returns the cache key for the specified table name.
1106
     *
1107
     * @param string $name the table name.
1108
     *
1109
     * @return array the cache key.
1110
     */
1111 172
    protected function getCacheKey(string $name): array
1112
    {
1113 172
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
1114
    }
1115
1116
    /**
1117
     * Returns the cache tag name.
1118
     *
1119
     * This allows {@see refresh()} to invalidate all cached table schemas.
1120
     *
1121
     * @return string the cache tag name.
1122
     */
1123 172
    protected function getCacheTag(): string
1124
    {
1125 172
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
1126
    }
1127
1128
    /**
1129
     * @return bool whether this DBMS supports [savepoint](http://en.wikipedia.org/wiki/Savepoint).
1130
     */
1131 5
    public function supportsSavepoint(): bool
1132
    {
1133 5
        return $this->db->isSavepointEnabled();
1134
    }
1135
1136
    /**
1137
     * Changes row's array key case to lower.
1138
     *
1139
     * @param array $row row's array or an array of row's arrays.
1140
     * @param bool $multiple whether multiple rows or a single row passed.
1141
     *
1142
     * @return array normalized row or rows.
1143
     */
1144 159
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
1145
    {
1146 159
        if ($multiple) {
1147 76
            return array_map(static function (array $row) {
1148 73
                return array_change_key_case($row, CASE_LOWER);
1149
            }, $row);
1150
        }
1151
1152 101
        return array_change_key_case($row, CASE_LOWER);
1153
    }
1154
1155
    /**
1156
     * @inheritDoc
1157
     */
1158
    public function getLastInsertID(?string $sequenceName = null): string
1159
    {
1160
        return $this->db->getLastInsertID($sequenceName);
1161
    }
1162
1163
    /**
1164
     * @throws Exception|InvalidConfigException|Throwable
1165
     */
1166 1
    public function getViewNames(string $schema = '', bool $refresh = false): array
1167
    {
1168 1
        if (!isset($this->viewNames[$schema]) || $refresh) {
1169 1
            $this->viewNames[$schema] = $this->findViewNames($schema);
1170
        }
1171
1172 1
        return is_array($this->viewNames[$schema]) ? $this->viewNames[$schema] : [];
1173
    }
1174
}
1175