Passed
Push — adopt-last-change-db ( 4e573f )
by Wilmer
15:19 queued 11:43
created

Schema::findColumns()   D

Complexity

Conditions 21
Paths 52

Size

Total Lines 157
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 21.034

Importance

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