Passed
Pull Request — master (#232)
by Def
03:37
created

Schema::findColumns()   B

Complexity

Conditions 9
Paths 22

Size

Total Lines 58
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 11.2532

Importance

Changes 3
Bugs 0 Features 1
Metric Value
cc 9
eloc 31
c 3
b 0
f 1
nc 22
nop 1
dl 0
loc 58
ccs 23
cts 33
cp 0.6969
crap 11.2532
rs 8.0555

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

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