Passed
Pull Request — master (#70)
by Alexander
13:48
created

Schema::findConstraints()   C

Complexity

Conditions 10
Paths 56

Size

Total Lines 570
Code Lines 527

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 118
CRAP Score 11.1194

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 10
eloc 527
c 2
b 0
f 0
nc 56
nop 1
dl 0
loc 570
ccs 118
cts 152
cp 0.7763
crap 11.1194
rs 6.1333

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

275
        if ($this->/** @scrutinizer ignore-call */ findColumns($table)) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
276
            $this->findConstraints($table);
277
            return $table;
278
        }
279
280
        return null;
281
    }
282
283
    /**
284
     * Loads a primary key for the given table.
285
     *
286
     * @param string $tableName table name.
287
     *
288
     * @throws Exception|InvalidConfigException
289
     *
290
     * @return Constraint|null primary key for the given table, `null` if the table has no primary key.
291
     */
292 28
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
293
    {
294 28
        $tablePrimaryKey = $this->loadTableConstraints($tableName, 'primaryKey');
0 ignored issues
show
Bug introduced by
The method loadTableConstraints() does not exist on Yiisoft\Db\Pgsql\Schema. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

294
        /** @scrutinizer ignore-call */ 
295
        $tablePrimaryKey = $this->loadTableConstraints($tableName, 'primaryKey');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
295 28
296 28
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
297 28
    }
298
299 28
    /**
300 28
     * Loads all foreign keys for the given table.
301 28
     *
302
     * @param string $tableName table name.
303 28
     *
304 25
     * @throws Exception|InvalidConfigException
305 25
     *
306 25
     * @return array|ForeignKeyConstraint[] foreign keys for the given table.
307 25
     */
308 25
    protected function loadTableForeignKeys(string $tableName): array
309
    {
310 25
        $tableForeignKeys = $this->loadTableConstraints($tableName, 'foreignKeys');
311
312
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
313 28
    }
314
315
    /**
316
     * Loads all indexes for the given table.
317
     *
318
     * @param string $tableName table name.
319
     *
320
     * @throws Exception|InvalidConfigException|Throwable
321
     *
322
     * @return IndexConstraint[] indexes for the given table.
323
     */
324
    protected function loadTableIndexes(string $tableName): array
325 13
    {
326
        $sql = <<<'SQL'
327 13
SELECT
328
    "ic"."relname" AS "name",
329
    "ia"."attname" AS "column_name",
330
    "i"."indisunique" AS "index_is_unique",
331
    "i"."indisprimary" AS "index_is_primary"
332
FROM "pg_class" AS "tc"
333
INNER JOIN "pg_namespace" AS "tcns"
334
    ON "tcns"."oid" = "tc"."relnamespace"
335
INNER JOIN "pg_index" AS "i"
336
    ON "i"."indrelid" = "tc"."oid"
337
INNER JOIN "pg_class" AS "ic"
338
    ON "ic"."oid" = "i"."indexrelid"
339 13
INNER JOIN "pg_attribute" AS "ia"
340
    ON "ia"."attrelid" = "i"."indrelid" AND "ia"."attnum" = ANY ("i"."indkey")
341 13
WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
342
ORDER BY "ia"."attnum" ASC
343
SQL;
344
345
        $resolvedName = $this->resolveTableName($tableName);
346
347
        $indexes = $this->getDb()->createCommand($sql, [
348
            ':schemaName' => $resolvedName->getSchemaName(),
349
            ':tableName' => $resolvedName->getName(),
350
        ])->queryAll();
351
352
        /** @var array<array-key, array<array-key, mixed>> @indexes */
353 12
        $indexes = $this->normalizePdoRowKeyCase($indexes, true);
354
        $indexes = ArrayHelper::index($indexes, null, 'name');
355 12
        $result = [];
356
357
        /**
358
         * @var object|string|null $name
359
         * @var array<
360
         *   array-key,
361
         *   array{
362
         *     name: string,
363 69
         *     column_name: string,
364
         *     index_is_unique: bool,
365 69
         *     index_is_primary: bool
366
         *   }
367
         * > $index
368
         */
369
        foreach ($indexes as $name => $index) {
370
            $ic = (new IndexConstraint())
371
                ->name($name)
372
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
373
                ->primary($index[0]['index_is_primary'])
374 97
                ->unique($index[0]['index_is_unique']);
375
376 97
            $result[] = $ic;
377
        }
378 97
379
        return $result;
380
    }
