Passed
Push — dev ( c92037...8f8fc2 )
by Def
16:17 queued 13:40
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 381
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
131
    {
132 381
        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 164
    public function getRawTableName(string $name): string
208
    {
209 164
        if (str_contains($name, '{{')) {
210 23
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
211
212 23
            return str_replace('%', $this->db->getTablePrefix(), $name);
213
        }
214
215 164
        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 100
    protected function findColumns(TableSchemaInterface $table): bool
233
    {
234 100
        $tableName = $table->getFullName() ?? '';
235 100
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
236
237
        try {
238 100
            $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 94
        foreach ($columns as $info) {
256 94
            $info = $this->normalizeRowKeyCase($info, false);
257
258 94
            $column = $this->loadColumnSchema($info);
259 94
            $table->columns($column->getName(), $column);
260
261 94
            if ($column->isPrimaryKey()) {
262 60
                $table->primaryKey($column->getName());
263 60
                if ($column->isAutoIncrement()) {
264 58
                    $table->sequenceName('');
265
                }
266
            }
267
        }
268
269 94
        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 94
    protected function findConstraints(TableSchemaInterface $table): void
280
    {
281 94
        $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 94
            $rows = $this->db->createCommand($sql, [
307 94
                ':schemaName' => $table->getSchemaName(),
308 94
                ':tableName' => $table->getName(),
309 94
            ])->queryAll();
310
311 94
            $constraints = [];
312
313
            /**  @psalm-var RowConstraint $row */
314 94
            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 94
            $table->foreignKeys([]);
320
321
            /**
322
             * @var array{referenced_table_name: string, columns: array} $constraint
323
             */
324 94
            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 164
    protected function getCacheKey(string $name): array
394
    {
395 164
        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 164
    protected function getCacheTag(): string
406
    {
407 164
        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 100
    protected function getCreateTableSql(TableSchemaInterface $table): string
420
    {
421 100
        $tableName = $table->getFullName() ?? '';
422
423
        try {
424
            /** @var array<array-key, string> $row */
425 100
            $row = $this->db->createCommand(
426 100
                'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
427 100
            )->queryOne();
428
429 94
            if (isset($row['Create Table'])) {
430 92
                $sql = $row['Create Table'];
431
            } else {
432 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

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