Passed
Pull Request — master (#232)
by Def
16:25 queued 12:41
created

Schema::loadColumnSchema()   F

Complexity

Conditions 27
Paths 500

Size

Total Lines 99
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 57
CRAP Score 27.0037

Importance

Changes 5
Bugs 0 Features 1
Metric Value
cc 27
eloc 61
c 5
b 0
f 1
nc 500
nop 1
dl 0
loc 99
ccs 57
cts 58
cp 0.9828
crap 27.0037
rs 0.6944

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

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