Passed
Push — master ( 735c08...5f608f )
by Def
32:29 queued 29:35
created

Schema::findConstraints()   B

Complexity

Conditions 9
Paths 43

Size

Total Lines 75
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 19.125

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 9
eloc 52
c 2
b 0
f 0
nc 43
nop 1
dl 0
loc 75
ccs 15
cts 30
cp 0.5
crap 19.125
rs 7.4917

How to fix   Long Method   

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 Throwable;
9
use Yiisoft\Arrays\ArrayHelper;
10
use Yiisoft\Db\Constraint\Constraint;
11
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
12
use Yiisoft\Db\Constraint\IndexConstraint;
13
use Yiisoft\Db\Exception\Exception;
14
use Yiisoft\Db\Exception\InvalidConfigException;
15
use Yiisoft\Db\Exception\NotSupportedException;
16
use Yiisoft\Db\Expression\Expression;
17
use Yiisoft\Db\Schema\ColumnSchemaInterface;
18
use Yiisoft\Db\Schema\Schema as AbstractSchema;
19
use Yiisoft\Db\Schema\TableSchemaInterface;
20
21
use function array_change_key_case;
22
use function array_map;
23
use function array_merge;
24
use function array_values;
25
use function bindec;
26
use function explode;
27
use function md5;
28
use function preg_match;
29
use function preg_match_all;
30
use function serialize;
31
use function str_replace;
32
use function stripos;
33
use function strtolower;
34
use function trim;
35
36
/**
37
 * The class Schema is the class for retrieving metadata from a Mysql database (version 5.7 and above).
38
 *
39
 * @psalm-type ColumnArray = array{
40
 *   table_schema: string,
41
 *   table_name: string,
42
 *   column_name: string,
43
 *   data_type: string,
44
 *   type_type: string|null,
45
 *   character_maximum_length: int,
46
 *   column_comment: string|null,
47
 *   modifier: int,
48
 *   is_nullable: bool,
49
 *   column_default: mixed,
50
 *   is_autoinc: bool,
51
 *   sequence_name: string|null,
52
 *   enum_values: array<array-key, float|int|string>|string|null,
53
 *   numeric_precision: int|null,
54
 *   numeric_scale: int|null,
55
 *   size: string|null,
56
 *   is_pkey: bool|null,
57
 *   dimension: int
58
 * }
59
 *
60
 * @psalm-type ColumnInfoArray = array{
61
 *   field: string,
62
 *   type: string,
63
 *   collation: string|null,
64
 *   null: string,
65
 *   key: string,
66
 *   default: string|null,
67
 *   extra: string,
68
 *   privileges: string,
69
 *   comment: string
70
 * }
71
 *
72
 * @psalm-type RowConstraint = array{
73
 *   constraint_name: string,
74
 *   column_name: string,
75
 *   referenced_table_name: string,
76
 *   referenced_column_name: string
77
 * }
78
 *
79
 * @psalm-type ConstraintArray = array<
80
 *   array-key,
81
 *   array {
82
 *     name: string,
83
 *     column_name: string,
84
 *     type: string,
85
 *     foreign_table_schema: string|null,
86
 *     foreign_table_name: string|null,
87
 *     foreign_column_name: string|null,
88
 *     on_update: string,
89
 *     on_delete: string,
90
 *     check_expr: string
91
 *   }
92
 * >
93
 */
