Issues (10)

src/Schema.php (2 issues)

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\Driver\Pdo\AbstractPdoSchema;
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\Helper\DbArrayHelper;
18
use Yiisoft\Db\Schema\Builder\ColumnInterface;
19
use Yiisoft\Db\Schema\ColumnSchemaInterface;
20
use Yiisoft\Db\Schema\TableSchemaInterface;
21
22
use function array_change_key_case;
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
 * Implements MySQL, MariaDB specific schema, supporting MySQL Server 5.7, MariaDB Server 10.4 and higher.
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
 * @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
 *   extra_default_value: string|null,
71
 *   privileges: string,
72
 *   comment: string
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
 * @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 AbstractPdoSchema
96
{
97
    /**
98
     * Mapping from physical column types (keys) to abstract column types (values).
99
     *
100
     * @var string[]
101
     */
102
    private const TYPE_MAP = [
103
        'tinyint' => self::TYPE_TINYINT,
104
        'bit' => self::TYPE_INTEGER,
105
        'smallint' => self::TYPE_SMALLINT,
106
        'mediumint' => self::TYPE_INTEGER,
107
        'int' => self::TYPE_INTEGER,
108
        'integer' => self::TYPE_INTEGER,
109
        'bigint' => self::TYPE_BIGINT,
110
        'float' => self::TYPE_FLOAT,
111
        'double' => self::TYPE_DOUBLE,
112
        'real' => self::TYPE_FLOAT,
113
        'decimal' => self::TYPE_DECIMAL,
114
        'numeric' => self::TYPE_DECIMAL,
115
        'tinytext' => self::TYPE_TEXT,
116
        'mediumtext' => self::TYPE_TEXT,
117
        'longtext' => self::TYPE_TEXT,
118
        'longblob' => self::TYPE_BINARY,
119
        'blob' => self::TYPE_BINARY,
120
        'text' => self::TYPE_TEXT,
121
        'varchar' => self::TYPE_STRING,
122
        'string' => self::TYPE_STRING,
123
        'char' => self::TYPE_CHAR,
124
        'datetime' => self::TYPE_DATETIME,
125
        'year' => self::TYPE_DATE,
126
        'date' => self::TYPE_DATE,
127
        'time' => self::TYPE_TIME,
128
        'timestamp' => self::TYPE_TIMESTAMP,
129
        'enum' => self::TYPE_STRING,
130
        'varbinary' => self::TYPE_BINARY,
131
        'json' => self::TYPE_JSON,
132
    ];
133
134 16
    public function createColumn(string $type, array|int|string $length = null): ColumnInterface
135
    {
136 16
        return new Column($type, $length);
137
    }
138
139
    /**
140
     * Returns all unique indexes for the given table.
141
     *
142
     * Each array element is of the following structure:
143
     *
144
     * ```php
145
     * [
146
     *     'IndexName1' => ['col1' [, ...]],
147
     *     'IndexName2' => ['col2' [, ...]],
148
     * ]
149
     * ```
150
     *
151
     * @param TableSchemaInterface $table The table metadata.
152
     *
153
     * @throws Exception
154
     * @throws InvalidConfigException
155
     * @throws Throwable
156
     *
157
     * @return array All unique indexes for the given table.
158
     */
159 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
160
    {
161 1
        $sql = $this->getCreateTableSql($table);
162 1
        $uniqueIndexes = [];
163 1
        $regexp = '/UNIQUE KEY\s+[`"](.+)[`"]\s*\(([`"].+[`"])+\)/mi';
164
165 1
        if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER) > 0) {
166 1
            foreach ($matches as $match) {
167 1
                $indexName = $match[1];
168 1
                $indexColumns = array_map('trim', preg_split('/[`"],[`"]/', trim($match[2], '`"')));
169 1
                $uniqueIndexes[$indexName] = $indexColumns;
170
            }
171
        }
172
173 1
        ksort($uniqueIndexes);
174
175 1
        return $uniqueIndexes;
176
    }
177
178
    /**
179
     * Collects the metadata of table columns.
180
     *
181
     * @param TableSchemaInterface $table The table metadata.
182
     *
183
     * @throws Exception
184
     * @throws Throwable If DB query fails.
185
     *
186
     * @return bool Whether the table exists in the database.
187
     */
188 178
    protected function findColumns(TableSchemaInterface $table): bool