381
382 97
    /**
383 97
     * Loads all unique constraints for the given table.
384
     *
385
     * @param string $tableName table name.
386 97
     *
387 97
     * @throws Exception|InvalidConfigException
388 97
     *
389
     * @return array|Constraint[] unique constraints for the given table.
390
     */
391
    protected function loadTableUniques(string $tableName): array
392
    {
393
        $tableUniques = $this->loadTableConstraints($tableName, 'uniques');
394
395
        return is_array($tableUniques) ? $tableUniques : [];
396
    }
397
398
    /**
399
     * Loads all check constraints for the given table.
400
     *
401
     * @param string $tableName table name.
402
     *
403
     * @throws Exception|InvalidConfigException
404
     *
405
     * @return array|CheckConstraint[] check constraints for the given table.
406
     */
407
    protected function loadTableChecks(string $tableName): array
408
    {
409
        $tableChecks = $this->loadTableConstraints($tableName, 'checks');
410
411
        return is_array($tableChecks) ? $tableChecks : [];
412
    }
413
414 91
    /**
415
     * Loads all default value constraints for the given table.
416 91
     *
417 91
     * @param string $tableName table name.
418
     *
419
     * @throws NotSupportedException
420
     *
421
     * @return DefaultValueConstraint[] default value constraints for the given table.
422
     */
423
    protected function loadTableDefaultValues(string $tableName): array
0 ignored issues
show
Unused Code introduced by
The parameter $tableName is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

423
    protected function loadTableDefaultValues(/** @scrutinizer ignore-unused */ string $tableName): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
424
    {
425 91
        throw new NotSupportedException('PostgreSQL does not support default value constraints.');
426
    }
427
428
    /**
429
     * Creates a query builder for the PostgreSQL database.
430
     *
431
     * @return QueryBuilder query builder instance
432
     */
433
    public function createQueryBuilder(): QueryBuilder
434
    {
435
        return new QueryBuilder($this->getDb());
436
    }
437
438
    /**
439
     * Resolves the table name and schema name (if any).
440
     *
441
     * @param TableSchema $table the table metadata object.
442
     * @param string $name the table name
443 91
     */
444 91
    protected function resolveTableNames(TableSchema $table, string $name): void
445
    {
446
        $parts = explode('.', str_replace('"', '', $name));
447
448
        if (isset($parts[1])) {
449 91
            $table->schemaName($parts[0]);
450
            $table->name($parts[1]);
451 91
        } else {
452 8
            $table->schemaName($this->defaultSchema);
453
            $table->name($parts[0]);
454
        }
455
456 8
        if ($table->getSchemaName() !== $this->defaultSchema) {
457
            $name = (string) $table->getSchemaName() . '.' . (string) $table->getName();
458
        } else {
459 8
            $name = $table->getName();
460
        }
461
462 8
        $table->fullName($name);
463
    }
464 8
465 8
    protected function findViewNames(string $schema = ''): array
466 8
    {
467
        if ($schema === '') {
468
            $schema = $this->defaultSchema;
469
        }
470
471 8
        $sql = <<<'SQL'
472
SELECT c.relname AS table_name
473
FROM pg_class c
474 91
INNER JOIN pg_namespace ns ON ns.oid = c.relnamespace
475 8
WHERE ns.nspname = :schemaName AND (c.relkind = 'v' OR c.relkind = 'm')
476
ORDER BY c.relname
477 91
SQL;
478
479
        return $this->getDb()->createCommand($sql, [':schemaName' => $schema])->queryColumn();
480
    }
481
482
    /**
483
     * Collects the foreign key column details for the given table.
484
     *
485
     * @param TableSchema $table the table metadata
486
     *
487
     * @throws Exception|InvalidConfigException|Throwable
488
     */
489
    protected function findConstraints(TableSchema $table): void
