Passed
Pull Request — master (#78)
by Wilmer
06:38
created

Schema::loadColumnSchema()   D

Complexity

Conditions 19
Paths 100

Size

Total Lines 75
Code Lines 44

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 19.0389

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 19
eloc 44
c 1
b 0
f 0
nc 100
nop 1
dl 0
loc 75
ccs 40
cts 42
cp 0.9524
crap 19.0389
rs 4.5166

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\Mysql;
6
7
use JsonException;
8
use PDO;
9
use PDOException;
10
use Throwable;
11
use Yiisoft\Arrays\ArrayHelper;
12
use Yiisoft\Db\Constraint\Constraint;
13
use Yiisoft\Db\Constraint\ConstraintFinderInterface;
14
use Yiisoft\Db\Constraint\ConstraintFinderTrait;
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\Schema as AbstractSchema;
22
23
use function array_change_key_case;
24
use function array_map;
25
use function array_merge;
26
use function array_values;
27
use function bindec;
28
use function explode;
29
use function preg_match;
30
use function preg_match_all;
31
use function str_replace;
32
use function stripos;
33
use function strpos;
34
use function strtolower;
35
use function trim;
36
37
/**
38
 * The class Schema is the class for retrieving metadata from a Mysql database (version 5.7 and above).
39
 *
40
 * @psalm-type ColumnArray = array{
41
 *   table_schema: string,
42
 *   table_name: string,
43
 *   column_name: string,
44
 *   data_type: string,
45
 *   type_type: string|null,
46
 *   character_maximum_length: int,
47
 *   column_comment: string|null,
48
 *   modifier: int,
49
 *   is_nullable: bool,
50
 *   column_default: mixed,
51
 *   is_autoinc: bool,
52
 *   sequence_name: string|null,
53
 *   enum_values: array<array-key, float|int|string>|string|null,
54
 *   numeric_precision: int|null,
55
 *   numeric_scale: int|null,
56
 *   size: string|null,
57
 *   is_pkey: bool|null,
58
 *   dimension: int
59
 * }
60
 *
61
 * @psalm-type ColumnInfoArray = array{
62
 *   field: string,
63
 *   type: string,
64
 *   collation: string|null,
65
 *   null: string,
66
 *   key: string,
67
 *   default: string|null,
68
 *   extra: string,
69
 *   privileges: string,
70
 *   comment: string
71
 * }
72
 *
73
 * @psalm-type RowConstraint = array{
74
 *   constraint_name: string,
75
 *   column_name: string,
76
 *   referenced_table_name: string,
77
 *   referenced_column_name: string
78
 * }
79
 *
80
 * @psalm-type ConstraintArray = array<
81
 *   array-key,
82
 *   array {
83
 *     name: string,
84
 *     column_name: string,
85
 *     type: string,
86
 *     foreign_table_schema: string|null,
87
 *     foreign_table_name: string|null,
88
 *     foreign_column_name: string|null,
89
 *     on_update: string,
90
 *     on_delete: string,
91
 *     check_expr: string
92
 *   }
93
 * >
94
 */
95
final class Schema extends AbstractSchema implements ConstraintFinderInterface
96
{
97
    use ConstraintFinderTrait;
98
99
    /** @var array<array-key, string> $typeMap */
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...
100
    private array $typeMap = [
101
        'tinyint' => self::TYPE_TINYINT,
102
        'bit' => self::TYPE_INTEGER,
103
        'smallint' => self::TYPE_SMALLINT,
104
        'mediumint' => self::TYPE_INTEGER,
105
        'int' => self::TYPE_INTEGER,
106
        'integer' => self::TYPE_INTEGER,
107
        'bigint' => self::TYPE_BIGINT,
108
        'float' => self::TYPE_FLOAT,
109
        'double' => self::TYPE_DOUBLE,
110
        'real' => self::TYPE_FLOAT,
111
        'decimal' => self::TYPE_DECIMAL,
112
        'numeric' => self::TYPE_DECIMAL,
113
        'tinytext' => self::TYPE_TEXT,
114
        'mediumtext' => self::TYPE_TEXT,
115
        'longtext' => self::TYPE_TEXT,
116
        'longblob' => self::TYPE_BINARY,
117
        'blob' => self::TYPE_BINARY,
118
        'text' => self::TYPE_TEXT,
119
        'varchar' => self::TYPE_STRING,
120
        'string' => self::TYPE_STRING,
121
        'char' => self::TYPE_CHAR,
122
        'datetime' => self::TYPE_DATETIME,
123
        'year' => self::TYPE_DATE,
124
        'date' => self::TYPE_DATE,
125
        'time' => self::TYPE_TIME,
126
        'timestamp' => self::TYPE_TIMESTAMP,
127
        'enum' => self::TYPE_STRING,
128
        'varbinary' => self::TYPE_BINARY,
129
        'json' => self::TYPE_JSON,
130
    ];
131
132
    /**
133
     * @var string|string[] character used to quote schema, table, etc. names. An array of 2 characters can be used in
134
     * case starting and ending characters are different.
135
     */
136
    protected $tableQuoteCharacter = '`';
137
138
    /**
139
     * @var string|string[] character used to quote column names. An array of 2 characters can be used in case starting
140
     * and ending characters are different.
141
     */
142
    protected $columnQuoteCharacter = '`';
143
144
    /**
145
     * Resolves the table name and schema name (if any).
146
     *
147
     * @param string $name the table name.
148
     *
149
     * @return TableSchema
150
     *
151
     * {@see TableSchema}
152
     */
153 58
    protected function resolveTableName(string $name): TableSchema
154
    {
155 58
        $resolvedName = new TableSchema();
156
157 58
        $parts = explode('.', str_replace('`', '', $name));
158
159 58
        if (isset($parts[1])) {
160
            $resolvedName->schemaName($parts[0]);
161
            $resolvedName->name($parts[1]);
162
        } else {
163 58
            $resolvedName->schemaName($this->defaultSchema);
164 58
            $resolvedName->name($name);
165
        }
166
167 58
        $resolvedName->fullName(($resolvedName->getSchemaName() !== $this->defaultSchema ?
168 58
            (string) $resolvedName->getSchemaName() . '.' : '') . (string) $resolvedName->getName());
169
170 58
        return $resolvedName;
171
    }
172
173
    /**
174
     * Returns all table names in the database.
175
     *
176
     * This method should be overridden by child classes in order to support this feature because the default
177
     * implementation simply throws an exception.
178
     *
179
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
180
     *
181
     * @throws Exception|InvalidConfigException|Throwable
182
     *
183
     * @return array all table names in the database. The names have NO schema name prefix.
184
     */
185 3
    protected function findTableNames(string $schema = ''): array
186
    {
187 3
        $sql = 'SHOW TABLES';
188
189 3
        if ($schema !== '') {
190
            $sql .= ' FROM ' . $this->quoteSimpleTableName($schema);
191
        }
192
193 3
        return $this->getDb()->createCommand($sql)->queryColumn();
194
    }
195
196
    /**
197
     * Loads the metadata for the specified table.
198
     *
199
     * @param string $name table name.
200
     *
201
     * @throws Exception|Throwable
202
     *
203
     * @return TableSchema|null DBMS-dependent table metadata, `null` if the table does not exist.
204
     */
205 93
    protected function loadTableSchema(string $name): ?TableSchema
206
    {
207 93
        $table = new TableSchema();
208
209 93
        $this->resolveTableNames($table, $name);
210
211 93
        if ($this->findColumns($table)) {
212 87
            $this->findConstraints($table);
213
214 87
            return $table;
215
        }
216
217 15
        return null;
218
    }
219
220
    /**
221
     * Loads a primary key for the given table.
222
     *
223
     * @param string $tableName table name.
224
     *
225
     * @throws Exception|InvalidConfigException|Throwable
226
     *
227
     * @return Constraint|null primary key for the given table, `null` if the table has no primary key.*
228
     */
229 31
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
230
    {
231 31
        $tablePrimaryKey = $this->loadTableConstraints($tableName, 'primaryKey');
232
233 31
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
234
    }
235
236
    /**
237
     * Loads all foreign keys for the given table.
238
     *
239
     * @param string $tableName table name.
240
     *
241
     * @throws Exception|InvalidConfigException|Throwable
242
     *
243
     * @return array|ForeignKeyConstraint[] foreign keys for the given table.
244
     */
245 4
    protected function loadTableForeignKeys(string $tableName): array
246
    {
247 4
        $tableForeignKeys = $this->loadTableConstraints($tableName, 'foreignKeys');
248
249 4
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
250
    }
251
252
    /**
253
     * Loads all indexes for the given table.
254
     *
255
     * @param string $tableName table name.
256
     *
257
     * @throws Exception|InvalidConfigException|Throwable
258
     *
259
     * @return IndexConstraint[] indexes for the given table.
260
     */
261 28
    protected function loadTableIndexes(string $tableName): array
262
    {
263
        $sql = <<<'SQL'
264 28
SELECT
265
    `s`.`INDEX_NAME` AS `name`,
266
    `s`.`COLUMN_NAME` AS `column_name`,
267
    `s`.`NON_UNIQUE` ^ 1 AS `index_is_unique`,
268
    `s`.`INDEX_NAME` = 'PRIMARY' AS `index_is_primary`
269
FROM `information_schema`.`STATISTICS` AS `s`
270
WHERE `s`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND `s`.`INDEX_SCHEMA` = `s`.`TABLE_SCHEMA` AND `s`.`TABLE_NAME` = :tableName
271
ORDER BY `s`.`SEQ_IN_INDEX` ASC
272
SQL;
273
274 28
        $resolvedName = $this->resolveTableName($tableName);
275
276 28
        $indexes = $this->getDb()->createCommand($sql, [
277 28
            ':schemaName' => $resolvedName->getSchemaName(),
278 28
            ':tableName' => $resolvedName->getName(),
279 28
        ])->queryAll();
280
281
        /** @var array<array-key, array<array-key, mixed>> $indexes */
282 28
        $indexes = $this->normalizePdoRowKeyCase($indexes, true);
283 28
        $indexes = ArrayHelper::index($indexes, null, 'name');
284 28
        $result = [];
285
286
        /**
287
         * @psalm-var object|string|null $name
288
         * @psalm-var array<array-key, array<array-key, mixed>> $index
289
         */
290 28
        foreach ($indexes as $name => $index) {
291 28
            $ic = new IndexConstraint();
292
293 28
            $ic->primary((bool) $index[0]['index_is_primary']);
294 28
            $ic->unique((bool) $index[0]['index_is_unique']);
295 28
            $ic->name($name !== 'PRIMARY' ? $name : null);
296 28
            $ic->columnNames(ArrayHelper::getColumn($index, 'column_name'));
297
298 28
            $result[] = $ic;
299
        }
300
301 28
        return $result;
302
    }
303
304
    /**
305
     * Loads all unique constraints for the given table.
306
     *
307
     * @param string $tableName table name.
308
     *
309
     * @throws Exception|InvalidConfigException|Throwable
310
     *
311
     * @return array|Constraint[] unique constraints for the given table.
312
     */
313 13
    protected function loadTableUniques(string $tableName): array
314
    {
315 13
        $tableUniques = $this->loadTableConstraints($tableName, 'uniques');
316
317 13
        return is_array($tableUniques) ? $tableUniques : [];
318
    }
319
320
    /**
321
     * Loads all check constraints for the given table.
322
     *
323
     * @param string $tableName table name.
324
     *
325
     * @throws NotSupportedException
326
     *
327
     * @return array check constraints for the given table.
328
     */
329 12
    protected function loadTableChecks(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

329
    protected function loadTableChecks(/** @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...
330
    {
331 12
        throw new NotSupportedException('MySQL does not support check constraints.');
332
    }
333
334
    /**
335
     * Loads all default value constraints for the given table.
336
     *
337
     * @param string $tableName table name.
338
     *
339
     * @throws NotSupportedException
340
     *
341
     * @return array default value constraints for the given table.
342
     */
343 12
    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

343
    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...
344
    {
345 12
        throw new NotSupportedException('MySQL does not support default value constraints.');
346
    }
347
348
    /**
349
     * Creates a query builder for the MySQL database.
350
     *
351
     * @return QueryBuilder query builder instance
352
     */
353 68
    public function createQueryBuilder(): QueryBuilder
354
    {
355 68
        return new QueryBuilder($this->getDb());
356
    }
357
358
    /**
359
     * Resolves the table name and schema name (if any).
360
     *
361
     * @param TableSchema $table the table metadata object.
362
     * @param string $name the table name.
363
     */
364 93
    protected function resolveTableNames(TableSchema $table, string $name): void
365
    {
366 93
        $parts = explode('.', str_replace('`', '', $name));
367
368 93
        if (isset($parts[1])) {
369
            $table->schemaName($parts[0]);
370
            $table->name($parts[1]);
371
            $table->fullName((string) $table->getSchemaName() . '.' . (string) $table->getName());
372
        } else {
373 93
            $table->name($parts[0]);
374 93
            $table->fullName($parts[0]);
375
        }
376 93
    }
377
378
    /**
379
     * Loads the column information into a {@see ColumnSchema} object.
380
     *
381
     * @var array $info column information.
382
     *
383
     * @throws JsonException
384
     *
385
     * @return ColumnSchema the column schema object.
386
     */
387 88
    protected function loadColumnSchema(array $info): ColumnSchema
388
    {
389 88
        $column = $this->createColumnSchema();
390
391
        /** @psalm-var ColumnInfoArray $info */
392 88
        $column->name($info['field']);
393 88
        $column->allowNull($info['null'] === 'YES');
394 88
        $column->primaryKey(strpos($info['key'], 'PRI') !== false);
395 88
        $column->autoIncrement(stripos($info['extra'], 'auto_increment') !== false);
396 88
        $column->comment($info['comment']);
397 88
        $column->dbType($info['type']);
398 88
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
399 88
        $column->type(self::TYPE_STRING);
400
401 88
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
402 88
            $type = strtolower($matches[1]);
403
404 88
            if (isset($this->typeMap[$type])) {
405 88
                $column->type($this->typeMap[$type]);
406
            }
407
408 88
            if (!empty($matches[2])) {
409 87
                if ($type === 'enum') {
410 20
                    preg_match_all("/'[^']*'/", $matches[2], $values);
411
412 20
                    foreach ($values[0] as $i => $value) {
413 20
                        $values[$i] = trim($value, "'");
414
                    }
415
416 20
                    $column->enumValues($values);
417
                } else {
418 87
                    $values = explode(',', $matches[2]);
419 87
                    $column->precision((int) $values[0]);
420 87
                    $column->size((int) $values[0]);
421
422 87
                    if (isset($values[1])) {
423 31
                        $column->scale((int) $values[1]);
424
                    }
425
426 87
                    if ($column->getSize() === 1 && $type === 'tinyint') {
427 21
                        $column->type('boolean');
428 87
                    } elseif ($type === 'bit') {
429 20
                        if ($column->getSize() > 32) {
430
                            $column->type('bigint');
431 20
                        } elseif ($column->getSize() === 32) {
432
                            $column->type('integer');
433
                        }
434
                    }
435
                }
436
            }
437
        }
438
439 88
        $column->phpType($this->getColumnPhpType($column));
440
441 88
        if (!$column->isPrimaryKey()) {
442
            /**
443
             * When displayed in the INFORMATION_SCHEMA.COLUMNS table, a default CURRENT TIMESTAMP is displayed
444
             * as CURRENT_TIMESTAMP up until MariaDB 10.2.2, and as current_timestamp() from MariaDB 10.2.3.
445
             *
446
             * See details here: https://mariadb.com/kb/en/library/now/#description
447
             */
448
            if (
449 85
                ($column->getType() === 'timestamp' || $column->getType() === 'datetime')
450 85
                && preg_match('/^current_timestamp(?:\((\d*)\))?$/i', (string) $info['default'], $matches)
451
            ) {
452 24
                $column->defaultValue(new Expression('CURRENT_TIMESTAMP' . (!empty($matches[1])
453 24
                    ? '(' . $matches[1] . ')' : '')));
454 82
            } elseif (isset($type) && $type === 'bit') {
455 20
                $column->defaultValue(bindec(trim((string) $info['default'], 'b\'')));
456
            } else {
457 82
                $column->defaultValue($column->phpTypecast($info['default']));
458
            }
459
        }
460
461 88
        return $column;
462
    }
463
464
    /**
465
     * Collects the metadata of table columns.
466
     *
467
     * @param TableSchema $table the table metadata.
468
     *
469
     * @throws Exception|Throwable if DB query fails.
470
     *
471
     * @return bool whether the table exists in the database.
472
     */
473 93
    protected function findColumns(TableSchema $table): bool
474
    {
475 93
        $tableName = $table->getFullName() ?? '';
476
477 93
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->quoteTableName($tableName);
478
479
        try {
480 93
            $columns = $this->getDb()->createCommand($sql)->queryAll();
481 15
        } catch (Exception $e) {
482 15
            $previous = $e->getPrevious();
483
484 15
            if ($previous instanceof PDOException && strpos($previous->getMessage(), 'SQLSTATE[42S02') !== false) {
485
                /**
486
                 * table does not exist.
487
                 *
488
                 * https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error
489
                 */
490 15
                return false;
491
            }
492
493
            throw $e;
494
        }
495
496 87
        $slavePdo = $this->getDb()->getSlavePdo();
497
498
        /** @psalm-var ColumnInfoArray $info */
499 87
        foreach ($columns as $info) {
500 87
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) !== PDO::CASE_LOWER) {
501 86
                $info = array_change_key_case($info, CASE_LOWER);
502
            }
503
504 87
            $column = $this->loadColumnSchema($info);
505 87
            $table->columns($column->getName(), $column);
506
507 87
            if ($column->isPrimaryKey()) {
508 54
                $table->primaryKey($column->getName());
509 54
                if ($column->isAutoIncrement()) {
510 52
                    $table->sequenceName('');
511
                }
512
            }
513
        }
514
515 87
        return true;
516
    }
517
518
    /**
519
     * Gets the CREATE TABLE sql string.
520
     *
521
     * @param TableSchema $table the table metadata.
522
     *
523
     * @throws Exception|InvalidConfigException|Throwable
524
     *
525
     * @return string $sql the result of 'SHOW CREATE TABLE'.
526
     */
527 1
    protected function getCreateTableSql(TableSchema $table): string
528
    {
529 1
        $tableName = $table->getFullName() ?? '';
530
531
        /** @var array<array-key, string> $row */
532 1
        $row = $this->getDb()->createCommand(
533 1
            'SHOW CREATE TABLE ' . $this->quoteTableName($tableName)
534 1
        )->queryOne();
535
536 1
        if (isset($row['Create Table'])) {
537 1
            $sql = $row['Create Table'];
538
        } else {
539
            $row = array_values($row);
0 ignored issues
show
Bug introduced by
$row of type false is incompatible with the type array expected by parameter $array of array_values(). ( Ignorable by Annotation )

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

539
            $row = array_values(/** @scrutinizer ignore-type */ $row);
Loading history...
540
            $sql = $row[1];
541
        }
542
543 1
        return $sql;
544
    }
545
546
    /**
547
     * Collects the foreign key column details for the given table.
548
     *
549
     * @param TableSchema $table the table metadata.
550
     *
551
     * @throws Exception|Throwable
552
     */
553 87
    protected function findConstraints(TableSchema $table): void
554
    {
555
        $sql = <<<'SQL'
556 87
SELECT
557
    `kcu`.`CONSTRAINT_NAME` AS `constraint_name`,
558
    `kcu`.`COLUMN_NAME` AS `column_name`,
559
    `kcu`.`REFERENCED_TABLE_NAME` AS `referenced_table_name`,
560
    `kcu`.`REFERENCED_COLUMN_NAME` AS `referenced_column_name`
561
FROM `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`
562
JOIN `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` ON
563
    (
564
        `kcu`.`CONSTRAINT_CATALOG` = `rc`.`CONSTRAINT_CATALOG` OR
565
        (`kcu`.`CONSTRAINT_CATALOG` IS NULL AND `rc`.`CONSTRAINT_CATALOG` IS NULL)
566
    ) AND
567
    `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
568
    `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME`
569
WHERE `rc`.`CONSTRAINT_SCHEMA` = database() AND `kcu`.`TABLE_SCHEMA` = database()
570
AND `rc`.`TABLE_NAME` = :tableName AND `kcu`.`TABLE_NAME` = :tableName1
571
SQL;
572
573
        try {
574 87
            $rows = $this->getDb()->createCommand(
575 87
                $sql,
576 87
                [':tableName' => $table->getName(), ':tableName1' => $table->getName()]
577 87
            )->queryAll();
578
579 87
            $constraints = [];
580
581
            /**  @psalm-var RowConstraint $row */
582 87
            foreach ($rows as $row) {
583 24
                $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
584 24
                $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
585
            }
586
587 87
            $table->foreignKeys([]);
588
589
            /**
590
             * @var string $name
591
             * @var array{referenced_table_name: string, columns: array} $constraint
592
             */
593 87
            foreach ($constraints as $name => $constraint) {
594 24
                $table->foreignKey($name, array_merge(
595 24
                    [$constraint['referenced_table_name']],
596 24
                    $constraint['columns']
597
                ));
598
            }
599
        } catch (Exception $e) {
600
            $previous = $e->getPrevious();
601
602
            if (!$previous instanceof PDOException || strpos($previous->getMessage(), 'SQLSTATE[42S02') === false) {
603
                throw $e;
604
            }
605
606
            // table does not exist, try to determine the foreign keys using the table creation sql
607
            $sql = $this->getCreateTableSql($table);
608
            $regexp = '/FOREIGN KEY\s+\(([^\)]+)\)\s+REFERENCES\s+([^\(^\s]+)\s*\(([^\)]+)\)/mi';
609
610
            if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
611
                foreach ($matches as $match) {
612
                    $fks = array_map('trim', explode(',', str_replace('`', '', $match[1])));
613
                    $pks = array_map('trim', explode(',', str_replace('`', '', $match[3])));
614
                    $constraint = [str_replace('`', '', $match[2])];
615
616
                    foreach ($fks as $k => $name) {
617
                        $constraint[$name] = $pks[$k];
618
                    }
619
620
                    $table->foreignKey(\md5(\serialize($constraint)), $constraint);
621
                }
622
                $table->foreignKeys(array_values($table->getForeignKeys()));
623
            }
