Passed
Branch master (f6686f)
by Wilmer
24:11 queued 21:04
created

Schema::findConstraints()   B

Complexity

Conditions 9
Paths 43

Size

Total Lines 75
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 19.125

Importance

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

How to fix   Long Method   

Long Method

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

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

Commonly applied refactorings include:

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

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