189
    {
190 178
        $tableName = $table->getFullName() ?? '';
191 178
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
192
193
        try {
194 178
            $columns = $this->db->createCommand($sql)->queryAll();
195
            // Chapter 1: crutches for MariaDB. {@see https://github.com/yiisoft/yii2/issues/19747}
196 160
            $columnsExtra = [];
197 160
            if (str_contains($this->db->getServerVersion(), 'MariaDB')) {
198
                /** @psalm-var array[] $columnsExtra */
199
                $columnsExtra = $this->db->createCommand(
200
                    <<<SQL
201
                    SELECT `COLUMN_NAME` as name,`COLUMN_DEFAULT` as default_value
202
                    FROM INFORMATION_SCHEMA.COLUMNS
203
                    WHERE TABLE_SCHEMA = COALESCE(:schemaName, DATABASE()) AND TABLE_NAME = :tableName
204
                    SQL ,
205
                    [
206
                        ':schemaName' => $table->getSchemaName(),
207
                        ':tableName' => $table->getName(),
208
                    ]
209
                )->queryAll();
210
                /** @psalm-var string[] $cols */
211 160
                foreach ($columnsExtra as $cols) {
212
                    $columnsExtra[$cols['name']] = $cols['default_value'];
213
                }
214
            }
215 37
        } catch (Exception $e) {
216 37
            $previous = $e->getPrevious();
217
218 37
            if ($previous && str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
219
                /**
220
                 * The table doesn't exist.
221
                 *
222
                 * @link https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error
223
                 */
224 37
                return false;
225
            }
226
227
            throw $e;
228
        }
229
230 160
        $jsonColumns = $this->getJsonColumns($table);
231
232
        /** @psalm-var ColumnInfoArray $info */
233 160
        foreach ($columns as $info) {
234 160
            $info = array_change_key_case($info);
235
236 160
            $info['extra_default_value'] = $columnsExtra[(string) $info['field']] ?? '';
237
238 160
            if (in_array($info['field'], $jsonColumns, true)) {
239
                $info['type'] = self::TYPE_JSON;
240
            }
241
242
            /** @psalm-var ColumnInfoArray $info */
243 160
            $column = $this->loadColumnSchema($info);
244 160
            $table->column($column->getName(), $column);
245
246 160
            if ($column->isPrimaryKey()) {
247 93
                $table->primaryKey($column->getName());
248 93
                if ($column->isAutoIncrement()) {
249 74
                    $table->sequenceName('');
250
                }
251
            }
252
        }
253
254 160
        return true;
255
    }
256
257
    /**
258
     * Collects the foreign key column details for the given table.
259
     *
260
     * @param TableSchemaInterface $table The table metadata.
261
     *
262
     * @throws Exception
263
     * @throws InvalidConfigException
264
     * @throws Throwable
265
     */
266 160
    protected function findConstraints(TableSchemaInterface $table): void
267
    {
268 160
        $sql = <<<SQL
269
        SELECT
270
            `kcu`.`CONSTRAINT_NAME` AS `constraint_name`,
271
            `kcu`.`COLUMN_NAME` AS `column_name`,
272
            `kcu`.`REFERENCED_TABLE_NAME` AS `referenced_table_name`,
273
            `kcu`.`REFERENCED_COLUMN_NAME` AS `referenced_column_name`
274
        FROM `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`
275
        JOIN `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` ON
276
            (
277
                `kcu`.`CONSTRAINT_CATALOG` = `rc`.`CONSTRAINT_CATALOG` OR
278
                (
279
                    `kcu`.`CONSTRAINT_CATALOG` IS NULL AND
280
                    `rc`.`CONSTRAINT_CATALOG` IS NULL
281
                )
282
            ) AND
283
            `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
284
            `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` AND
285
            `kcu`.`TABLE_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
286
            `kcu`.`TABLE_NAME` = `rc`.`TABLE_NAME`
287
        WHERE `rc`.`CONSTRAINT_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND `rc`.`TABLE_NAME` = :tableName
288 160
        SQL;
289
290 160
        $constraints = [];
291 160
        $rows = $this->db->createCommand($sql, [
292 160
            ':schemaName' => $table->getSchemaName(),
293 160
            ':tableName' => $table->getName(),
294 160
        ])->queryAll();
295
296
        /**  @psalm-var RowConstraint $row */
297 160
        foreach ($rows as $row) {
298 38
            $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
299 38
            $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
300
        }
301
302 160
        $table->foreignKeys([]);
303
304
        /**
305
         * @psalm-var array{referenced_table_name: string, columns: array} $constraint
306
         */
307 160
        foreach ($constraints as $name => $constraint) {
308 38
            $table->foreignKey(
309 38
                $name,
310 38
                array_merge(
311 38
                    [$constraint['referenced_table_name']],
312 38
                    $constraint['columns']
313 38
                ),
314 38
            );
315
        }
316
    }
317
318
    /**
319
     * @throws Exception
320
     * @throws InvalidConfigException
321
     * @throws Throwable
322
     */
323 1
    protected function findSchemaNames(): array
324
    {
325 1
        $sql = <<<SQL
326
        SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys')
327 1
        SQL;
328
329 1
        return $this->db->createCommand($sql)->queryColumn();
330
    }
331
332
    /**
333
     * @throws Exception
334
     * @throws InvalidConfigException
335
     * @throws Throwable
336
     */
337 178
    protected function findTableComment(TableSchemaInterface $tableSchema): void
338
    {
339 178
        $sql = <<<SQL
340
        SELECT `TABLE_COMMENT`
341
        FROM `INFORMATION_SCHEMA`.`TABLES`
342
        WHERE
343
              `TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
344
              `TABLE_NAME` = :tableName;
345 178
        SQL;
346
347 178
        $comment = $this->db->createCommand($sql, [
348 178
            ':schemaName' => $tableSchema->getSchemaName(),
349 178
            ':tableName' => $tableSchema->getName(),
350 178
        ])->queryScalar();
351
352 178
        $tableSchema->comment(is_string($comment) ? $comment : null);
353
    }