94
final class Schema extends AbstractSchema
95
{
96
    /** @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...
97
    private array $typeMap = [
98
        'tinyint' => self::TYPE_TINYINT,
99
        'bit' => self::TYPE_INTEGER,
100
        'smallint' => self::TYPE_SMALLINT,
101
        'mediumint' => self::TYPE_INTEGER,
102
        'int' => self::TYPE_INTEGER,
103
        'integer' => self::TYPE_INTEGER,
104
        'bigint' => self::TYPE_BIGINT,
105
        'float' => self::TYPE_FLOAT,
106
        'double' => self::TYPE_DOUBLE,
107
        'real' => self::TYPE_FLOAT,
108
        'decimal' => self::TYPE_DECIMAL,
109
        'numeric' => self::TYPE_DECIMAL,
110
        'tinytext' => self::TYPE_TEXT,
111
        'mediumtext' => self::TYPE_TEXT,
112
        'longtext' => self::TYPE_TEXT,
113
        'longblob' => self::TYPE_BINARY,
114
        'blob' => self::TYPE_BINARY,
115
        'text' => self::TYPE_TEXT,
116
        'varchar' => self::TYPE_STRING,
117
        'string' => self::TYPE_STRING,
118
        'char' => self::TYPE_CHAR,
119
        'datetime' => self::TYPE_DATETIME,
120
        'year' => self::TYPE_DATE,
121
        'date' => self::TYPE_DATE,
122
        'time' => self::TYPE_TIME,
123
        'timestamp' => self::TYPE_TIMESTAMP,
124
        'enum' => self::TYPE_STRING,
125
        'varbinary' => self::TYPE_BINARY,
126
        'json' => self::TYPE_JSON,
127
    ];
128
129
    /**
130
     * Create a column schema builder instance giving the type and value precision.
131
     *
132
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
133
     *
134
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
135
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
136
     *
137
     * @return ColumnSchemaBuilder column schema builder instance
138
     *
139
     * @psalm-param string[]|int|string|null $length
140
     */
141 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
142
    {
143 3
        return new ColumnSchemaBuilder($type, $length, $this->db->getQuoter());
144
    }
145
146
    /**
147
     * Returns all unique indexes for the given table.
148
     *
149
     * Each array element is of the following structure:
150
     *
151
     * ```php
152
     * [
153
     *     'IndexName1' => ['col1' [, ...]],
154
     *     'IndexName2' => ['col2' [, ...]],
155
     * ]
156
     * ```
157
     *
158
     * @param TableSchemaInterface $table the table metadata.
159
     *
160
     * @throws Exception|InvalidConfigException|Throwable
161
     *
162
     * @return array all unique indexes for the given table.
163
     */
164 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
165
    {
166 1
        $sql = $this->getCreateTableSql($table);
167
168 1
        $uniqueIndexes = [];
169
170 1
        $regexp = '/UNIQUE KEY\s+`(.+)`\s*\((`.+`)+\)/mi';
171
172 1
        if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
173 1
            foreach ($matches as $match) {
174 1
                $indexName = $match[1];
175 1
                $indexColumns = array_map('trim', explode('`,`', trim($match[2], '`')));
176 1
                $uniqueIndexes[$indexName] = $indexColumns;
177
            }
178
        }
179
180 1
        return $uniqueIndexes;
181
    }
182
183
    /**
184
     * @inheritDoc
185
     */
186
    public function getLastInsertID(?string $sequenceName = null): string
187
    {
188
        return $this->db->getLastInsertID($sequenceName);
189
    }
190
191 5
    public function supportsSavepoint(): bool
192
    {
193 5
        return $this->db->isSavepointEnabled();
194
    }
195
196
    /**
197
     * Collects the metadata of table columns.
198
     *
199
     * @param TableSchemaInterface $table the table metadata.
200
     *
201
     * @throws Exception|Throwable if DB query fails.
202
     *
203
     * @return bool whether the table exists in the database.
204
     */
205 102
    protected function findColumns(TableSchemaInterface $table): bool
206
    {
207 102
        $tableName = $table->getFullName() ?? '';
208 102
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
209
210
        try {
211 102
            $columns = $this->db->createCommand($sql)->queryAll();
212 15
        } catch (Exception $e) {
213 15
            $previous = $e->getPrevious();
214
215 15
            if ($previous && str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
216
                /**
217
                 * table does not exist.
218
                 *
219
                 * https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error
220
                 */
221 15
                return false;
222
            }
223
224
            throw $e;
225
        }
226
227
        /** @psalm-var ColumnInfoArray $info */
228 96
        foreach ($columns as $info) {
229 96
            $info = $this->normalizeRowKeyCase($info, false);
230
231 96
            $column = $this->loadColumnSchema($info);
232 96
            $table->columns($column->getName(), $column);
233
234 96
            if ($column->isPrimaryKey()) {
235 61
                $table->primaryKey($column->getName());
236 61
                if ($column->isAutoIncrement()) {
237 59
                    $table->sequenceName('');
238
                }
239
            }
240
        }
241
242 96
        return true;
243
    }
244
245
    /**
246
     * Collects the foreign key column details for the given table.
247
     *
248
     * @param TableSchemaInterface $table the table metadata.
249
     *
250
     * @throws Exception|Throwable
251
     */
252 96
    protected function findConstraints(TableSchemaInterface $table): void
