Passed
Push — dev ( f9d2dd...629131 )
by Def
08:09 queued 05:38
created

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

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