Test Failed
Pull Request — dev (#118)
by Def
09:16 queued 06:49
created

Schema::findConstraints()   B

Complexity

Conditions 9
Paths 43

Size

Total Lines 75
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 19.125

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 15
cts 30
cp 0.5
crap 19.125
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\Cache\SchemaCache;
11
use Yiisoft\Db\Connection\ConnectionInterface;
12
use Yiisoft\Db\Constraint\Constraint;
13
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
14
use Yiisoft\Db\Constraint\IndexConstraint;
15
use Yiisoft\Db\Exception\Exception;
16
use Yiisoft\Db\Exception\InvalidConfigException;
17
use Yiisoft\Db\Exception\NotSupportedException;
18
use Yiisoft\Db\Expression\Expression;
19
use Yiisoft\Db\Schema\Schema as AbstractSchema;
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 md5;
29
use function preg_match;
30
use function preg_match_all;
31
use function serialize;
32
use function str_replace;
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 382
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
131
    {
132 382
        parent::__construct($schemaCache);
133
    }
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|string|null $length
146
     */
147 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
148
    {
149 3
        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|InvalidConfigException|Throwable
167
     *
168
     * @return array all unique indexes for the given table.
169
     */
170 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
171
    {
172 1
        $sql = $this->getCreateTableSql($table);
173
174 1
        $uniqueIndexes = [];
175
176 1
        $regexp = '/UNIQUE KEY\s+`(.+)`\s*\((`.+`)+\)/mi';
177
178 1
        if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
179 1
            foreach ($matches as $match) {
180 1
                $indexName = $match[1];
181 1
                $indexColumns = array_map('trim', explode('`,`', trim($match[2], '`')));
182 1
                $uniqueIndexes[$indexName] = $indexColumns;
183
            }
184
        }
185
186 1
        return $uniqueIndexes;
187
    }
188
189
    /**
190
     * @inheritDoc
191
     */
192
    public function getLastInsertID(?string $sequenceName = null): string
193
    {
194
        return $this->db->getLastInsertID($sequenceName);
195
    }
196
197
    /**
198
     * Returns the actual name of a given table name.
199
     *
200
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
201
     * {@see ConnectionInterface::tablePrefix}.
202
     *
203
     * @param string $name the table name to be converted.
204
     *
205
     * @return string the real name of the given table name.
206
     */
207 162
    public function getRawTableName(string $name): string
208
    {
209 162
        if (str_contains($name, '{{')) {
210 23
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
211
212 23
            return str_replace('%', $this->db->getTablePrefix(), $name);
213
        }
214
215 162
        return $name;
216
    }
217
218 5
    public function supportsSavepoint(): bool
219
    {
220 5
        return $this->db->isSavepointEnabled();
221
    }
222
223
    /**
224
     * Collects the metadata of table columns.
225
     *
226
     * @param TableSchemaInterface $table the table metadata.
227
     *
228
     * @throws Exception|Throwable if DB query fails.
229
     *
230
     * @return bool whether the table exists in the database.
231
     */
232 98
    protected function findColumns(TableSchemaInterface $table): bool
233
    {
234 98
        $tableName = $table->getFullName() ?? '';
235 98
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
236
237
        try {
238 98
            $columns = $this->db->createCommand($sql)->queryAll();
239 15
        } catch (Exception $e) {
240 15
            $previous = $e->getPrevious();
241
242 15
            if ($previous && str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
243
                /**
244
                 * table does not exist.
245
                 *
246
                 * https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error
247
                 */
248 15
                return false;
249
            }
250
251
            throw $e;
252
        }
253
254
        /** @psalm-var ColumnInfoArray $info */
255 92
        foreach ($columns as $info) {
256 92
            $info = $this->normalizeRowKeyCase($info, false);
257
258 92
            $column = $this->loadColumnSchema($info);
259 92
            $table->columns($column->getName(), $column);
260
261 92
            if ($column->isPrimaryKey()) {
262 58
                $table->primaryKey($column->getName());
263 58
                if ($column->isAutoIncrement()) {
264 56
                    $table->sequenceName('');
265
                }
266
            }
267
        }
268
269 92
        return true;
270
    }
271
272
    /**
273
     * Collects the foreign key column details for the given table.
274
     *
275
     * @param TableSchemaInterface $table the table metadata.
276
     *
277
     * @throws Exception|Throwable
278
     */
279 92
    protected function findConstraints(TableSchemaInterface $table): void
280
    {
281 92
        $sql = <<<SQL
282
        SELECT
283
            `kcu`.`CONSTRAINT_NAME` AS `constraint_name`,
284
            `kcu`.`COLUMN_NAME` AS `column_name`,
285
            `kcu`.`REFERENCED_TABLE_NAME` AS `referenced_table_name`,
286
            `kcu`.`REFERENCED_COLUMN_NAME` AS `referenced_column_name`
287
        FROM `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`
288
        JOIN `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` ON
289
            (
290
                `kcu`.`CONSTRAINT_CATALOG` = `rc`.`CONSTRAINT_CATALOG` OR
291
                (
292
                    `kcu`.`CONSTRAINT_CATALOG` IS NULL AND
293
                    `rc`.`CONSTRAINT_CATALOG` IS NULL
294
                )
295
            ) AND
296
            `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
297
            `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` AND
298
            `kcu`.`TABLE_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
299
            `kcu`.`TABLE_NAME` = `rc`.`TABLE_NAME`
300
        WHERE
301
            `rc`.`CONSTRAINT_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
302
            `rc`.`TABLE_NAME` = :tableName
303
        SQL;
304
305
        try {
306 92
            $rows = $this->db->createCommand($sql, [
307 92
                ':schemaName' => $table->getSchemaName(),
308 92
                ':tableName' => $table->getName(),
309 92
            ])->queryAll();
310
311 92
            $constraints = [];
312
313
            /**  @psalm-var RowConstraint $row */
314 92
            foreach ($rows as $row) {
315 25
                $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
316 25
                $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
317
            }
318
319 92
            $table->foreignKeys([]);
320
321
            /**
322
             * @var array{referenced_table_name: string, columns: array} $constraint
323
             */
324 92
            foreach ($constraints as $name => $constraint) {
325 25
                $table->foreignKey($name, array_merge(
326 25
                    [$constraint['referenced_table_name']],
327 25
                    $constraint['columns']
328
                ));
329
            }
330
        } catch (Exception $e) {
331
            $previous = $e->getPrevious();
332
333
            if ($previous === null || !str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
334
                throw $e;
335
            }
336
337
            // table does not exist, try to determine the foreign keys using the table creation sql
338
            $sql = $this->getCreateTableSql($table);
339
            $regexp = '/FOREIGN KEY\s+\(([^)]+)\)\s+REFERENCES\s+([^(^\s]+)\s*\(([^)]+)\)/mi';
340
341
            if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
342
                foreach ($matches as $match) {
343
                    $fks = array_map('trim', explode(',', str_replace('`', '', $match[1])));
344
                    $pks = array_map('trim', explode(',', str_replace('`', '', $match[3])));
345
                    $constraint = [str_replace('`', '', $match[2])];
346
347
                    foreach ($fks as $k => $name) {
348
                        $constraint[$name] = $pks[$k];
349
                    }
350
351
                    $table->foreignKey(md5(serialize($constraint)), $constraint);
352
                }
353
                $table->foreignKeys(array_values($table->getForeignKeys()));
354
            }
