Test Failed
Pull Request — master (#180)
by Wilmer
11:02 queued 07:59
created

Schema::loadColumnSchema()   F

Complexity

Conditions 21
Paths 250

Size

Total Lines 83
Code Lines 50

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 21.034

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 21
eloc 50
c 1
b 0
f 0
nc 250
nop 1
dl 0
loc 83
ccs 45
cts 47
cp 0.9574
crap 21.034
rs 2.7083

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Mysql;
6
7
use JsonException;
8
use Throwable;
9
use Yiisoft\Arrays\ArrayHelper;
10
use Yiisoft\Db\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 ksort;
27
use function md5;
28
use function preg_match;
29
use function preg_match_all;
30
use function serialize;
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 10
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
141
    {
142 10
        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
        ksort($uniqueIndexes);
182
183
        return $uniqueIndexes;
184
    }
185
186
    /**
187
     * Collects the metadata of table columns.
188
     *
189
     * @param TableSchemaInterface $table the table metadata.
190
     *
191
     * @throws Exception
192
     * @throws Throwable if DB query fails.
193
     *
194 140
     * @return bool whether the table exists in the database.
195
     */
196 140
    protected function findColumns(TableSchemaInterface $table): bool
197 140
    {
198
        $tableName = $table->getFullName() ?? '';
199
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
200 140
201 25
        try {
202 25
            $columns = $this->db->createCommand($sql)->queryAll();
203
        } catch (Exception $e) {
204 25
            $previous = $e->getPrevious();
205
206
            if ($previous && str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
207
                /**
208
                 * table does not exist.
209
                 *
210 25
                 * https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error
211
                 */
212
                return false;
213
            }
214
215
            throw $e;
216
        }
217 125
218 125
        /** @psalm-var ColumnInfoArray $info */
219
        foreach ($columns as $info) {
220 125
            $info = $this->normalizeRowKeyCase($info, false);
221 125
222
            $column = $this->loadColumnSchema($info);
223 125
            $table->columns($column->getName(), $column);
224 81
225 81
            if ($column->isPrimaryKey()) {
226 69
                $table->primaryKey($column->getName());
227
                if ($column->isAutoIncrement()) {
228
                    $table->sequenceName('');
229
                }
230
            }
231 125
        }
232
233
        return true;
234
    }
235
236
    /**
237
     * Collects the foreign key column details for the given table.
238
     *
239
     * @param TableSchemaInterface $table the table metadata.
240
     */
241
    protected function findConstraints(TableSchemaInterface $table): void
242 125
    {
243
        $sql = <<<SQL
244 125
        SELECT
245
            `kcu`.`CONSTRAINT_NAME` AS `constraint_name`,
246
            `kcu`.`COLUMN_NAME` AS `column_name`,
247
            `kcu`.`REFERENCED_TABLE_NAME` AS `referenced_table_name`,
248
            `kcu`.`REFERENCED_COLUMN_NAME` AS `referenced_column_name`
249
        FROM `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`
250
        JOIN `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` ON
251
            (
252
                `kcu`.`CONSTRAINT_CATALOG` = `rc`.`CONSTRAINT_CATALOG` OR
253
                (
254
                    `kcu`.`CONSTRAINT_CATALOG` IS NULL AND
255
                    `rc`.`CONSTRAINT_CATALOG` IS NULL
256
                )
257
            ) AND
258
            `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
259
            `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` AND
260
            `kcu`.`TABLE_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
261
            `kcu`.`TABLE_NAME` = `rc`.`TABLE_NAME`
262
        WHERE `rc`.`CONSTRAINT_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND `rc`.`TABLE_NAME` = :tableName
263
        SQL;
264
265
        $constraints = [];
266
        $rows = $this->db->createCommand($sql, [
267
            ':schemaName' => $table->getSchemaName(),
268
            ':tableName' => $table->getName(),
269 125
        ])->queryAll();
270 125
271 125
        /**  @psalm-var RowConstraint $row */
272 125
        foreach ($rows as $row) {
273
            $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
274 125
            $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
275
        }
276
277 125
        $table->foreignKeys([]);
278 40
279 40
        /**
280
         * @var array{referenced_table_name: string, columns: array} $constraint
281
         */
282 125
        foreach ($constraints as $name => $constraint) {
283
            $table->foreignKey(
284
                $name,
285
                array_merge(
286
                    [$constraint['referenced_table_name']],
287 125
                    $constraint['columns']
288 40
                ),
289 40
            );
290 40
        }
291
    }
292
293
    protected function findSchemaNames(): array
294
    {
295
        $sql = <<<SQL
296
        SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys')
297
        SQL;
298
299
        return $this->db->createCommand($sql)->queryColumn();
300
    }
301
302
    protected function findTableComment(TableSchemaInterface $tableSchema): void
303
    {
304
        $sql = <<<SQL
305
        SELECT `TABLE_COMMENT`
306
        FROM `INFORMATION_SCHEMA`.`TABLES`
307
        WHERE
308
              `TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
309
              `TABLE_NAME` = :tableName;
310
        SQL;
311
312
        $comment = $this->db->createCommand($sql, [
313
            ':schemaName' => $tableSchema->getSchemaName(),
314
            ':tableName' => $tableSchema->getName(),
315
        ])->queryScalar();
316
317
        $tableSchema->comment(is_string($comment) ? $comment : null);
318
    }
319
320
    /**
321 140
     * Returns all table names in the database.
322
     *
323 140
     * This method should be overridden by child classes in order to support this feature because the default
324
     * implementation simply throws an exception.
325
     *
326
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
327
     *
328
     * @throws Exception
329
     * @throws InvalidConfigException
330
     * @throws Throwable
331 140
     *
332 140
     * @return array All table names in the database. The names have NO schema name prefix.
333 140
     */
334 140
    protected function findTableNames(string $schema = ''): array
335
    {
336 140
        $sql = 'SHOW TABLES';
337
338
        if ($schema !== '') {
339
            $sql .= ' FROM ' . $this->db->getQuoter()->quoteSimpleTableName($schema);
340
        }
341
342
        return $this->db->createCommand($sql)->queryColumn();
343
    }
344
345
    protected function findViewNames(string $schema = ''): array
346
    {
347
        $sql = match ($schema) {
348
            '' => <<<SQL
349
            SELECT table_name as view FROM information_schema.tables WHERE table_type LIKE 'VIEW' AND table_schema != 'sys'
350
            SQL,
351
            default => <<<SQL
352
            SELECT table_name as view FROM information_schema.tables WHERE table_type LIKE 'VIEW' AND table_schema = '$schema'
353 7
            SQL,
354
        };
355 7
356
        /** @psalm-var string[][] $views */
357 7
        $views = $this->db->createCommand($sql)->queryAll();
358
359
        foreach ($views as $key => $view) {
360
            $views[$key] = $view['view'];
361 7
        }
362
363
        return $views;
364
    }
365
366
    /**
367
     * Returns the cache key for the specified table name.
368
     *
369
     * @param string $name the table name.
370
     *
371 209
     * @return array the cache key.
372
     */
373 209
    protected function getCacheKey(string $name): array
374
    {
375
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
376
    }
377
378
    /**
379
     * Returns the cache tag name.
380
     *
381
     * This allows {@see refresh()} to invalidate all cached table schemas.
382
     *
383 209
     * @return string the cache tag name.
384
     */
385 209
    protected function getCacheTag(): string
386
    {
387
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
388
    }
389
390
    /**
391
     * Gets the CREATE TABLE sql string.
392
     *
393
     * @param TableSchemaInterface $table the table metadata.
394
     *
395
     * @throws Exception
396
     * @throws InvalidConfigException
397
     * @throws Throwable
398
     *
399 140
     * @return string $sql the result of 'SHOW CREATE TABLE'.
400
     */
401 140
    protected function getCreateTableSql(TableSchemaInterface $table): string
402
    {
403
        $tableName = $table->getFullName() ?? '';
404
405 140
        try {
406 140
            /** @var array<array-key, string> $row */
407 140
            $row = $this->db->createCommand(
408
                'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
409 125
            )->queryOne();
410 123
411
            if (isset($row['Create Table'])) {
412 2
                $sql = $row['Create Table'];
413 125
            } else {
414
                $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

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