354
355
    /**
356
     * Returns all table names in the database.
357
     *
358
     * This method should be overridden by child classes to support this feature because the default implementation
359
     * simply throws an exception.
360
     *
361
     * @param string $schema The schema of the tables.
362
     * Defaults to empty string, meaning the current or default schema.
363
     *
364
     * @throws Exception
365
     * @throws InvalidConfigException
366
     * @throws Throwable
367
     *
368
     * @return array All tables name in the database. The names have NO schema name prefix.
369
     */
370 12
    protected function findTableNames(string $schema = ''): array
371
    {
372 12
        $sql = 'SHOW TABLES';
373
374 12
        if ($schema !== '') {
375 1
            $sql .= ' FROM ' . $this->db->getQuoter()->quoteSimpleTableName($schema);
376
        }
377
378 12
        return $this->db->createCommand($sql)->queryColumn();
379
    }
380
381
    /**
382
     * @throws Exception
383
     * @throws InvalidConfigException
384
     * @throws Throwable
385
     */
386 1
    protected function findViewNames(string $schema = ''): array
387
    {
388 1
        $sql = match ($schema) {
389 1
            '' => <<<SQL
390
            SELECT table_name as view FROM information_schema.tables WHERE table_type LIKE 'VIEW' AND table_schema != 'sys' order by table_name
391 1
            SQL,
392 1
            default => <<<SQL
393 1
            SELECT table_name as view FROM information_schema.tables WHERE table_type LIKE 'VIEW' AND table_schema = '$schema' order by table_name
394 1
            SQL,
395 1
        };
396
397
        /** @psalm-var string[][] $views */
398 1
        $views = $this->db->createCommand($sql)->queryAll();
399
400 1
        foreach ($views as $key => $view) {
401 1
            $views[$key] = $view['view'];
402
        }
403
404 1
        return $views;
405
    }
406
407
    /**
408
     * Returns the cache key for the specified table name.
409
     *
410
     * @param string $name The table name.
411
     *
412
     * @return array The cache key.
413
     *
414
     * @psalm-suppress DeprecatedMethod
415
     */
416 267
    protected function getCacheKey(string $name): array
417
    {
418 267
        return array_merge([self::class], $this->generateCacheKey(), [$this->getRawTableName($name)]);
0 ignored issues
show
Deprecated Code introduced by
The function Yiisoft\Db\Schema\Abstra...hema::getRawTableName() has been deprecated: Use {@see Quoter::getRawTableName()}. Will be removed in version 2.0.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

418
        return array_merge([self::class], $this->generateCacheKey(), [/** @scrutinizer ignore-deprecated */ $this->getRawTableName($name)]);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
419
    }
420
421
    /**
422
     * Returns the cache tag name.
423
     *
424
     * This allows {@see refresh()} to invalidate all cached table schemas.
425
     *
426
     * @return string The cache tag name.
427
     */
428 227
    protected function getCacheTag(): string
429
    {
430 227
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
431
    }
432
433
    /**
434
     * Gets the `CREATE TABLE` SQL string.
435
     *
436
     * @param TableSchemaInterface $table The table metadata.
437
     *
438
     * @throws Exception
439
     * @throws InvalidConfigException
440
     * @throws Throwable
441
     *
442
     * @return string $sql The result of `SHOW CREATE TABLE`.
443
     */
444 178
    protected function getCreateTableSql(TableSchemaInterface $table): string
445
    {
446 178
        $tableName = $table->getFullName() ?? '';
447
448
        try {
449
            /** @psalm-var array<array-key, string> $row */
450 178
            $row = $this->db->createCommand(
451 178
                'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
452 178
            )->queryOne();
453
454 160
            if (isset($row['Create Table'])) {
455 158
                $sql = $row['Create Table'];
456
            } else {
457 4
                $row = array_values($row);
0 ignored issues
show
$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

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