490
    {
491
        $tableName = $table->getName();
492
        $tableSchema = $table->getSchemaName();
493
494
        if ($tableName !== null) {
495
            $tableName = $this->quoteValue($tableName);
496
        }
497
498
        if ($tableSchema !== null) {
499
            $tableSchema = $this->quoteValue($tableSchema);
0 ignored issues
show
Unused Code introduced by
The assignment to $tableSchema is dead and can be removed.
Loading history...
500
        }
501
502
        /**
503
         * We need to extract the constraints de hard way since:
504
         * {@see http://www.postgresql.org/message-id/[email protected]}
505
         */
506
507
        $sql = <<<SQL
508
select
509
    ct.conname as constraint_name,
510
    a.attname as column_name,
511
    fc.relname as foreign_table_name,
512
    fns.nspname as foreign_table_schema,
513
    fa.attname as foreign_column_name
514
from
515
    (SELECT ct.conname, ct.conrelid, ct.confrelid, ct.conkey, ct.contype, ct.confkey, generate_subscripts(ct.conkey, 1) AS s
516
       FROM pg_constraint ct
517
    ) AS ct
518
    inner join pg_class c on c.oid=ct.conrelid
519
    inner join pg_namespace ns on c.relnamespace=ns.oid
520
    inner join pg_attribute a on a.attrelid=ct.conrelid and a.attnum = ct.conkey[ct.s]
521
    left join pg_class fc on fc.oid=ct.confrelid
522
    left join pg_namespace fns on fc.relnamespace=fns.oid
523
    left join pg_attribute fa on fa.attrelid=ct.confrelid and fa.attnum = ct.confkey[ct.s]
524
where
525
    ct.contype='f'
526
    and c.relname={$tableName}
527
    and ns.nspname={$tableSchema}
528
order by
529
    fns.nspname, fc.relname, a.attnum
530
SQL;
531
532
        /** @var array{array{tableName: string, columns: array}} $constraints */
533
        $constraints = [];
534
        $slavePdo = $this->getDb()->getSlavePdo();
535
536
        /** @var FindConstraintArray $constraint */
537
        foreach ($this->getDb()->createCommand($sql)->queryAll() as $constraint) {
538
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
539
                $constraint = array_change_key_case($constraint, CASE_LOWER);
540
            }
541
542
            if ($constraint['foreign_table_schema'] !== $this->defaultSchema) {
543
                $foreignTable = $constraint['foreign_table_schema'] . '.' . $constraint['foreign_table_name'];
544
            } else {
545
                $foreignTable = $constraint['foreign_table_name'];
546
            }
547
548
            $name = $constraint['constraint_name'];
549
550
            if (!isset($constraints[$name])) {
551
                $constraints[$name] = [
552
                    'tableName' => $foreignTable,
553
                    'columns' => [],
554
                ];
555
            }
556
557
            $constraints[$name]['columns'][$constraint['column_name']] = $constraint['foreign_column_name'];
558
        }
559
560
        /**
561
         * @var int|string $foreingKeyName.
562
         * @var array{tableName: string, columns: array} $constraint
563
         */
564
        foreach ($constraints as $foreingKeyName => $constraint) {
565 97
            $table->foreignKey(
566
                (string) $foreingKeyName,
567 97
                array_merge([$constraint['tableName']], $constraint['columns'])
568 97
            );
569
        }
570 97
    }
571
572 97
    /**
573
     * Gets information about given table unique indexes.
574
     *
575
     * @param TableSchema $table the table metadata.
576
     *
577 97
     * @throws Exception|InvalidConfigException|Throwable
578
     *
579
     * @return array with index and column names.
580
     */
581
    protected function getUniqueIndexInformation(TableSchema $table): array
582
    {
583
        $sql = <<<'SQL'
584
SELECT
585
    i.relname as indexname,
586
    pg_get_indexdef(idx.indexrelid, k + 1, TRUE) AS columnname
587
FROM (
588 97
  SELECT *, generate_subscripts(indkey, 1) AS k
589
  FROM pg_index
590
) idx
591
INNER JOIN pg_class i ON i.oid = idx.indexrelid
592
INNER JOIN pg_class c ON c.oid = idx.indrelid
593
INNER JOIN pg_namespace ns ON c.relnamespace = ns.oid
594
WHERE idx.indisprimary = FALSE AND idx.indisunique = TRUE
595
AND c.relname = :tableName AND ns.nspname = :schemaName
596
ORDER BY i.relname, k
597
SQL;
598
599
        return $this->getDb()->createCommand($sql, [
600
            ':schemaName' => $table->getSchemaName(),
601
            ':tableName' => $table->getName(),
602
        ])->queryAll();
603
    }