355
        }
356
    }
357
358
    /**
359
     * Returns all table names in the database.
360
     *
361
     * This method should be overridden by child classes in order to support this feature because the default
362
     * implementation simply throws an exception.
363
     *
364
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
365
     *
366
     * @throws Exception|InvalidConfigException|Throwable
367
     *
368
     * @return array all table names in the database. The names have NO schema name prefix.
369
     */
370 7
    protected function findTableNames(string $schema = ''): array
371
    {
372 7
        $sql = 'SHOW TABLES';
373
374 7
        if ($schema !== '') {
375
            $sql .= ' FROM ' . $this->db->getQuoter()->quoteSimpleTableName($schema);
376
        }
377
378 7
        $tableNames = $this->db->createCommand($sql)->queryColumn();
379 7
        if (!$tableNames) {
380
            return [];
381
        }
382
383 7
        return $tableNames;
384
    }
385
386
    /**
387
     * Returns the cache key for the specified table name.
388
     *
389
     * @param string $name the table name.
390
     *
391
     * @return array the cache key.
392
     */
393 162
    protected function getCacheKey(string $name): array
394
    {
395 162
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
396
    }
397
398
    /**
399
     * Returns the cache tag name.
400
     *
401
     * This allows {@see refresh()} to invalidate all cached table schemas.
402
     *
403
     * @return string the cache tag name.
404
     */
405 162
    protected function getCacheTag(): string
406
    {
407 162
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
408
    }
409
410
    /**
411
     * Gets the CREATE TABLE sql string.
412
     *
413
     * @param TableSchemaInterface $table the table metadata.
414
     *
415
     * @throws Exception|InvalidConfigException|Throwable
416
     *
417
     * @return string $sql the result of 'SHOW CREATE TABLE'.
418
     */
419 1
    protected function getCreateTableSql(TableSchemaInterface $table): string
420
    {
421 1
        $tableName = $table->getFullName() ?? '';
422
423
        /** @var array<array-key, string> $row */
424 1
        $row = $this->db->createCommand(
425 1
            'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
426 1
        )->queryOne();
427
428 1
        if (isset($row['Create Table'])) {
429 1
            $sql = $row['Create Table'];
430
        } else {
431
            $row = array_values($row);
0 ignored issues
show
Bug introduced by
$row of type false 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

431
            $row = array_values(/** @scrutinizer ignore-type */ $row);
Loading history...
432
            $sql = $row[1];
433
        }
434
435 1
        return $sql;
436
    }
437
438
    /**
439
     * Loads the column information into a {@see ColumnSchema} object.
440
     *
441
     * @param array $info column information.
442
     *
443
     * @throws JsonException
444
     *
445
     * @return ColumnSchema the column schema object.
446
     */
447 93
    protected function loadColumnSchema(array $info): ColumnSchema
448
    {
449 93
        $column = $this->createColumnSchema();
450
451
        /** @psalm-var ColumnInfoArray $info */
452 93
        $column->name($info['field']);
453 93
        $column->allowNull($info['null'] === 'YES');
454 93
        $column->primaryKey(str_contains($info['key'], 'PRI'));
455 93
        $column->autoIncrement(stripos($info['extra'], 'auto_increment') !== false);
456 93
        $column->comment($info['comment']);
457 93
        $column->dbType($info['type']);
458 93
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
459 93
        $column->type(self::TYPE_STRING);
460
461 93
        $extra = $info['extra'];
462 93
        if (str_starts_with($extra, 'DEFAULT_GENERATED')) {
463
            $extra = strtoupper(substr($extra, 18));
464 93
        }
465 93
        $column->extra(trim($extra));
0 ignored issues
show
Bug introduced by
The method extra() does not exist on Yiisoft\Db\Mysql\ColumnSchema. ( Ignorable by Annotation )

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

465
        $column->/** @scrutinizer ignore-call */ 
466
                 extra(trim($extra));

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

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