624
        }
625 87
    }
626
627
    /**
628
     * Returns all unique indexes for the given table.
629
     *
630
     * Each array element is of the following structure:
631
     *
632
     * ```php
633
     * [
634
     *     'IndexName1' => ['col1' [, ...]],
635
     *     'IndexName2' => ['col2' [, ...]],
636
     * ]
637
     * ```
638
     *
639
     * @param TableSchema $table the table metadata.
640
     *
641
     * @throws Exception|InvalidConfigException|Throwable
642
     *
643
     * @return array all unique indexes for the given table.
644
     */
645 1
    public function findUniqueIndexes(TableSchema $table): array
646
    {
647 1
        $sql = $this->getCreateTableSql($table);
648
649 1
        $uniqueIndexes = [];
650
651 1
        $regexp = '/UNIQUE KEY\s+\`(.+)\`\s*\((\`.+\`)+\)/mi';
652
653 1
        if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
654 1
            foreach ($matches as $match) {
655 1
                $indexName = $match[1];
656 1
                $indexColumns = array_map('trim', explode('`,`', trim($match[2], '`')));
657 1
                $uniqueIndexes[$indexName] = $indexColumns;
658
            }
659
        }
660
661 1
        return $uniqueIndexes;
662
    }
663
664
    /**
665
     * Create a column schema builder instance giving the type and value precision.
666
     *
667
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
668
     *
669
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
670
     * @param array|int|string $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
671
     *
672
     * @return ColumnSchemaBuilder column schema builder instance
673
     */
