Passed
Branch master (589f31)
by Wilmer
04:41 queued 01:41
created

Schema::findConstraints()   B

Complexity

Conditions 9
Paths 43

Size

Total Lines 75
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 19.125

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 9
eloc 52
c 2
b 0
f 0
nc 43
nop 1
dl 0
loc 75
ccs 15
cts 30
cp 0.5
crap 19.125
rs 7.4917

How to fix   Long Method   

Long Method

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

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

Commonly applied refactorings include:

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

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