Passed
Pull Request — dev (#109)
by Def
08:01 queued 05:24
created

Schema::loadColumnSchema()   D

Complexity

Conditions 19
Paths 100

Size

Total Lines 75
Code Lines 44

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 19.0389

Importance

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

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Mysql;
6
7
use JsonException;
8
use 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
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 375
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
130
    {
131 375
        parent::__construct($schemaCache);
132
    }
133
134
    /**
135
     * Create a column schema builder instance giving the type and value precision.
136
     *
137
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
138
     *
139
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
140
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
141
     *
142
     * @return ColumnSchemaBuilder column schema builder instance
143
     *
144
     * @psalm-param string[]|int|string|null $length
145
     */
146 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
147
    {
148 3
        return new ColumnSchemaBuilder($type, $length, $this->db->getQuoter());
149
    }
150
151
    /**
152
     * @throws Exception|InvalidConfigException|Throwable
153
     */
154 1
    public function createSavepoint(string $name): void
155
    {
156 1
        $this->db->createCommand("SAVEPOINT $name")->execute();
157
    }
158
159
    /**
160
     * Returns all unique indexes for the given table.
161
     *
162
     * Each array element is of the following structure:
163
     *
164
     * ```php
165
     * [
166
     *     'IndexName1' => ['col1' [, ...]],
167
     *     'IndexName2' => ['col2' [, ...]],
168
     * ]
169
     * ```
170
     *
171
     * @param TableSchema $table the table metadata.
172
     *
173
     * @throws Exception|InvalidConfigException|Throwable
174
     *
175
     * @return array all unique indexes for the given table.
176
     */
177 1
    public function findUniqueIndexes(TableSchema $table): array
178
    {
179 1
        $sql = $this->getCreateTableSql($table);
180
181 1
        $uniqueIndexes = [];
182
183 1
        $regexp = '/UNIQUE KEY\s+`(.+)`\s*\((`.+`)+\)/mi';
184
185 1
        if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
186 1
            foreach ($matches as $match) {
187 1
                $indexName = $match[1];
188 1
                $indexColumns = array_map('trim', explode('`,`', trim($match[2], '`')));
189 1
                $uniqueIndexes[$indexName] = $indexColumns;
190
            }
191
        }
192
193 1
        return $uniqueIndexes;
194
    }
195
196
    /**
197
     * @inheritDoc
198
     */
199
    public function getLastInsertID(?string $sequenceName = null): string
200
    {
201
        return $this->db->getLastInsertID($sequenceName);
202
    }
203
204
    /**
205
     * Returns the actual name of a given table name.
206
     *
207
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
208
     * {@see ConnectionInterface::tablePrefix}.
209
     *
210
     * @param string $name the table name to be converted.
211
     *
212
     * @return string the real name of the given table name.
213
     */
214 155
    public function getRawTableName(string $name): string
215
    {
216 155
        if (str_contains($name, '{{')) {
217 23
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
218
219 23
            return str_replace('%', $this->db->getTablePrefix(), $name);
220
        }
221
222 155
        return $name;
223
    }
224
225 1
    public function rollBackSavepoint(string $name): void
226
    {
227 1
        $this->db->createCommand("ROLLBACK TO SAVEPOINT $name")->execute();
228
    }
229
230
    /**
231
     * @throws Exception|InvalidConfigException|Throwable
232
     */
233
    public function releaseSavepoint(string $name): void
234
    {
235
        $this->db->createCommand("RELEASE SAVEPOINT $name")->execute();
236
    }
237
238 2
    public function setTransactionIsolationLevel(string $level): void
239
    {
240 2
        $this->db->createCommand("SET TRANSACTION ISOLATION LEVEL $level")->execute();
241
    }
242
243 2
    public function supportsSavepoint(): bool
244
    {
245 2
        return $this->db->isSavepointEnabled();
246
    }
247
248
    /**
249
     * Collects the metadata of table columns.
250
     *
251
     * @param TableSchema $table the table metadata.
252
     *
253
     * @throws Exception|Throwable if DB query fails.
254
     *
255
     * @return bool whether the table exists in the database.
256
     */
257 95
    protected function findColumns(TableSchema $table): bool
258
    {
259 95
        $tableName = $table->getFullName() ?? '';
260 95
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
261
262
        try {
263 95
            $columns = $this->db->createCommand($sql)->queryAll();
264 15
        } catch (Exception $e) {
265 15
            $previous = $e->getPrevious();
266
267 15
            if ($previous && str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
268
                /**
269
                 * table does not exist.
270
                 *
271
                 * https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error
272
                 */
273 15
                return false;
274
            }
275
276
            throw $e;
277
        }
278
279
        /** @psalm-var ColumnInfoArray $info */
280 89
        foreach ($columns as $info) {
281 89
            $info = $this->normalizeRowKeyCase($info, false);
282
283 89
            $column = $this->loadColumnSchema($info);
284 89
            $table->columns($column->getName(), $column);
285
286 89
            if ($column->isPrimaryKey()) {
287 55
                $table->primaryKey($column->getName());
288 55
                if ($column->isAutoIncrement()) {
289 53
                    $table->sequenceName('');
290
                }
291
            }
292
        }
293
294 89
        return true;
295
    }
296
297
    /**
298
     * Collects the foreign key column details for the given table.
299
     *
300
     * @param TableSchema $table the table metadata.
301
     *
302
     * @throws Exception|Throwable
303
     */