604
605
    /**
606
     * Returns all unique indexes for the given table.
607
     *
608
     * Each array element is of the following structure:
609
     *
610
     * ```php
611
     * [
612
     *     'IndexName1' => ['col1' [, ...]],
613
     *     'IndexName2' => ['col2' [, ...]],
614
     * ]
615
     * ```
616
     *
617
     * @param TableSchema $table the table metadata
618
     *
619
     * @throws Exception|InvalidConfigException|Throwable
620
     *
621
     * @return array all unique indexes for the given table.
622
     */
623
    public function findUniqueIndexes(TableSchema $table): array
624
    {
625
        $uniqueIndexes = [];
626
        $slavePdo = $this->getDb()->getSlavePdo();
627
628
        /** @var array{indexname: string, columnname: string} $row */
629
        foreach ($this->getUniqueIndexInformation($table) as $row) {
630
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
631
                $row = array_change_key_case($row, CASE_LOWER);
632
            }
633 97
634 97
            $column = $row['columnname'];
635
636
            if (!empty($column) && $column[0] === '"') {
637
                /**
638
                 * postgres will quote names that are not lowercase-only.
639 97
                 *
640
                 * {@see https://github.com/yiisoft/yii2/issues/10613}
641 97
                 */
642 16
                $column = substr($column, 1, -1);
643
            }
644
645 91
            $uniqueIndexes[$row['indexname']][] = $column;
646 91
        }
647
648
        return $uniqueIndexes;
649
    }
650 91
651 91
    /**
652
     * Collects the metadata of table columns.
653 91
     *
654 62
     * @param TableSchema $table the table metadata.
655
     *
656 62
     * @throws Exception|InvalidConfigException|JsonException|Throwable
657 62
     *
658
     * @return bool whether the table exists in the database.
659
     */
660 62
    protected function findColumns(TableSchema $table): bool