253
    {
254 96
        $sql = <<<SQL
255
        SELECT
256
            `kcu`.`CONSTRAINT_NAME` AS `constraint_name`,
257
            `kcu`.`COLUMN_NAME` AS `column_name`,
258
            `kcu`.`REFERENCED_TABLE_NAME` AS `referenced_table_name`,
259
            `kcu`.`REFERENCED_COLUMN_NAME` AS `referenced_column_name`
260
        FROM `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`
261
        JOIN `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` ON
262
            (
263
                `kcu`.`CONSTRAINT_CATALOG` = `rc`.`CONSTRAINT_CATALOG` OR
264
                (
265
                    `kcu`.`CONSTRAINT_CATALOG` IS NULL AND
266
                    `rc`.`CONSTRAINT_CATALOG` IS NULL
267
                )
268
            ) AND
269
            `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
270
            `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` AND
271
            `kcu`.`TABLE_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
272
            `kcu`.`TABLE_NAME` = `rc`.`TABLE_NAME`
273
        WHERE
274
            `rc`.`CONSTRAINT_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
275
            `rc`.`TABLE_NAME` = :tableName
276
        SQL;
277
278
        try {
279 96
            $rows = $this->db->createCommand($sql, [
280 96
                ':schemaName' => $table->getSchemaName(),
281 96
                ':tableName' => $table->getName(),
282 96
            ])->queryAll();
283
284 96
            $constraints = [];
285
286
            /**  @psalm-var RowConstraint $row */
287 96
            foreach ($rows as $row) {
288 26
                $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
289 26
                $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
290
            }
291
292 96
            $table->foreignKeys([]);
293
294
            /**
295
             * @var array{referenced_table_name: string, columns: array} $constraint
296
             */
297 96
            foreach ($constraints as $name => $constraint) {
298 26
                $table->foreignKey($name, array_merge(
299 26
                    [$constraint['referenced_table_name']],
300 26
                    $constraint['columns']
301
                ));
302
            }
303
        } catch (Exception $e) {
304
            $previous = $e->getPrevious();
305
306
            if ($previous === null || !str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
307
                throw $e;
308
            }
309
310
            // table does not exist, try to determine the foreign keys using the table creation sql
311
            $sql = $this->getCreateTableSql($table);
312
            $regexp = '/FOREIGN KEY\s+\(([^)]+)\)\s+REFERENCES\s+([^(^\s]+)\s*\(([^)]+)\)/mi';
313
314
            if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
315
                foreach ($matches as $match) {
316
                    $fks = array_map('trim', explode(',', str_replace('`', '', $match[1])));
317
                    $pks = array_map('trim', explode(',', str_replace('`', '', $match[3])));
318
                    $constraint = [str_replace('`', '', $match[2])];
319
320
                    foreach ($fks as $k => $name) {
321
                        $constraint[$name] = $pks[$k];
322
                    }
323
324
                    $table->foreignKey(md5(serialize($constraint)), $constraint);
325
                }
326
                $table->foreignKeys(array_values($table->getForeignKeys()));
327
            }
328
        }
329
    }
330
331
    /**
332
     * Returns all table names in the database.
333
     *
334
     * This method should be overridden by child classes in order to support this feature because the default
335
     * implementation simply throws an exception.
336
     *
337
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
338
     *
339
     * @throws Exception|InvalidConfigException|Throwable
340
     *
341
     * @return array All table names in the database. The names have NO schema name prefix.
342
     */
343 7
    protected function findTableNames(string $schema = ''): array
344
    {
345 7
        $sql = 'SHOW TABLES';
346
347 7
        if ($schema !== '') {
348
            $sql .= ' FROM ' . $this->db->getQuoter()->quoteSimpleTableName($schema);
349
        }
350
351 7
        return $this->db->createCommand($sql)->queryColumn();
352
    }
353
354
    /**
355
     * Returns the cache key for the specified table name.
356
     *
357
     * @param string $name the table name.
358
     *
359
     * @return array the cache key.
360
     */
361 171
    protected function getCacheKey(string $name): array
362
    {
363 171
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
364
    }
365
366
    /**
367
     * Returns the cache tag name.
368
     *
369
     * This allows {@see refresh()} to invalidate all cached table schemas.
370
     *
371
     * @return string the cache tag name.
372
     */
373 171
    protected function getCacheTag(): string
