Test Failed
Pull Request — master (#180)
by Wilmer
03:50
created

Schema::findConstraints()   B

Complexity

Conditions 9
Paths 43

Size

Total Lines 75
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 21.4523

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 9
eloc 52
c 2
b 0
f 0
nc 43
nop 1
dl 0
loc 75
ccs 13
cts 28
cp 0.4643
crap 21.4523
rs 7.4917

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

457
                $row = array_values(/** @scrutinizer ignore-type */ $row);
Loading history...
458 126
                $sql = $row[1];
459 98
            }
460 23
        } catch (Exception) {
461
            $sql = '';
462 23
        }
463 23
464
        return $sql;
465
    }
466 23
467
    /**
468 98
     * Loads the column information into a {@see ColumnSchemaInterface} object.
469 98
     *
470 98
     * @param array $info column information.
471
     *
472 98
     * @throws JsonException
473 36
     *
474
     * @return ColumnSchemaInterface the column schema object.
475
     */
476 98
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
477 24
    {
478 98
        $column = $this->createColumnSchema();
479 23
480
        /** @psalm-var ColumnInfoArray $info */
481 23
        $column->name($info['field']);
482
        $column->allowNull($info['null'] === 'YES');
483
        $column->primaryKey(str_contains($info['key'], 'PRI'));
484
        $column->autoIncrement(stripos($info['extra'], 'auto_increment') !== false);
485
        $column->comment($info['comment']);
486
        $column->dbType($info['type']);
487
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
488
        $column->type(self::TYPE_STRING);
489 126
490
        $extra = $info['extra'];
491 126
        if (str_starts_with($extra, 'DEFAULT_GENERATED')) {
492
            $extra = strtoupper(substr($extra, 18));
493
        }
494
        $column->extra(trim($extra));
495
496
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
497
            $type = strtolower($matches[1]);
498
499 123
            if (isset($this->typeMap[$type])) {
500 123
                $column->type($this->typeMap[$type]);
501
            }
502 26
503 26
            if (!empty($matches[2])) {
504 120
                if ($type === 'enum') {
505 23
                    preg_match_all("/'[^']*'/", $matches[2], $values);
506
507 123
                    foreach ($values[0] as $i => $value) {
508
                        $values[$i] = trim($value, "'");
509 81
                    }
510 1
511
                    $column->enumValues($values);
512
                } else {
513 126
                    $values = explode(',', $matches[2]);
514
                    $column->precision((int) $values[0]);
515
                    $column->size((int) $values[0]);
516
517
                    if (isset($values[1])) {
518
                        $column->scale((int) $values[1]);
519
                    }
520
521
                    if ($column->getSize() === 1 && $type === 'tinyint') {
522
                        $column->type(self::TYPE_BOOLEAN);
523
                    } elseif ($type === 'bit') {
524
                        if ($column->getSize() > 32) {
525 14
                            $column->type(self::TYPE_BIGINT);
526
                        } elseif ($column->getSize() === 32) {
527 14
                            $column->type(self::TYPE_INTEGER);
528
                        }
529
                    }
530
                }
531
            }
532
        }
533
534
        $column->phpType($this->getColumnPhpType($column));
535
536
        if (!$column->isPrimaryKey()) {
537
            /**
538
             * When displayed in the INFORMATION_SCHEMA.COLUMNS table, a default CURRENT TIMESTAMP is displayed
539
             * as CURRENT_TIMESTAMP up until MariaDB 10.2.2, and as current_timestamp() from MariaDB 10.2.3.
540
             *
541
             * See details here: https://mariadb.com/kb/en/library/now/#description
542
             */
543
            if (
544
                ($column->getType() === 'timestamp' || $column->getType() === 'datetime')
545 65
                && preg_match('/^current_timestamp(?:\((\d*)\))?$/i', (string) $info['default'], $matches)
546
            ) {
547 65
                $column->defaultValue(new Expression('CURRENT_TIMESTAMP' . (!empty($matches[1])
548
                    ? '(' . $matches[1] . ')' : '')));
549
            } elseif (isset($type) && $type === 'bit') {
550
                $column->defaultValue(bindec(trim((string) $info['default'], 'b\'')));
551
            } else {
552
                $column->defaultValue($column->phpTypecast($info['default']));
553
            }
554
        } elseif ($info['default'] !== null) {
555
            $column->defaultValue($column->phpTypecast($info['default']));
556
        }
557
558
        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
    protected function loadTableChecks(string $tableName): array
