Passed
Push — dev ( 1e6c50...6dcd8f )
by Def
05:36 queued 03:04
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\Cache\SchemaCache;
11
use Yiisoft\Db\Connection\ConnectionInterface;
12
use Yiisoft\Db\Constraint\Constraint;
13
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
14
use Yiisoft\Db\Constraint\IndexConstraint;
15
use Yiisoft\Db\Exception\Exception;
16
use Yiisoft\Db\Exception\InvalidConfigException;
17
use Yiisoft\Db\Exception\NotSupportedException;
18
use Yiisoft\Db\Expression\Expression;
19
use Yiisoft\Db\Schema\Schema as AbstractSchema;
20
use Yiisoft\Db\Schema\TableSchemaInterface;
21
22
use function array_change_key_case;
23
use function array_map;
24
use function array_merge;
25
use function array_values;
26
use function bindec;
27
use function explode;
28
use function md5;
29
use function preg_match;
30
use function preg_match_all;
31
use function serialize;
32
use function str_replace;
33
use function stripos;
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
96
{
97
    /** @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...
98
    private array $typeMap = [
99
        'tinyint' => self::TYPE_TINYINT,
100
        'bit' => self::TYPE_INTEGER,
101
        'smallint' => self::TYPE_SMALLINT,
102
        'mediumint' => self::TYPE_INTEGER,
103
        'int' => self::TYPE_INTEGER,
104
        'integer' => self::TYPE_INTEGER,
105
        'bigint' => self::TYPE_BIGINT,
106
        'float' => self::TYPE_FLOAT,
107
        'double' => self::TYPE_DOUBLE,
108
        'real' => self::TYPE_FLOAT,
109
        'decimal' => self::TYPE_DECIMAL,
110
        'numeric' => self::TYPE_DECIMAL,
111
        'tinytext' => self::TYPE_TEXT,
112
        'mediumtext' => self::TYPE_TEXT,
113
        'longtext' => self::TYPE_TEXT,
114
        'longblob' => self::TYPE_BINARY,
115
        'blob' => self::TYPE_BINARY,
116
        'text' => self::TYPE_TEXT,
117
        'varchar' => self::TYPE_STRING,
118
        'string' => self::TYPE_STRING,
119
        'char' => self::TYPE_CHAR,
120
        'datetime' => self::TYPE_DATETIME,
121
        'year' => self::TYPE_DATE,
122
        'date' => self::TYPE_DATE,
123
        'time' => self::TYPE_TIME,
124
        'timestamp' => self::TYPE_TIMESTAMP,
125
        'enum' => self::TYPE_STRING,
126
        'varbinary' => self::TYPE_BINARY,
127
        'json' => self::TYPE_JSON,
128
    ];
129
130 382
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
131
    {
132 382
        parent::__construct($schemaCache);
133
    }
134
135
    /**
136
     * Create a column schema builder instance giving the type and value precision.
137
     *
138
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
139
     *
140
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
141
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
142
     *
143
     * @return ColumnSchemaBuilder column schema builder instance
144
     *
145
     * @psalm-param string[]|int|string|null $length
146
     */
147 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
148
    {
149 3
        return new ColumnSchemaBuilder($type, $length, $this->db->getQuoter());
150
    }
151
152
    /**
153
     * Returns all unique indexes for the given table.
154
     *
155
     * Each array element is of the following structure:
156
     *
157
     * ```php
158
     * [
159
     *     'IndexName1' => ['col1' [, ...]],
160
     *     'IndexName2' => ['col2' [, ...]],
161
     * ]
162
     * ```
163
     *
164
     * @param TableSchemaInterface $table the table metadata.
165
     *
166
     * @throws Exception|InvalidConfigException|Throwable
167
     *
168
     * @return array all unique indexes for the given table.
169
     */
170 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
171
    {
172 1
        $sql = $this->getCreateTableSql($table);
173
174 1
        $uniqueIndexes = [];
175
176 1
        $regexp = '/UNIQUE KEY\s+`(.+)`\s*\((`.+`)+\)/mi';
177
178 1
        if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
179 1
            foreach ($matches as $match) {
180 1
                $indexName = $match[1];
181 1
                $indexColumns = array_map('trim', explode('`,`', trim($match[2], '`')));
182 1
                $uniqueIndexes[$indexName] = $indexColumns;
183
            }
184
        }
185
186 1
        return $uniqueIndexes;
187
    }