661 88
    {
662
        $tableName = $table->getName();
663 54
        $schemaName = $table->getSchemaName();
664 29
        $orIdentity = '';
665 29
666 29
        if ($tableName !== null) {
667 54
            $tableName = $this->getDb()->quoteValue($tableName);
668
        }
669
670 27
        if ($schemaName !== null) {
671 54
            $schemaName = $this->getDb()->quoteValue($schemaName);
672 51
        }
673 31
674
        if (version_compare($this->getDb()->getServerVersion(), '12.0', '>=')) {
675 31
            $orIdentity = 'OR a.attidentity != \'\'';
676 27
        }
677 31
678 28
        $sql = <<<SQL
679 30
SELECT
680 30
    d.nspname AS table_schema,
681 5
    c.relname AS table_name,
682
    a.attname AS column_name,
683 30
    COALESCE(td.typname, tb.typname, t.typname) AS data_type,
684
    COALESCE(td.typtype, tb.typtype, t.typtype) AS type_type,
685
    a.attlen AS character_maximum_length,
686
    pg_catalog.col_description(c.oid, a.attnum) AS column_comment,
687
    a.atttypmod AS modifier,
688
    a.attnotnull = false AS is_nullable,
689
    CAST(pg_get_expr(ad.adbin, ad.adrelid) AS varchar) AS column_default,
690
    coalesce(pg_get_expr(ad.adbin, ad.adrelid) ~ 'nextval',false) {$orIdentity} AS is_autoinc,
691 91
    pg_get_serial_sequence(quote_ident(d.nspname) || '.' || quote_ident(c.relname), a.attname) AS sequence_name,
692
    CASE WHEN COALESCE(td.typtype, tb.typtype, t.typtype) = 'e'::char
693
        THEN array_to_string((SELECT array_agg(enumlabel) FROM pg_enum WHERE enumtypid = COALESCE(td.oid, tb.oid, a.atttypid))::varchar[], ',')
694
        ELSE NULL
695
    END AS enum_values,
696
    CASE atttypid
697
         WHEN 21 /*int2*/ THEN 16
698
         WHEN 23 /*int4*/ THEN 32
699
         WHEN 20 /*int8*/ THEN 64
700
         WHEN 1700 /*numeric*/ THEN
701 91
              CASE WHEN atttypmod = -1
702
               THEN null
703 91
               ELSE ((atttypmod - 4) >> 16) & 65535
704 91
               END
705 91
         WHEN 700 /*float4*/ THEN 24 /*FLT_MANT_DIG*/
706 91
         WHEN 701 /*float8*/ THEN 53 /*DBL_MANT_DIG*/
707 91
         ELSE null
708 91
      END   AS numeric_precision,
709 91
      CASE
710 91
        WHEN atttypid IN (21, 23, 20) THEN 0
711 91
        WHEN atttypid IN (1700) THEN
712 91
        CASE
713 91
            WHEN atttypmod = -1 THEN null
714 91
            ELSE (atttypmod - 4) & 65535
715 91
        END
716 91
           ELSE null
717 91
      END AS numeric_scale,
718
    CAST(
719
             information_schema._pg_char_max_length(information_schema._pg_truetypid(a, t), information_schema._pg_truetypmod(a, t))
720
             AS numeric
721
    ) AS size,
722
    a.attnum = any (ct.conkey) as is_pkey,
723
    COALESCE(NULLIF(a.attndims, 0), NULLIF(t.typndims, 0), (t.typcategory='A')::int) AS dimension
724 91
FROM
725
    pg_class c
726 91
    LEFT JOIN pg_attribute a ON a.attrelid = c.oid
727 91
    LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
728
    LEFT JOIN pg_type t ON a.atttypid = t.oid
729 59
    LEFT JOIN pg_type tb ON (a.attndims > 0 OR t.typcategory='A') AND t.typelem > 0 AND t.typelem = tb.oid OR t.typbasetype > 0 AND t.typbasetype = tb.oid
730
    LEFT JOIN pg_type td ON t.typndims > 0 AND t.typbasetype > 0 AND tb.typelem = td.oid
731
    LEFT JOIN pg_namespace d ON d.oid = c.relnamespace
732 59
    LEFT JOIN pg_constraint ct ON ct.conrelid = c.oid AND ct.contype = 'p'
733
WHERE
734 89
    a.attnum > 0 AND t.typname != '' AND NOT a.attisdropped
735
    AND c.relname = {$tableName}
736
    AND d.nspname = {$schemaName}
737
ORDER BY
738 91
    a.attnum;
739 91
SQL;
740
741
        /** @var array columns */
742
        $columns = $this->getDb()->createCommand($sql)->queryAll();
743
        $slavePdo = $this->getDb()->getSlavePdo();
744 91
745
        if (empty($columns)) {
746 91
            return false;
747
        }
748
749
        /** @var array<array-key, mixed> $column */
750
        foreach ($columns as $column) {
751
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) === PDO::CASE_UPPER) {
752
                $column = array_change_key_case($column, CASE_LOWER);
753
            }
754
755
            /** @psalm-var ColumnArray $column */
756
            $loadColumnSchema = $this->loadColumnSchema($column);
757
            $table->columns($loadColumnSchema->getName(), $loadColumnSchema);
758
759
            /** @var mixed $defaultValue */
760
            $defaultValue = $loadColumnSchema->getDefaultValue();