374
    {
375 171
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
376
    }
377
378
    /**
379
     * Gets the CREATE TABLE sql string.
380
     *
381
     * @param TableSchemaInterface $table the table metadata.
382
     *
383
     * @throws Exception|InvalidConfigException|Throwable
384
     *
385
     * @return string $sql the result of 'SHOW CREATE TABLE'.
386
     */
387 102
    protected function getCreateTableSql(TableSchemaInterface $table): string
388
    {
389 102
        $tableName = $table->getFullName() ?? '';
390
391
        try {
392
            /** @var array<array-key, string> $row */
393 102
            $row = $this->db->createCommand(
394 102
                'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
395 102
            )->queryOne();
396
397 96
            if (isset($row['Create Table'])) {
398 94
                $sql = $row['Create Table'];
399
            } else {
400 2
                $row = array_values($row);
0 ignored issues
show
Bug introduced by
$row of type null 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

400
                $row = array_values(/** @scrutinizer ignore-type */ $row);
Loading history...
401 96
                $sql = $row[1];
402
            }
403 15
        } catch (Exception) {
404 15
            $sql = '';
405
        }
406
407 102
        return $sql;
408
    }
409
410
    /**
411
     * Loads the column information into a {@see ColumnSchemaInterface} object.
412
     *
413
     * @param array $info column information.
414
     *
415
     * @throws JsonException
416
     *
417
     * @return ColumnSchemaInterface the column schema object.
418
     */
419 97
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
420
    {
421 97
        $column = $this->createColumnSchema();
422
423
        /** @psalm-var ColumnInfoArray $info */
424 97
        $column->name($info['field']);
425 97
        $column->allowNull($info['null'] === 'YES');
426 97
        $column->primaryKey(str_contains($info['key'], 'PRI'));
427 97
        $column->autoIncrement(stripos($info['extra'], 'auto_increment') !== false);
428 97
        $column->comment($info['comment']);
429 97
        $column->dbType($info['type']);
430 97
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
431 97
        $column->type(self::TYPE_STRING);
432
433 97
        $extra = $info['extra'];
434 97
        if (str_starts_with($extra, 'DEFAULT_GENERATED')) {
435 23
            $extra = strtoupper(substr($extra, 18));
436
        }
437 97
        $column->extra(trim($extra));
438
439 97
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
440 97
            $type = strtolower($matches[1]);
441
442 97
            if (isset($this->typeMap[$type])) {
443 97
                $column->type($this->typeMap[$type]);
444
            }
445
446 97
            if (!empty($matches[2])) {
447 89
                if ($type === 'enum') {
448 21
                    preg_match_all("/'[^']*'/", $matches[2], $values);
449
450 21
                    foreach ($values[0] as $i => $value) {
451 21
                        $values[$i] = trim($value, "'");
452
                    }
453
454 21
                    $column->enumValues($values);
455
                } else {
456 89
                    $values = explode(',', $matches[2]);
457 89
                    $column->precision((int) $values[0]);
458 89
                    $column->size((int) $values[0]);
459
460 89
                    if (isset($values[1])) {
461 33
                        $column->scale((int) $values[1]);
462
                    }
463
464 89
                    if ($column->getSize() === 1 && $type === 'tinyint') {
465 22
                        $column->type(self::TYPE_BOOLEAN);
466 89
                    } elseif ($type === 'bit') {
467 21
                        if ($column->getSize() > 32) {
468
                            $column->type(self::TYPE_BIGINT);
469 21
                        } elseif ($column->getSize() === 32) {
470
                            $column->type(self::TYPE_INTEGER);
471
                        }
472
                    }
473
                }
474
            }
475
        }
476
477 97
        $column->phpType($this->getColumnPhpType($column));
