Passed
Pull Request — master (#205)
by Wilmer
03:21
created

Schema::loadColumnSchema()   F

Complexity

Conditions 26
Paths 750

Size

Total Lines 96
Code Lines 57

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 51
CRAP Score 26.1161

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 26
eloc 57
c 3
b 0
f 0
nc 750
nop 1
dl 0
loc 96
ccs 51
cts 54
cp 0.9444
crap 26.1161
rs 0.3472

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

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