Passed
Pull Request — master (#90)
by Def
10:32
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 94
    protected function loadTableSchema(string $name): ?TableSchema
206
    {
207 94
        $table = new TableSchema();
208
209 94
        $this->resolveTableNames($table, $name);
210
211 94
        if ($this->findColumns($table)) {
212 88
            $this->findConstraints($table);
213
214 88
            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 28
        $sql = <<<'SQL'
264
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
271
    `s`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
272
    `s`.`INDEX_SCHEMA` = `s`.`TABLE_SCHEMA` AND
273
    `s`.`TABLE_NAME` = :tableName
274
ORDER BY `s`.`SEQ_IN_INDEX` ASC
275
SQL;
276
277 28
        $resolvedName = $this->resolveTableName($tableName);
278
279 28
        $indexes = $this->getDb()->createCommand($sql, [
280 28
            ':schemaName' => $resolvedName->getSchemaName(),
281 28
            ':tableName' => $resolvedName->getName(),
282 28
        ])->queryAll();
283
284
        /** @var array<array-key, array<array-key, mixed>> $indexes */
285 28
        $indexes = $this->normalizePdoRowKeyCase($indexes, true);
286 28
        $indexes = ArrayHelper::index($indexes, null, 'name');
287 28
        $result = [];
288
289
        /**
290
         * @psalm-var object|string|null $name
291
         * @psalm-var array<array-key, array<array-key, mixed>> $index
292
         */
293 28
        foreach ($indexes as $name => $index) {
294 28
            $ic = new IndexConstraint();
295
296 28
            $ic->primary((bool) $index[0]['index_is_primary']);
297 28
            $ic->unique((bool) $index[0]['index_is_unique']);
298 28
            $ic->name($name !== 'PRIMARY' ? $name : null);
299 28
            $ic->columnNames(ArrayHelper::getColumn($index, 'column_name'));
300
301 28
            $result[] = $ic;
302
        }
303
304 28
        return $result;
305
    }
306
307
    /**
308
     * Loads all unique constraints for the given table.
309
     *
310
     * @param string $tableName table name.
311
     *
312
     * @throws Exception|InvalidConfigException|Throwable
313
     *
314
     * @return array|Constraint[] unique constraints for the given table.
315
     */
316 13
    protected function loadTableUniques(string $tableName): array
317
    {
318 13
        $tableUniques = $this->loadTableConstraints($tableName, 'uniques');
319
320 13
        return is_array($tableUniques) ? $tableUniques : [];
321
    }
322
323
    /**
324
     * Loads all check constraints for the given table.
325
     *
326
     * @param string $tableName table name.
327
     *
328
     * @throws NotSupportedException
329
     *
330
     * @return array check constraints for the given table.
331
     */
332 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

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

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

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