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

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