674 3
    public function createColumnSchemaBuilder(string $type, $length = null): ColumnSchemaBuilder
675
    {
676 3
        return new ColumnSchemaBuilder($type, $length, $this->getDb());
677
    }
678
679
    /**
680
     * Loads multiple types of constraints and returns the specified ones.
681
     *
682
     * @param string $tableName table name.
683
     * @param string $returnType return type:
684
     * - primaryKey
685
     * - foreignKeys
686
     * - uniques
687
     *
688
     * @throws Exception|InvalidConfigException|Throwable
689
     *
690
     * @return (Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
691
     *
692
     * @psalm-return Constraint|list<Constraint|ForeignKeyConstraint>|null
693
     */
694 48
    private function loadTableConstraints(string $tableName, string $returnType)
695
    {
696
        $sql = <<<'SQL'
697 48
SELECT
698
    `kcu`.`CONSTRAINT_NAME` AS `name`,
699
    `kcu`.`COLUMN_NAME` AS `column_name`,
700
    `tc`.`CONSTRAINT_TYPE` AS `type`,
701
    CASE
702
        WHEN :schemaName IS NULL AND `kcu`.`REFERENCED_TABLE_SCHEMA` = DATABASE() THEN NULL
703
        ELSE `kcu`.`REFERENCED_TABLE_SCHEMA`
704
    END AS `foreign_table_schema`,
705
    `kcu`.`REFERENCED_TABLE_NAME` AS `foreign_table_name`,
706
    `kcu`.`REFERENCED_COLUMN_NAME` AS `foreign_column_name`,
707
    `rc`.`UPDATE_RULE` AS `on_update`,
708
    `rc`.`DELETE_RULE` AS `on_delete`,
709
    `kcu`.`ORDINAL_POSITION` AS `position`
710
FROM
711
    `information_schema`.`KEY_COLUMN_USAGE` AS `kcu`,
712
    `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`,
713
    `information_schema`.`TABLE_CONSTRAINTS` AS `tc`
714
WHERE
715
    `kcu`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND `kcu`.`CONSTRAINT_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND `kcu`.`TABLE_NAME` = :tableName
716
    AND `rc`.`CONSTRAINT_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND `rc`.`TABLE_NAME` = :tableName AND `rc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME`
717
    AND `tc`.`TABLE_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND `tc`.`TABLE_NAME` = :tableName AND `tc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME` AND `tc`.`CONSTRAINT_TYPE` = 'FOREIGN KEY'
718
UNION
719
SELECT
720
    `kcu`.`CONSTRAINT_NAME` AS `name`,
721
    `kcu`.`COLUMN_NAME` AS `column_name`,
722
    `tc`.`CONSTRAINT_TYPE` AS `type`,
723
    NULL AS `foreign_table_schema`,
724
    NULL AS `foreign_table_name`,
725
    NULL AS `foreign_column_name`,
726
    NULL AS `on_update`,
727
    NULL AS `on_delete`,
728
    `kcu`.`ORDINAL_POSITION` AS `position`
729
FROM
730
    `information_schema`.`KEY_COLUMN_USAGE` AS `kcu`,
731
    `information_schema`.`TABLE_CONSTRAINTS` AS `tc`
732
WHERE
733
    `kcu`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND `kcu`.`TABLE_NAME` = :tableName
734
    AND `tc`.`TABLE_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND `tc`.`TABLE_NAME` = :tableName AND `tc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME` AND `tc`.`CONSTRAINT_TYPE` IN ('PRIMARY KEY', 'UNIQUE')
735
ORDER BY `position` ASC
736
SQL;
737
738 48
        $resolvedName = $this->resolveTableName($tableName);
739
740 48
        $constraints = $this->getDb()->createCommand(
741 48
            $sql,
742
            [
743 48
                ':schemaName' => $resolvedName->getSchemaName(),
744 48
                ':tableName' => $resolvedName->getName(),
745
            ]
746 48
        )->queryAll();
747
748
        /** @var array<array-key, array> $constraints */
749 48
        $constraints = $this->normalizePdoRowKeyCase($constraints, true);
750 48
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
751
752
        $result = [
753 48
            'primaryKey' => null,
754
            'foreignKeys' => [],
755
            'uniques' => [],
756
        ];
757
758
        /**
759
         * @var string $type
760
         * @var array $names
761
         */
762 48
        foreach ($constraints as $type => $names) {
763
            /**
764
             * @psalm-var object|string|null $name
765
             * @psalm-var ConstraintArray $constraint
766
             */
767 48
            foreach ($names as $name => $constraint) {
768 48
                switch ($type) {
769 48
                    case 'PRIMARY KEY':
770 37
                        $ct = (new Constraint())
771 37
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
772
773 37
                        $result['primaryKey'] = $ct;
774
775 37
                        break;
776 46
                    case 'FOREIGN KEY':
777 10
                        $fk = (new ForeignKeyConstraint())
778 10
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
779 10
                            ->foreignTableName($constraint[0]['foreign_table_name'])
780 10
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
781 10
                            ->onDelete($constraint[0]['on_delete'])
782 10
                            ->onUpdate($constraint[0]['on_update'])
783 10
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
784 10
                            ->name($name);
785
786 10
                        $result['foreignKeys'][] = $fk;
787
788 10
                        break;
789 37
                    case 'UNIQUE':
790 37
                        $ct = (new Constraint())
791 37
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
792 37
                            ->name($name);
793
794 37
                        $result['uniques'][] = $ct;
795
796 37
                        break;
797
                }
798
            }
799
        }
800
801 48
        foreach ($result as $type => $data) {
802 48
            $this->setTableMetadata($tableName, $type, $data);
803
        }
804
805 48
        return $result[$returnType];
806
    }
807
808
    /**
809
     * Creates a column schema for the database.
810
     *
811
     * This method may be overridden by child classes to create a DBMS-specific column schema.
812
     *
813
     * @return ColumnSchema column schema instance.
814
     */
815 88
    private function createColumnSchema(): ColumnSchema
816
    {
817 88
        return new ColumnSchema();
818
    }
819
}
820