Passed
Pull Request — master (#154)
by Alexander
06:14 queued 02:55
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 104
    protected function findColumns(TableSchemaInterface $table): bool
206
    {
207 104
        $tableName = $table->getFullName() ?? '';
208 104
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
209
210
        try {
211 104
            $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 98
        foreach ($columns as $info) {
229 98
            $info = $this->normalizeRowKeyCase($info, false);
230
231 98
            $column = $this->loadColumnSchema($info);
232 98
            $table->columns($column->getName(), $column);
233
234 98
            if ($column->isPrimaryKey()) {
235 62
                $table->primaryKey($column->getName());
236 62
                if ($column->isAutoIncrement()) {
237 60
                    $table->sequenceName('');
238
                }
239
            }
240
        }
241
242 98
        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 98
    protected function findConstraints(TableSchemaInterface $table): void
253
    {
254 98
        $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 98
            $rows = $this->db->createCommand($sql, [
280 98
                ':schemaName' => $table->getSchemaName(),
281 98
                ':tableName' => $table->getName(),
282 98
            ])->queryAll();
283
284 98
            $constraints = [];
285
286
            /**  @psalm-var RowConstraint $row */
287 98
            foreach ($rows as $row) {
288 27
                $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
289 27
                $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
290
            }
291
292 98
            $table->foreignKeys([]);
293
294
            /**
295
             * @var array{referenced_table_name: string, columns: array} $constraint
296
             */
297 98
            foreach ($constraints as $name => $constraint) {
298 27
                $table->foreignKey($name, array_merge(
299 27
                    [$constraint['referenced_table_name']],
300 27
                    $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 173
    protected function getCacheKey(string $name): array
362
    {
363 173
        return array_merge([self::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 173
    protected function getCacheTag(): string
374
    {
375 173
        return md5(serialize(array_merge([self::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 104
    protected function getCreateTableSql(TableSchemaInterface $table): string
388
    {
389 104
        $tableName = $table->getFullName() ?? '';
390
391
        try {
392
            /** @var array<array-key, string> $row */
393 104
            $row = $this->db->createCommand(
394 104
                'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
395 104
            )->queryOne();
396
397 98
            if (isset($row['Create Table'])) {
398 96
                $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 98
                $sql = $row[1];
402
            }
403 15
        } catch (Exception) {
404 15
            $sql = '';
405
        }
406
407 104
        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 99
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
420
    {
421 99
        $column = $this->createColumnSchema();
422
423
        /** @psalm-var ColumnInfoArray $info */
424 99
        $column->name($info['field']);
425 99
        $column->allowNull($info['null'] === 'YES');
426 99
        $column->primaryKey(str_contains($info['key'], 'PRI'));
427 99
        $column->autoIncrement(stripos($info['extra'], 'auto_increment') !== false);
428 99
        $column->comment($info['comment']);
429 99
        $column->dbType($info['type']);
430 99
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
431 99
        $column->type(self::TYPE_STRING);
432
433 99
        $extra = $info['extra'];
434 99
        if (str_starts_with($extra, 'DEFAULT_GENERATED')) {
435 24
            $extra = strtoupper(substr($extra, 18));
436
        }
437 99
        $column->extra(trim($extra));
438
439 99
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
440 99
            $type = strtolower($matches[1]);
441
442 99
            if (isset($this->typeMap[$type])) {
443 99
                $column->type($this->typeMap[$type]);
444
            }
445
446 99
            if (!empty($matches[2])) {
447 91
                if ($type === 'enum') {
448 22
                    preg_match_all("/'[^']*'/", $matches[2], $values);
449
450 22
                    foreach ($values[0] as $i => $value) {
451 22
                        $values[$i] = trim($value, "'");
452
                    }
453
454 22
                    $column->enumValues($values);
455
                } else {
456 91
                    $values = explode(',', $matches[2]);
457 91
                    $column->precision((int) $values[0]);
458 91
                    $column->size((int) $values[0]);
459
460 91
                    if (isset($values[1])) {
461 34
                        $column->scale((int) $values[1]);
462
                    }
463
464 91
                    if ($column->getSize() === 1 && $type === 'tinyint') {
465 23
                        $column->type(self::TYPE_BOOLEAN);
466 91
                    } elseif ($type === 'bit') {
467 22
                        if ($column->getSize() > 32) {
468
                            $column->type(self::TYPE_BIGINT);
469 22
                        } elseif ($column->getSize() === 32) {
470
                            $column->type(self::TYPE_INTEGER);
471
                        }
472
                    }
473
                }
474
            }
475
        }
476
477 99
        $column->phpType($this->getColumnPhpType($column));
478
479 99
        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 96
                ($column->getType() === 'timestamp' || $column->getType() === 'datetime')
488 96
                && preg_match('/^current_timestamp(?:\((\d*)\))?$/i', (string) $info['default'], $matches)
489
            ) {
490 25
                $column->defaultValue(new Expression('CURRENT_TIMESTAMP' . (!empty($matches[1])
491 25
                    ? '(' . $matches[1] . ')' : '')));
492 93
            } elseif (isset($type) && $type === 'bit') {
493 22
                $column->defaultValue(bindec(trim((string) $info['default'], 'b\'')));
494
            } else {
495 96
                $column->defaultValue($column->phpTypecast($info['default']));
496
            }
497 62
        } elseif ($info['default'] !== null) {
498 1
            $column->defaultValue($column->phpTypecast($info['default']));
499
        }
500
501 99
        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 104
    protected function loadTableSchema(string $name): ?TableSchemaInterface
753
    {
754 104
        $table = $this->resolveTableName($name);
755 104
        $this->resolveTableCreateSql($table);
756
757 104
        if ($this->findColumns($table)) {
758 98
            $this->findConstraints($table);
759
760 98
            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 147
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
791
    {
792 147
        if ($multiple) {
793 67
            return array_map(static fn (array $row) => array_change_key_case($row, CASE_LOWER), $row);
794
        }
795
796 98
        return array_change_key_case($row, CASE_LOWER);
797
    }
798
799
    /**
800
     * Resolves the table name and schema name (if any).
801
     *
802
     * @param string $name the table name.
803
     *
804
     * {@see TableSchemaInterface}
805
     */
806 149
    protected function resolveTableName(string $name): TableSchemaInterface
807
    {
808 149
        $resolvedName = new TableSchema();
809
810 149
        $parts = array_reverse(
811 149
            $this->db->getQuoter()->getTableNameParts($name)
812
        );
813
814 149
        $resolvedName->name($parts[0] ?? '');
815 149
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
816
817 149
        $resolvedName->fullName(
818 149
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
819 149
            implode('.', array_reverse($parts)) : $resolvedName->getName()
820
        );
821
822 149
        return $resolvedName;
823
    }
824
825
    /**
826
     * @throws Exception|InvalidConfigException|Throwable
827
     */
828 104
    protected function resolveTableCreateSql(TableSchemaInterface $table): void
829
    {
830 104
        $sql = $this->getCreateTableSql($table);
831 104
        $table->createSql($sql);
832
    }
833
834
    /**
835
     * Creates a column schema for the database.
836
     *
837
     * This method may be overridden by child classes to create a DBMS-specific column schema.
838
     *
839
     * @return ColumnSchema column schema instance.
840
     */
841 99
    private function createColumnSchema(): ColumnSchema
842
    {
843 99
        return new ColumnSchema();
844
    }
845
}
846