188
189
    /**
190
     * @inheritDoc
191
     */
192
    public function getLastInsertID(?string $sequenceName = null): string
193
    {
194
        return $this->db->getLastInsertID($sequenceName);
195
    }
196
197
    /**
198
     * Returns the actual name of a given table name.
199
     *
200
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
201
     * {@see ConnectionInterface::tablePrefix}.
202
     *
203
     * @param string $name the table name to be converted.
204
     *
205
     * @return string the real name of the given table name.
206
     */
207 162
    public function getRawTableName(string $name): string
208
    {
209 162
        if (str_contains($name, '{{')) {
210 23
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
211
212 23
            return str_replace('%', $this->db->getTablePrefix(), $name);
213
        }
214
215 162
        return $name;
216
    }
217
218 5
    public function supportsSavepoint(): bool
219
    {
220 5
        return $this->db->isSavepointEnabled();
221
    }
222
223
    /**
224
     * Collects the metadata of table columns.
225
     *
226
     * @param TableSchemaInterface $table the table metadata.
227
     *
228
     * @throws Exception|Throwable if DB query fails.
229
     *
230
     * @return bool whether the table exists in the database.
231
     */
232 98
    protected function findColumns(TableSchemaInterface $table): bool
233
    {
234 98
        $tableName = $table->getFullName() ?? '';
235 98
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
236
237
        try {
238 98
            $columns = $this->db->createCommand($sql)->queryAll();
239 15
        } catch (Exception $e) {
240 15
            $previous = $e->getPrevious();
241
242 15
            if ($previous && str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
243
                /**
244
                 * table does not exist.
245
                 *
246
                 * https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error
247
                 */
248 15
                return false;
249
            }
250
251
            throw $e;
252
        }
253
254
        /** @psalm-var ColumnInfoArray $info */
255 92
        foreach ($columns as $info) {
256 92
            $info = $this->normalizeRowKeyCase($info, false);
257
258 92
            $column = $this->loadColumnSchema($info);
259 92
            $table->columns($column->getName(), $column);
260
261 92
            if ($column->isPrimaryKey()) {
262 58
                $table->primaryKey($column->getName());
263 58
                if ($column->isAutoIncrement()) {
264 56
                    $table->sequenceName('');
265
                }
266
            }
267
        }
268
269 92
        return true;
270
    }
271
272
    /**
273
     * Collects the foreign key column details for the given table.
274
     *
275
     * @param TableSchemaInterface $table the table metadata.
276
     *
277
     * @throws Exception|Throwable
278
     */
279 92
    protected function findConstraints(TableSchemaInterface $table): void