761
762
            if ($loadColumnSchema->isPrimaryKey()) {
763
                $table->primaryKey($loadColumnSchema->getName());
764
765
                if ($table->getSequenceName() === null) {
766
                    $table->sequenceName($loadColumnSchema->getSequenceName());
767
                }
768
769
                $loadColumnSchema->defaultValue(null);
770
            } elseif ($defaultValue) {
771
                if (
772
                    is_string($defaultValue) &&
773
                    in_array($loadColumnSchema->getType(), [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME], true) &&
774
                    in_array(
775
                        strtoupper($defaultValue),
776
                        ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'],
777
                        true
778
                    )
779
                ) {
780
                    $loadColumnSchema->defaultValue(new Expression($defaultValue));
781
                } elseif ($loadColumnSchema->getType() === 'boolean') {
782
                    $loadColumnSchema->defaultValue(($defaultValue  === 'true'));
783
                } elseif (is_string($defaultValue) && preg_match("/^B'(.*?)'::/", $defaultValue, $matches)) {
784
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
785
                } elseif (is_string($defaultValue) && preg_match("/^'(\d+)'::\"bit\"$/", $defaultValue, $matches)) {
786
                    $loadColumnSchema->defaultValue(bindec($matches[1]));
787
                } elseif (is_string($defaultValue) && preg_match("/^'(.*?)'::/", $defaultValue, $matches)) {
788
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1]));
789
                } elseif (
790
                    is_string($defaultValue) &&
791
                    preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches)
792
                ) {
793
                    if ($matches[2] === 'NULL') {
794 61
                        $loadColumnSchema->defaultValue(null);
795
                    } else {
796 61
                        $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2]));
797
                    }
798
                } else {
799
                    $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue));
800
                }
801
            }
802
        }
803
804
        return true;
805
    }
806
807
    /**
808
     * Loads the column information into a {@see ColumnSchema} object.
809
     *
810
     * @param array{
811
     *   table_schema: string,
812
     *   table_name: string,
813
     *   column_name: string,
814
     *   data_type: string,
815
     *   type_type: string|null,
816
     *   character_maximum_length: int,
817
     *   column_comment: string|null,
818
     *   modifier: int,
819
     *   is_nullable: bool,
820
     *   column_default: mixed,
821
     *   is_autoinc: bool,
822
     *   sequence_name: string|null,
823
     *   enum_values: array<array-key, float|int|string>|string|null,
824 61
     *   numeric_precision: int|null,
825
     *   numeric_scale: int|null,
826
     *   size: string|null,
827
     *   is_pkey: bool|null,
828
     *   dimension: int
829
     * } $info column information.
830
     *
831
     * @return ColumnSchema the column schema object.
832 61
     */
833 61
    protected function loadColumnSchema(array $info): ColumnSchema
834 61
    {
835 61
        $column = $this->createColumnSchema();
836 61
        $column->allowNull($info['is_nullable']);
837 61
        $column->autoIncrement($info['is_autoinc']);
838 61
        $column->comment($info['column_comment']);
839
        $column->dbType($info['data_type']);
840 61
        $column->defaultValue($info['column_default']);
841
        $column->enumValues(($info['enum_values'] !== null)
842
            ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null);
843
        $column->unsigned(false); // has no meaning in PG
844
        $column->primaryKey((bool) $info['is_pkey']);
845
        $column->name($info['column_name']);
846 61
        $column->precision($info['numeric_precision']);
847 61
        $column->scale($info['numeric_scale']);
848
        $column->size($info['size'] === null ? null : (int) $info['size']);
849 61
        $column->dimension($info['dimension']);
850 46
851 46
        /**
852 46
         * pg_get_serial_sequence() doesn't track DEFAULT value change. GENERATED BY IDENTITY columns always have null
853
         * default value.
854 46
         *
855 46
         * @var mixed $defaultValue
856 59
         */
857 13
        $defaultValue = $column->getDefaultValue();
858 13
        $sequenceName = $info['sequence_name'] ?? null;
859 13
860 13
        if (
861
            isset($defaultValue) &&
862 13
            is_string($defaultValue) &&
863 13
            preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1
864 13
        ) {
865 13
            $column->sequenceName(preg_replace(
866
                ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'],
867 13
                '',
868 13
                $defaultValue
869
            ));
870 13
        } elseif ($sequenceName !== null) {
871 13
            $column->sequenceName($this->resolveTableName($sequenceName)->getFullName());
872 47
        }
873 46
874 46
        if (isset($this->typeMap[$column->getDbType()])) {
875 46
            $column->type($this->typeMap[$column->getDbType()]);
876
        } else {
877 46
            $column->type(self::TYPE_STRING);
878 46
        }
879 10
880 10
        $column->phpType($this->getColumnPhpType($column));
881 10
882 10
        return $column;
883 10
    }