478
479 97
        if (!$column->isPrimaryKey()) {
480
            /**
481
             * When displayed in the INFORMATION_SCHEMA.COLUMNS table, a default CURRENT TIMESTAMP is displayed
482
             * as CURRENT_TIMESTAMP up until MariaDB 10.2.2, and as current_timestamp() from MariaDB 10.2.3.
483
             *
484
             * See details here: https://mariadb.com/kb/en/library/now/#description
485
             */
486
            if (
487 94
                ($column->getType() === 'timestamp' || $column->getType() === 'datetime')
488 94
                && preg_match('/^current_timestamp(?:\((\d*)\))?$/i', (string) $info['default'], $matches)
489
            ) {
490 24
                $column->defaultValue(new Expression('CURRENT_TIMESTAMP' . (!empty($matches[1])
491 24
                    ? '(' . $matches[1] . ')' : '')));
492 91
            } elseif (isset($type) && $type === 'bit') {
493 21
                $column->defaultValue(bindec(trim((string) $info['default'], 'b\'')));
494
            } else {
495 94
                $column->defaultValue($column->phpTypecast($info['default']));
496
            }
497 61
        } elseif ($info['default'] !== null) {
498 1
            $column->defaultValue($column->phpTypecast($info['default']));
499
        }
500
501 97
        return $column;
502
    }
503
504
    /**
505
     * Loads all check constraints for the given table.
506
     *
507
     * @param string $tableName table name.
508
     *
509
     * @throws NotSupportedException
510
     *
511
     * @return array check constraints for the given table.
512
     */
513 12
    protected function loadTableChecks(string $tableName): array
514
    {
515 12
        throw new NotSupportedException('MySQL does not support check constraints.');
516
    }
517
518
    /**
519
     * Loads multiple types of constraints and returns the specified ones.
520
     *
521
     * @param string $tableName table name.
522
     * @param string $returnType return type:
523
     * - primaryKey
524
     * - foreignKeys
525
     * - uniques
526
     *
527
     * @throws Exception|InvalidConfigException|Throwable
528
     *
529
     * @return array|Constraint|null (Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
530
     */
531 56
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
532
    {
533 56
        $sql = <<<SQL
534
        SELECT
535
            `kcu`.`CONSTRAINT_NAME` AS `name`,
536
            `kcu`.`COLUMN_NAME` AS `column_name`,
537
            `tc`.`CONSTRAINT_TYPE` AS `type`,
538
        CASE
539
            WHEN :schemaName IS NULL AND `kcu`.`REFERENCED_TABLE_SCHEMA` = DATABASE() THEN NULL
540
        ELSE `kcu`.`REFERENCED_TABLE_SCHEMA`
541
        END AS `foreign_table_schema`,
542
            `kcu`.`REFERENCED_TABLE_NAME` AS `foreign_table_name`,
543
            `kcu`.`REFERENCED_COLUMN_NAME` AS `foreign_column_name`,
544
            `rc`.`UPDATE_RULE` AS `on_update`,
545
            `rc`.`DELETE_RULE` AS `on_delete`,
546
            `kcu`.`ORDINAL_POSITION` AS `position`
547
        FROM `information_schema`.`KEY_COLUMN_USAGE` AS `kcu`
548
        JOIN `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc` ON
549
            `rc`.`CONSTRAINT_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
550
            `rc`.`TABLE_NAME` = `kcu`.`TABLE_NAME` AND
551
            `rc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME`
552
        JOIN `information_schema`.`TABLE_CONSTRAINTS` AS `tc` ON
553
            `tc`.`TABLE_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
554
            `tc`.`TABLE_NAME` = `kcu`.`TABLE_NAME` AND
555
            `tc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME` AND
556
            `tc`.`CONSTRAINT_TYPE` = 'FOREIGN KEY'
557
        WHERE
558
            `kcu`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
559
            `kcu`.`CONSTRAINT_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
560
            `kcu`.`TABLE_NAME` = :tableName
561
        UNION
562
        SELECT
563
            `kcu`.`CONSTRAINT_NAME` AS `name`,
564
            `kcu`.`COLUMN_NAME` AS `column_name`,
565
            `tc`.`CONSTRAINT_TYPE` AS `type`,
566
        NULL AS `foreign_table_schema`,
567
        NULL AS `foreign_table_name`,
568
        NULL AS `foreign_column_name`,
569
        NULL AS `on_update`,
570
        NULL AS `on_delete`,
571
            `kcu`.`ORDINAL_POSITION` AS `position`
572
        FROM `information_schema`.`KEY_COLUMN_USAGE` AS `kcu`
573
        JOIN `information_schema`.`TABLE_CONSTRAINTS` AS `tc` ON
574
            `tc`.`TABLE_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
575
            `tc`.`TABLE_NAME` = `kcu`.`TABLE_NAME` AND
576
            `tc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME` AND
577
            `tc`.`CONSTRAINT_TYPE` IN ('PRIMARY KEY', 'UNIQUE')
578
        WHERE
579
            `kcu`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
580
            `kcu`.`TABLE_NAME` = :tableName
581
        ORDER BY `position` ASC
582
        SQL;
583
584 56
        $resolvedName = $this->resolveTableName($tableName);
585
586 56
        $constraints = $this->db->createCommand($sql, [
587 56
            ':schemaName' => $resolvedName->getSchemaName(),
588 56
            ':tableName' => $resolvedName->getName(),
589 56
        ])->queryAll();
590
591
        /** @var array<array-key, array> $constraints */
592 56
        $constraints = $this->normalizeRowKeyCase($constraints, true);
593 56
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
594
595 56
        $result = [
596
            self::PRIMARY_KEY => null,
597 56
            self::FOREIGN_KEYS => [],
598 56
            self::UNIQUES => [],
599
        ];
600
601
        /**
602
         * @var string $type
603
         * @var array $names
604
         */
605 56
        foreach ($constraints as $type => $names) {
606
            /**
607
             * @psalm-var object|string|null $name
608
             * @psalm-var ConstraintArray $constraint
609
             */
610 51
            foreach ($names as $name => $constraint) {
611 51
                switch ($type) {
612 51
                    case 'PRIMARY KEY':
613 40
                        $result[self::PRIMARY_KEY] = (new Constraint())
614 40
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
615 40
                        break;
616 49
                    case 'FOREIGN KEY':
617 13
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
618 13
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
619 13
                            ->foreignTableName($constraint[0]['foreign_table_name'])
620 13
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
621 13
                            ->onDelete($constraint[0]['on_delete'])
622 13
                            ->onUpdate($constraint[0]['on_update'])
623 13
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
624 13
                            ->name($name);
625 13
                        break;
626 40
                    case 'UNIQUE':
627 40
                        $result[self::UNIQUES][] = (new Constraint())
628 40
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
629 40
                            ->name($name);
630 40
                        break;
631
                }
632
            }
633
        }
634
635 56
        foreach ($result as $type => $data) {
636 56
            $this->setTableMetadata($tableName, $type, $data);
637
        }
638
639 56
        return $result[$returnType];
640
    }