280
    {
281 92
        $sql = <<<SQL
282
        SELECT
283
            `kcu`.`CONSTRAINT_NAME` AS `constraint_name`,
284
            `kcu`.`COLUMN_NAME` AS `column_name`,
285
            `kcu`.`REFERENCED_TABLE_NAME` AS `referenced_table_name`,
286
            `kcu`.`REFERENCED_COLUMN_NAME` AS `referenced_column_name`
287
        FROM `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`
288
        JOIN `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` ON
289
            (
290
                `kcu`.`CONSTRAINT_CATALOG` = `rc`.`CONSTRAINT_CATALOG` OR
291
                (
292
                    `kcu`.`CONSTRAINT_CATALOG` IS NULL AND
293
                    `rc`.`CONSTRAINT_CATALOG` IS NULL
294
                )
295
            ) AND
296
            `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
297
            `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` AND
298
            `kcu`.`TABLE_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
299
            `kcu`.`TABLE_NAME` = `rc`.`TABLE_NAME`
300
        WHERE
301
            `rc`.`CONSTRAINT_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
302
            `rc`.`TABLE_NAME` = :tableName
303
        SQL;
304
305
        try {
306 92
            $rows = $this->db->createCommand($sql, [
307 92
                ':schemaName' => $table->getSchemaName(),
308 92
                ':tableName' => $table->getName(),
309 92
            ])->queryAll();
310
311 92
            $constraints = [];
312
313
            /**  @psalm-var RowConstraint $row */
314 92
            foreach ($rows as $row) {
315 25
                $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
316 25
                $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
317
            }
318
319 92
            $table->foreignKeys([]);
320
321
            /**
322
             * @var array{referenced_table_name: string, columns: array} $constraint
323
             */
324 92
            foreach ($constraints as $name => $constraint) {
325 25
                $table->foreignKey($name, array_merge(
326 25
                    [$constraint['referenced_table_name']],
327 25
                    $constraint['columns']
328
                ));
329
            }
330
        } catch (Exception $e) {
331
            $previous = $e->getPrevious();
332
333
            if ($previous === null || !str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
334
                throw $e;
335
            }
336
337
            // table does not exist, try to determine the foreign keys using the table creation sql
338
            $sql = $this->getCreateTableSql($table);
339
            $regexp = '/FOREIGN KEY\s+\(([^)]+)\)\s+REFERENCES\s+([^(^\s]+)\s*\(([^)]+)\)/mi';
340
341
            if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
342
                foreach ($matches as $match) {
343
                    $fks = array_map('trim', explode(',', str_replace('`', '', $match[1])));
344
                    $pks = array_map('trim', explode(',', str_replace('`', '', $match[3])));
345
                    $constraint = [str_replace('`', '', $match[2])];
346
347
                    foreach ($fks as $k => $name) {
348
                        $constraint[$name] = $pks[$k];
349
                    }
350
351
                    $table->foreignKey(md5(serialize($constraint)), $constraint);
352
                }
353
                $table->foreignKeys(array_values($table->getForeignKeys()));
354
            }
355
        }
356
    }
357
358
    /**
359
     * Returns all table names in the database.
360
     *
361
     * This method should be overridden by child classes in order to support this feature because the default
362
     * implementation simply throws an exception.
363
     *
364
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
365
     *
366
     * @throws Exception|InvalidConfigException|Throwable
367
     *
368
     * @return array all table names in the database. The names have NO schema name prefix.
369
     */
370 7
    protected function findTableNames(string $schema = ''): array
371
    {
372 7
        $sql = 'SHOW TABLES';
373
374 7
        if ($schema !== '') {
375
            $sql .= ' FROM ' . $this->db->getQuoter()->quoteSimpleTableName($schema);
376
        }
377
378 7
        $tableNames = $this->db->createCommand($sql)->queryColumn();
379 7
        if (!$tableNames) {
380
            return [];
381
        }
382
383 7
        return $tableNames;
384
    }
385
386
    /**
387
     * Returns the cache key for the specified table name.
388
     *
389
     * @param string $name the table name.
390
     *
391
     * @return array the cache key.
392
     */
393 162
    protected function getCacheKey(string $name): array
394
    {
395 162
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
396
    }
397
398
    /**
399
     * Returns the cache tag name.
400
     *
401
     * This allows {@see refresh()} to invalidate all cached table schemas.
402
     *
403
     * @return string the cache tag name.
404
     */
405 162
    protected function getCacheTag(): string
406
    {
407 162
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
408
    }
409
410
    /**
411
     * Gets the CREATE TABLE sql string.
412
     *
413
     * @param TableSchemaInterface $table the table metadata.
414
     *
415
     * @throws Exception|InvalidConfigException|Throwable
416
     *
417
     * @return string $sql the result of 'SHOW CREATE TABLE'.
418
     */
419 1
    protected function getCreateTableSql(TableSchemaInterface $table): string
420
    {
421 1
        $tableName = $table->getFullName() ?? '';
422
423
        /** @var array<array-key, string> $row */
424 1
        $row = $this->db->createCommand(
425 1
            'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
426 1
        )->queryOne();
427
428 1
        if (isset($row['Create Table'])) {
429 1
            $sql = $row['Create Table'];
430
        } else {
431
            $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

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