884
885 10
    /**
886 10
     * Executes the INSERT command, returning primary key values.
887
     *
888
     * @param string $table the table that new rows will be inserted into.
889
     * @param array $columns the column data (name => value) to be inserted into the table.
890
     *
891 61
     * @throws Exception|InvalidConfigException|Throwable
892 61
     *
893
     * @return array|false primary key values or false if the command fails.
894
     */
895 61
    public function insert(string $table, array $columns)
896
    {
897
        $params = [];
898
        $returnColumns = [];
899
        $sql = $this->getDb()->getQueryBuilder()->insert($table, $columns, $params);
900
        $tableSchema = $this->getTableSchema($table);
901
902
        if ($tableSchema !== null) {
903
            $returnColumns = $tableSchema->getPrimaryKey();
904
        }
905 91
906
        if (!empty($returnColumns)) {
907 91
            $returning = [];
908
            /** @var string $name */
909
            foreach ($returnColumns as $name) {
910
                $returning[] = $this->quoteColumnName($name);
911
            }
912
            $sql .= ' RETURNING ' . implode(', ', $returning);
913
        }
914
915
        $command = $this->getDb()->createCommand($sql, $params);
916
        $command->prepare(false);
917
        $result = $command->queryOne();
918
919
        $pdoStatement = $command->getPdoStatement();
920 4
921
        return $pdoStatement !== null && !$pdoStatement->rowCount() ? false : $result;
922 4
    }
923
924
    /**
925
     * Loads multiple types of constraints and returns the specified ones.
926
     *
927
     * @param string $tableName table name.
928
     * @param string $returnType return type:
929
     * - primaryKey
930
     * - foreignKeys
931
     * - uniques
932
     * - checks
933
     *
934
     * @throws Exception|InvalidConfigException|Throwable
935
     *
936
     * @return (CheckConstraint|Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
937
     *
938
     * @psalm-return Constraint|list<CheckConstraint|Constraint|ForeignKeyConstraint>|null
939
     */
940
    private function loadTableConstraints(string $tableName, string $returnType)