641
642
    /**
643
     * Loads all default value constraints for the given table.
644
     *
645
     * @param string $tableName table name.
646
     *
647
     * @throws NotSupportedException
648
     *
649
     * @return array default value constraints for the given table.
650
     */
651 12
    protected function loadTableDefaultValues(string $tableName): array
652
    {
653 12
        throw new NotSupportedException('MySQL does not support default value constraints.');
654
    }
655
656
    /**
657
     * Loads all foreign keys for the given table.
658
     *
659
     * @param string $tableName table name.
660
     *
661
     * @throws Exception|InvalidConfigException|Throwable
662
     *
663
     * @return array foreign keys for the given table.
664
     */
665 5
    protected function loadTableForeignKeys(string $tableName): array
666
    {
667 5
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
668
669 5
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
670
    }
671
672
    /**
673
     * Loads all indexes for the given table.
674
     *
675
     * @param string $tableName table name.
676
     *
677
     * @throws Exception|InvalidConfigException|Throwable
678
     *
679
     * @return IndexConstraint[] indexes for the given table.
680
     */
681 29
    protected function loadTableIndexes(string $tableName): array
682
    {
683 29
        $sql = <<<SQL
684
        SELECT
685
            `s`.`INDEX_NAME` AS `name`,
686
            `s`.`COLUMN_NAME` AS `column_name`,
687
            `s`.`NON_UNIQUE` ^ 1 AS `index_is_unique`,
688
            `s`.`INDEX_NAME` = 'PRIMARY' AS `index_is_primary`
689
        FROM `information_schema`.`STATISTICS` AS `s`
690
        WHERE
691
            `s`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
692
            `s`.`INDEX_SCHEMA` = `s`.`TABLE_SCHEMA` AND
693
            `s`.`TABLE_NAME` = :tableName
694
        ORDER BY `s`.`SEQ_IN_INDEX` ASC
695
        SQL;
696
697 29
        $resolvedName = $this->resolveTableName($tableName);
698
699 29
        $indexes = $this->db->createCommand($sql, [
700 29
            ':schemaName' => $resolvedName->getSchemaName(),
701 29
            ':tableName' => $resolvedName->getName(),
702 29
        ])->queryAll();
703
704
        /** @var array[] $indexes */
705 29
        $indexes = $this->normalizeRowKeyCase($indexes, true);
706 29
        $indexes = ArrayHelper::index($indexes, null, 'name');
707 29
        $result = [];
708
709
        /**
710
         * @psalm-var object|string|null $name
711
         * @psalm-var array[] $index
712
         */
713 29
        foreach ($indexes as $name => $index) {
714 29
            $ic = new IndexConstraint();
715
716 29
            $ic->primary((bool) $index[0]['index_is_primary']);
717 29
            $ic->unique((bool) $index[0]['index_is_unique']);
718 29
            $ic->name($name !== 'PRIMARY' ? $name : null);
719 29
            $ic->columnNames(ArrayHelper::getColumn($index, 'column_name'));
720
721 29
            $result[] = $ic;
722
        }
723
724 29
        return $result;
725
    }