571
    {
572
        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
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
591
    {
592
        $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 65
            WHEN :schemaName IS NULL AND `kcu`.`REFERENCED_TABLE_SCHEMA` = DATABASE() THEN NULL
599
        ELSE `kcu`.`REFERENCED_TABLE_SCHEMA`
600 65
        END AS `foreign_table_schema`,
601 65
            `kcu`.`REFERENCED_TABLE_NAME` AS `foreign_table_name`,
602 65
            `kcu`.`REFERENCED_COLUMN_NAME` AS `foreign_column_name`,
603 65
            `rc`.`UPDATE_RULE` AS `on_update`,
604
            `rc`.`DELETE_RULE` AS `on_delete`,
605
            `kcu`.`ORDINAL_POSITION` AS `position`
606 65
        FROM `information_schema`.`KEY_COLUMN_USAGE` AS `kcu`
607 65
        JOIN `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc` ON
608
            `rc`.`CONSTRAINT_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
609 65
            `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 65
            `kcu`.`TABLE_NAME` = :tableName
620
        UNION
621
        SELECT
622
            `kcu`.`CONSTRAINT_NAME` AS `name`,
623
            `kcu`.`COLUMN_NAME` AS `column_name`,
624 60
            `tc`.`CONSTRAINT_TYPE` AS `type`,
625
        NULL AS `foreign_table_schema`,
626 60
        NULL AS `foreign_table_name`,
627 43
        NULL AS `foreign_column_name`,
628 43
        NULL AS `on_update`,
629 43
        NULL AS `on_delete`,
630 56
            `kcu`.`ORDINAL_POSITION` AS `position`
631 17
        FROM `information_schema`.`KEY_COLUMN_USAGE` AS `kcu`
632 17
        JOIN `information_schema`.`TABLE_CONSTRAINTS` AS `tc` ON
633 17
            `tc`.`TABLE_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
634 17
            `tc`.`TABLE_NAME` = `kcu`.`TABLE_NAME` AND
635 17
            `tc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME` AND
636 17
            `tc`.`CONSTRAINT_TYPE` IN ('PRIMARY KEY', 'UNIQUE')
637 17
        WHERE
638 17
            `kcu`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
639 17
            `kcu`.`TABLE_NAME` = :tableName
640 46
        ORDER BY `position` ASC
641 46
        SQL;
642 46
643 46
        $resolvedName = $this->resolveTableName($tableName);
644 46
645
        $constraints = $this->db->createCommand($sql, [
646
            ':schemaName' => $resolvedName->getSchemaName(),
647
            ':tableName' => $resolvedName->getName(),
648
        ])->queryAll();
649 65
650 65
        /** @var array<array-key, array> $constraints */
651
        $constraints = $this->normalizeRowKeyCase($constraints, true);
652
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
653 65
654
        $result = [
655
            self::PRIMARY_KEY => null,
656
            self::FOREIGN_KEYS => [],
657
            self::UNIQUES => [],
658
        ];
659
660
        /**
661
         * @var string $type
662
         * @var array $names
663
         */
664
        foreach ($constraints as $type => $names) {
665 14
            /**
666
             * @psalm-var object|string|null $name
667 14
             * @psalm-var ConstraintArray $constraint
668
             */
669
            foreach ($names as $name => $constraint) {
670
                switch ($type) {
671
                    case 'PRIMARY KEY':
672
                        $result[self::PRIMARY_KEY] = (new Constraint())
673
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
674
                        break;
675
                    case 'FOREIGN KEY':
676
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
677
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
678
                            ->foreignTableName($constraint[0]['foreign_table_name'])
679
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
680
                            ->onDelete($constraint[0]['on_delete'])
681 9
                            ->onUpdate($constraint[0]['on_update'])
682
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
683 9
                            ->name($name);
684
                        break;
685 9
                    case 'UNIQUE':
686
                        $result[self::UNIQUES][] = (new Constraint())
687
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
688
                            ->name($name);
689
                        break;
690
                }
691
            }
692
        }
693
694
        foreach ($result as $type => $data) {
695
            $this->setTableMetadata($tableName, $type, $data);
696
        }
697
698
        return $result[$returnType];
699 32
    }
700
701 32
    /**
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
    protected function loadTableDefaultValues(string $tableName): array
711
    {
712
        throw new NotSupportedException(__METHOD__ . ' is not supported by MySQL.');
713
    }
714
715 32
    /**
716
     * Loads all foreign keys for the given table.
717 32
     *
718 32
     * @param string $tableName table name.
719 32
     *
720 32
     * @throws Exception
721
     * @throws InvalidConfigException
722
     * @throws Throwable
723 32
     *
724 32
     * @return array foreign keys for the given table.
725 32
     */
726
    protected function loadTableForeignKeys(string $tableName): array
727
    {
728
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
729
730
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
731 32
    }
732 32
733
    /**
734 32
     * Loads all indexes for the given table.
735 32
     *
736 32
     * @param string $tableName table name.
737 32
     *
738
     * @throws Exception
739 32
     * @throws InvalidConfigException
740
     * @throws Throwable
741
     *
742 32
     * @return IndexConstraint[] indexes for the given table.
743
     */
744
    protected function loadTableIndexes(string $tableName): array
745
    {
746
        $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 39
            `s`.`TABLE_NAME` = :tableName
757
        ORDER BY `s`.`SEQ_IN_INDEX` ASC
758 39
        SQL;
759
760 39
        $resolvedName = $this->resolveTableName($tableName);
761
762
        $indexes = $this->db->createCommand($sql, [
763
            ':schemaName' => $resolvedName->getSchemaName(),
764
            ':tableName' => $resolvedName->getName(),
765
        ])->queryAll();
766
767
        /** @var array[] $indexes */
768
        $indexes = $this->normalizeRowKeyCase($indexes, true);
769
        $indexes = ArrayHelper::index($indexes, null, 'name');
770
        $result = [];
771
772
        /**
773 140
         * @psalm-var object|string|null $name
774
         * @psalm-var array[] $index
775 140
         */
776 140
        foreach ($indexes as $name => $index) {
777 140
            $ic = new IndexConstraint();
778
779 140
            $ic->primary((bool) $index[0]['index_is_primary']);
780 125
            $ic->unique((bool) $index[0]['index_is_unique']);
781
            $ic->name($name !== 'PRIMARY' ? $name : null);
782 125
            $ic->columnNames(ArrayHelper::getColumn($index, 'column_name'));
783
784
            $result[] = $ic;
785 25
        }
786
787
        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 17
     * @return Constraint|null primary key for the given table, `null` if the table has no primary key.*
800
     */
801 17
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
802
    {
803 17
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
804
805
        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 185
     * @throws Exception
814
     * @throws Throwable
815 185
     *
816
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table does not exist.
817 185
     */
818 185
    protected function loadTableSchema(string $name): TableSchemaInterface|null
819
    {
820
        $table = $this->resolveTableName($name);
821 185
        $this->resolveTableCreateSql($table);
822 185
        $this->findTableComment($table);
823
824 185
        if ($this->findColumns($table)) {
825 185
            $this->findConstraints($table);
826 185
827
            return $table;
828
        }
829 185
830
        return null;
831
    }
832
833
    /**
834
     * Loads all unique constraints for the given table.
835
     *
836
     * @param string $tableName table name.
837 140
     *
838
     * @throws Exception
839 140
     * @throws InvalidConfigException
840 140
     * @throws Throwable
841
     *
842
     * @return array unique constraints for the given table.
843
     */
844
    protected function loadTableUniques(string $tableName): array
845
    {
846
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
847
848
        return is_array($tableUniques) ? $tableUniques : [];
849
    }
850 126
851
    /**
852 126
     * Resolves the table name and schema name (if any).
853
     *
854
     * @param string $name the table name.
855
     *
856
     * {@see TableSchemaInterface}
857
     */
858
    protected function resolveTableName(string $name): TableSchemaInterface
859
    {
860
        $resolvedName = new TableSchema();
861
862
        $parts = array_reverse(
863
            $this->db->getQuoter()->getTableNameParts($name)
864
        );
865
866
        $resolvedName->name($parts[0] ?? '');
867
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
868
869
        $resolvedName->fullName(
870
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
871
            implode('.', array_reverse($parts)) : $resolvedName->getName()
872
        );
873
874
        return $resolvedName;
875
    }
876
877
    /**
878
     * @throws Exception
879
     * @throws InvalidConfigException
880
     * @throws Throwable
881
     */
882
    protected function resolveTableCreateSql(TableSchemaInterface $table): void
883
    {
884
        $sql = $this->getCreateTableSql($table);
885
        $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
    private function createColumnSchema(): ColumnSchema
896
    {
897
        return new ColumnSchema();
898
    }
899
}
900