941
    {
942
        /** @var string $sql */
943
        $sql = <<<'SQL'
944
SELECT
945
    "c"."conname" AS "name",
946
    "a"."attname" AS "column_name",
947
    "c"."contype" AS "type",
948
    "ftcns"."nspname" AS "foreign_table_schema",
949
    "ftc"."relname" AS "foreign_table_name",
950
    "fa"."attname" AS "foreign_column_name",
951
    "c"."confupdtype" AS "on_update",
952
    "c"."confdeltype" AS "on_delete",
953
    pg_get_constraintdef("c"."oid") AS "check_expr"
954
FROM "pg_class" AS "tc"
955
INNER JOIN "pg_namespace" AS "tcns"
956
    ON "tcns"."oid" = "tc"."relnamespace"
957
INNER JOIN "pg_constraint" AS "c"
958
    ON "c"."conrelid" = "tc"."oid"
959
INNER JOIN "pg_attribute" AS "a"
960
    ON "a"."attrelid" = "c"."conrelid" AND "a"."attnum" = ANY ("c"."conkey")
961
LEFT JOIN "pg_class" AS "ftc"
962
    ON "ftc"."oid" = "c"."confrelid"
963
LEFT JOIN "pg_namespace" AS "ftcns"
964
    ON "ftcns"."oid" = "ftc"."relnamespace"
965
LEFT JOIN "pg_attribute" "fa"
966
    ON "fa"."attrelid" = "c"."confrelid" AND "fa"."attnum" = ANY ("c"."confkey")
967
WHERE "tcns"."nspname" = :schemaName AND "tc"."relname" = :tableName
968
ORDER BY "a"."attnum" ASC, "fa"."attnum" ASC
969
SQL;
970
971
        /** @var array<array-key, string> $actionTypes */
972
        $actionTypes = [
973
            'a' => 'NO ACTION',
974
            'r' => 'RESTRICT',
975
            'c' => 'CASCADE',
976
            'n' => 'SET NULL',
977
            'd' => 'SET DEFAULT',
978
        ];
979
980
        $resolvedName = $this->resolveTableName($tableName);
0 ignored issues
show
Bug introduced by
It seems like $tableName can also be of type null; however, parameter $name of Yiisoft\Db\Pgsql\Schema::resolveTableName() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

980
        $resolvedName = $this->resolveTableName(/** @scrutinizer ignore-type */ $tableName);
Loading history...
981
982
        $constraints = $this->getDb()->createCommand($sql, [
983
            ':schemaName' => $resolvedName->getSchemaName(),
984
            ':tableName' => $resolvedName->getName(),
985
        ])->queryAll();
986
987
        /** @var array<array-key, array> $constraints */
988
        $constraints = $this->normalizePdoRowKeyCase($constraints, true);
989
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
990
991
        $result = [
992
            'primaryKey' => null,
993
            'foreignKeys' => [],
994
            'uniques' => [],
995
            'checks' => [],
996
        ];
997
998
        /**
999
         * @var string $type
1000
         * @var array $names
1001
         */
1002
        foreach ($constraints as $type => $names) {
1003
            /**
1004
             * @psalm-var object|string|null $name
1005
             * @psalm-var ConstraintArray $constraint
1006
             */
1007
            foreach ($names as $name => $constraint) {
1008
                switch ($type) {
1009
                    case 'p':
1010
                        $ct = (new Constraint())
1011
                            ->name($name)
1012
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1013
1014
                        $result['primaryKey'] = $ct;
1015
                        break;
1016
                    case 'f':
1017
                        $onDelete = $actionTypes[$constraint[0]['on_delete']] ?? null;
1018
                        $onUpdate = $actionTypes[$constraint[0]['on_update']] ?? null;
1019
1020
                        $fk = (new ForeignKeyConstraint())
1021
                            ->name($name)
1022
                            ->columnNames(array_values(
1023
                                array_unique(ArrayHelper::getColumn($constraint, 'column_name'))
1024
                            ))
1025
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
1026
                            ->foreignTableName($constraint[0]['foreign_table_name'])
1027
                            ->foreignColumnNames(array_values(
1028
                                array_unique(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
1029
                            ))
1030
                            ->onDelete($onDelete)
1031
                            ->onUpdate($onUpdate);
1032
1033
                        $result['foreignKeys'][] = $fk;
1034
                        break;
1035
                    case 'u':
1036
                        $ct = (new Constraint())
1037
                            ->name($name)
1038
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
1039
1040
                        $result['uniques'][] = $ct;
1041
                        break;
1042
                    case 'c':
1043
                        $ck = (new CheckConstraint())
1044
                            ->name($name)
1045
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
1046
                            ->expression($constraint[0]['check_expr']);
1047
1048
                        $result['checks'][] = $ck;
1049
                        break;
1050
                }
1051
            }
1052
        }
1053
1054
        foreach ($result as $type => $data) {
1055
            $this->setTableMetadata($tableName, $type, $data);
0 ignored issues
show
Bug introduced by
It seems like $tableName can also be of type null; however, parameter $name of Yiisoft\Db\Schema\Schema::setTableMetadata() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1055
            $this->setTableMetadata(/** @scrutinizer ignore-type */ $tableName, $type, $data);
Loading history...
1056
        }
1057
1058
        return $result[$returnType];
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $returnType seems to be never defined.
Loading history...
1059
    }
1060
1061
    /**
1062
     * Creates a column schema for the database.
1063
     *
1064
     * This method may be overridden by child classes to create a DBMS-specific column schema.
1065
     *
1066
     * @return ColumnSchema column schema instance.
1067
     */
1068
    private function createColumnSchema(): ColumnSchema
0 ignored issues
show
Unused Code introduced by
The method createColumnSchema() is not used, and could be removed.

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

Loading history...
1069
    {
1070
        return new ColumnSchema();
1071
    }
1072
1073
    /**
1074
     * Create a column schema builder instance giving the type and value precision.
1075
     *
1076
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
1077
     *
1078
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
1079
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
1080
     *
1081
     * @return ColumnSchemaBuilder column schema builder instance
1082
     */
1083
    public function createColumnSchemaBuilder(string $type, $length = null): ColumnSchemaBuilder
1084
    {
1085
        return new ColumnSchemaBuilder($type, $length);
1086
    }
1087
}
1088