726
727
    /**
728
     * Loads a primary key for the given table.
729
     *
730
     * @param string $tableName table name.
731
     *
732
     * @throws Exception|InvalidConfigException|Throwable
733
     *
734
     * @return Constraint|null primary key for the given table, `null` if the table has no primary key.*
735
     */
736 37
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
737
    {
738 37
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
739
740 37
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
741
    }
742
743
    /**
744
     * Loads the metadata for the specified table.
745
     *
746
     * @param string $name table name.
747
     *
748
     * @throws Exception|Throwable
749
     *
750
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table does not exist.
751
     */
752 102
    protected function loadTableSchema(string $name): ?TableSchemaInterface
753
    {
754 102
        $table = $this->resolveTableName($name);
755 102
        $this->resolveTableCreateSql($table);
756
757 102
        if ($this->findColumns($table)) {
758 96
            $this->findConstraints($table);
759
760 96
            return $table;
761
        }
762
763 15
        return null;
764
    }
765
766
    /**
767
     * Loads all unique constraints for the given table.
768
     *
769
     * @param string $tableName table name.
770
     *
771
     * @throws Exception|InvalidConfigException|Throwable
772
     *
773
     * @return array unique constraints for the given table.
774
     */
775 14
    protected function loadTableUniques(string $tableName): array
776
    {
777 14
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
778
779 14
        return is_array($tableUniques) ? $tableUniques : [];
780
    }
781
782
    /**
783
     * Changes row's array key case to lower.
784
     *
785
     * @param array $row row's array or an array of row's arrays.
786
     * @param bool $multiple whether multiple rows or a single row passed.
787
     *
788
     * @return array normalized row or rows.
789
     */
790 145
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
791
    {
792 145
        if ($multiple) {
793 67
            return array_map(static function (array $row) {
794 62
                return array_change_key_case($row, CASE_LOWER);
795
            }, $row);
796
        }
797
798 96
        return array_change_key_case($row, CASE_LOWER);
799
    }
800
801
    /**
802
     * Resolves the table name and schema name (if any).
803
     *
804
     * @param string $name the table name.
805
     *
806
     * @return TableSchemaInterface
807
     *
808
     * {@see TableSchemaInterface}
809
     */
810 147
    protected function resolveTableName(string $name): TableSchemaInterface
811
    {
812 147
        $resolvedName = new TableSchema();
813
814 147
        $parts = array_reverse(
815 147
            $this->db->getQuoter()->getTableNameParts($name)
816
        );
817
818 147
        $resolvedName->name($parts[0] ?? '');
819 147
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
820
821 147
        $resolvedName->fullName(
822 147
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
823 147
            implode('.', array_reverse($parts)) : $resolvedName->getName()
824
        );
825
826 147
        return $resolvedName;
827
    }
828
829
    /**
830
     * @throws Exception|InvalidConfigException|Throwable
831
     */
832 102
    protected function resolveTableCreateSql(TableSchemaInterface $table): void
833
    {
834 102
        $sql = $this->getCreateTableSql($table);
835 102
        $table->createSql($sql);
836
    }
837
838
    /**
839
     * Creates a column schema for the database.
840
     *
841
     * This method may be overridden by child classes to create a DBMS-specific column schema.
842
     *
843
     * @return ColumnSchema column schema instance.
844
     */
845 97
    private function createColumnSchema(): ColumnSchema
846
    {
847 97
        return new ColumnSchema();
848
    }
849
}
850