304 89
    protected function findConstraints(TableSchema $table): void
305
    {
306 89
        $sql = <<<SQL
307
        SELECT
308
            `kcu`.`CONSTRAINT_NAME` AS `constraint_name`,
309
            `kcu`.`COLUMN_NAME` AS `column_name`,
310
            `kcu`.`REFERENCED_TABLE_NAME` AS `referenced_table_name`,
311
            `kcu`.`REFERENCED_COLUMN_NAME` AS `referenced_column_name`
312
        FROM `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`
313
        JOIN `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` ON
314
            (
315
                `kcu`.`CONSTRAINT_CATALOG` = `rc`.`CONSTRAINT_CATALOG` OR
316
                (
317
                    `kcu`.`CONSTRAINT_CATALOG` IS NULL AND
318
                    `rc`.`CONSTRAINT_CATALOG` IS NULL
319
                )
320
            ) AND
321
            `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
322
            `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` AND
323
            `kcu`.`TABLE_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
324
            `kcu`.`TABLE_NAME` = `rc`.`TABLE_NAME`
325
        WHERE
326
            `rc`.`CONSTRAINT_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
327
            `rc`.`TABLE_NAME` = :tableName
328
        SQL;
329
330
        try {
331 89
            $rows = $this->db->createCommand($sql, [
332 89
                ':schemaName' => $table->getSchemaName(),
333 89
                ':tableName' => $table->getName(),
334 89
            ])->queryAll();
335
336 89
            $constraints = [];
337
338
            /**  @psalm-var RowConstraint $row */
339 89
            foreach ($rows as $row) {
340 25
                $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
341 25
                $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
342
            }
343
344 89
            $table->foreignKeys([]);
345
346
            /**
347
             * @var array{referenced_table_name: string, columns: array} $constraint
348
             */
349 89
            foreach ($constraints as $name => $constraint) {
350 25
                $table->foreignKey($name, array_merge(
351 25
                    [$constraint['referenced_table_name']],
352 25
                    $constraint['columns']
353
                ));
354
            }
355
        } catch (Exception $e) {
356
            $previous = $e->getPrevious();
357
358
            if ($previous === null || !str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
359
                throw $e;
360
            }
361
362
            // table does not exist, try to determine the foreign keys using the table creation sql
363
            $sql = $this->getCreateTableSql($table);
364
            $regexp = '/FOREIGN KEY\s+\(([^)]+)\)\s+REFERENCES\s+([^(^\s]+)\s*\(([^)]+)\)/mi';
365
366
            if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
367
                foreach ($matches as $match) {
368
                    $fks = array_map('trim', explode(',', str_replace('`', '', $match[1])));
369
                    $pks = array_map('trim', explode(',', str_replace('`', '', $match[3])));
370
                    $constraint = [str_replace('`', '', $match[2])];
371
372
                    foreach ($fks as $k => $name) {
373
                        $constraint[$name] = $pks[$k];
374
                    }
375
376
                    $table->foreignKey(md5(serialize($constraint)), $constraint);
377
                }
378
                $table->foreignKeys(array_values($table->getForeignKeys()));
379
            }
380
        }
381
    }
382
383
    /**
384
     * Returns all table names in the database.
385
     *
386
     * This method should be overridden by child classes in order to support this feature because the default
387
     * implementation simply throws an exception.
388
     *
389
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
390
     *
391
     * @throws Exception|InvalidConfigException|Throwable
392
     *
393
     * @return array all table names in the database. The names have NO schema name prefix.
394
     */
395 3
    protected function findTableNames(string $schema = ''): array
396
    {
397 3
        $sql = 'SHOW TABLES';
398
399 3
        if ($schema !== '') {
400
            $sql .= ' FROM ' . $this->db->getQuoter()->quoteSimpleTableName($schema);
401
        }
402
403 3
        $tableNames = $this->db->createCommand($sql)->queryColumn();
404 3
        if (!$tableNames) {
405
            return [];
406
        }
407
408 3
        return $tableNames;
409
    }
410
411
    /**
412
     * Returns the cache key for the specified table name.
413
     *
414
     * @param string $name the table name.
415
     *
416
     * @return array the cache key.
417
     */
418 155
    protected function getCacheKey(string $name): array
419
    {
420 155
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
421
    }
422
423
    /**
424
     * Returns the cache tag name.
425
     *
426
     * This allows {@see refresh()} to invalidate all cached table schemas.
427
     *
428
     * @return string the cache tag name.
429
     */
430 155
    protected function getCacheTag(): string
431
    {
432 155
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
433
    }
434
435
    /**
436
     * Gets the CREATE TABLE sql string.
437
     *
438
     * @param TableSchema $table the table metadata.
439
     *
440
     * @throws Exception|InvalidConfigException|Throwable
441
     *
442
     * @return string $sql the result of 'SHOW CREATE TABLE'.
443
     */
444 1
    protected function getCreateTableSql(TableSchema $table): string
445
    {
446 1
        $tableName = $table->getFullName() ?? '';
447
448
        /** @var array<array-key, string> $row */
449 1
        $row = $this->db->createCommand(
450 1
            'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
451 1
        )->queryOne();
452
453 1
        if (isset($row['Create Table'])) {
454 1
            $sql = $row['Create Table'];
455
        } else {
456
            $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

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