Passed
Pull Request — dev (#99)
by Def
09:11 queued 02:07
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 372
    public function __construct(private ConnectionPDOInterface $db, SchemaCache $schemaCache)
136
    {
137 372
        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 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
151
    {
152 3
        return new ColumnSchemaBuilder($type, $length, $this->db->getQuoter());
153
    }
154
155
    /**
156
     * @throws Exception|InvalidConfigException|Throwable
157
     */
158 1
    public function createSavepoint(string $name): void
159
    {
160 1
        $this->db->createCommand("SAVEPOINT $name")->execute();
161
    }
162
163
    /**
164
     * Returns all unique indexes for the given table.
165
     *
166
     * Each array element is of the following structure:
167
     *
168
     * ```php
169
     * [
170
     *     'IndexName1' => ['col1' [, ...]],
171
     *     'IndexName2' => ['col2' [, ...]],
172
     * ]
173
     * ```
174
     *
175
     * @param TableSchema $table the table metadata.
176
     *
177
     * @throws Exception|InvalidConfigException|Throwable
178
     *
179
     * @return array all unique indexes for the given table.
180
     */
181 1
    public function findUniqueIndexes(TableSchema $table): array
182
    {
183 1
        $sql = $this->getCreateTableSql($table);
184
185 1
        $uniqueIndexes = [];
186
187 1
        $regexp = '/UNIQUE KEY\s+`(.+)`\s*\((`.+`)+\)/mi';
188
189 1
        if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
190 1
            foreach ($matches as $match) {
191 1
                $indexName = $match[1];
192 1
                $indexColumns = array_map('trim', explode('`,`', trim($match[2], '`')));
193 1
                $uniqueIndexes[$indexName] = $indexColumns;
194
            }
195
        }
196
197 1
        return $uniqueIndexes;
198
    }
199
200
    /**
201
     * Returns the ID of the last inserted row or sequence value.
202
     *
203
     * @param string $sequenceName name of the sequence object (required by some DBMS)
204
     *
205
     * @throws InvalidCallException if the DB connection is not active
206
     *
207
     * @return string the row ID of the last row inserted, or the last value retrieved from the sequence object
208
     *
209
     * @see http://www.php.net/manual/en/function.PDO-lastInsertId.php
210
     */
211 3
    public function getLastInsertID(string $sequenceName = ''): string
212
    {
213 3
        $pdo = $this->db->getPDO();
214
215 3
        if ($this->db->isActive() && $pdo !== null) {
216 3
            return $pdo->lastInsertId(
217 3
                $sequenceName === '' ? null : $this->db->getQuoter()->quoteTableName($sequenceName)
218
            );
219
        }
220
221
        throw new InvalidCallException('DB Connection is not active.');
222
    }
223
224
    /**
225
     * Returns the actual name of a given table name.
226
     *
227
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
228
     * {@see ConnectionInterface::tablePrefix}.
229
     *
230
     * @param string $name the table name to be converted.
231
     *
232
     * @return string the real name of the given table name.
233
     */
234 154
    public function getRawTableName(string $name): string
235
    {
236 154
        if (str_contains($name, '{{')) {
237 23
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
238
239 23
            return str_replace('%', $this->db->getTablePrefix(), $name);
240
        }
241
242 154
        return $name;
243
    }
244
245 1
    public function rollBackSavepoint(string $name): void
246
    {
247 1
        $this->db->createCommand("ROLLBACK TO SAVEPOINT $name")->execute();
248
    }
249
250
    /**
251
     * @throws Exception|InvalidConfigException|Throwable
252
     */
253
    public function releaseSavepoint(string $name): void
254
    {
255
        $this->db->createCommand("RELEASE SAVEPOINT $name")->execute();
256
    }
257
258 2
    public function setTransactionIsolationLevel(string $level): void
259
    {
260 2
        $this->db->createCommand("SET TRANSACTION ISOLATION LEVEL $level")->execute();
261
    }
262
263 2
    public function supportsSavepoint(): bool
264
    {
265 2
        return $this->db->isSavepointEnabled();
266
    }
267
268
    /**
269
     * Collects the metadata of table columns.
270
     *
271
     * @param TableSchema $table the table metadata.
272
     *
273
     * @throws Exception|Throwable if DB query fails.
274
     *
275
     * @return bool whether the table exists in the database.
276
     */
277 94
    protected function findColumns(TableSchema $table): bool
278
    {
279 94
        $tableName = $table->getFullName() ?? '';
280 94
        $sql = 'SHOW FULL COLUMNS FROM ' . $this->db->getQuoter()->quoteTableName($tableName);
281
282
        try {
283 94
            $columns = $this->db->createCommand($sql)->queryAll();
284 15
        } catch (Exception $e) {
285 15
            $previous = $e->getPrevious();
286
287 15
            if ($previous instanceof PDOException && str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
288
                /**
289
                 * table does not exist.
290
                 *
291
                 * https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_bad_table_error
292
                 */
293 15
                return false;
294
            }
295
296
            throw $e;
297
        }
298
299 88
        $slavePdo = $this->db->getSlavePdo();
300
301
        /** @psalm-var ColumnInfoArray $info */
302 88
        foreach ($columns as $info) {
303 88
            if ($slavePdo !== null && $slavePdo->getAttribute(PDO::ATTR_CASE) !== PDO::CASE_LOWER) {
304 87
                $info = array_change_key_case($info, CASE_LOWER);
305
            }
306
307 88
            $column = $this->loadColumnSchema($info);
308 88
            $table->columns($column->getName(), $column);
309
310 88
            if ($column->isPrimaryKey()) {
311 54
                $table->primaryKey($column->getName());
312 54
                if ($column->isAutoIncrement()) {
313 52
                    $table->sequenceName('');
314
                }
315
            }
316
        }
317
318 88
        return true;
319
    }
320
321
    /**
322
     * Collects the foreign key column details for the given table.
323
     *
324
     * @param TableSchema $table the table metadata.
325
     *
326
     * @throws Exception|Throwable
327
     */
328 88
    protected function findConstraints(TableSchema $table): void
329
    {
330 88
        $sql = <<<SQL
331
        SELECT
332
            `kcu`.`CONSTRAINT_NAME` AS `constraint_name`,
333
            `kcu`.`COLUMN_NAME` AS `column_name`,
334
            `kcu`.`REFERENCED_TABLE_NAME` AS `referenced_table_name`,
335
            `kcu`.`REFERENCED_COLUMN_NAME` AS `referenced_column_name`
336
        FROM `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc`
337
        JOIN `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` ON
338
            (
339
                `kcu`.`CONSTRAINT_CATALOG` = `rc`.`CONSTRAINT_CATALOG` OR
340
                (
341
                    `kcu`.`CONSTRAINT_CATALOG` IS NULL AND
342
                    `rc`.`CONSTRAINT_CATALOG` IS NULL
343
                )
344
            ) AND
345
            `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
346
            `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` AND
347
            `kcu`.`TABLE_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` AND
348
            `kcu`.`TABLE_NAME` = `rc`.`TABLE_NAME`
349
        WHERE
350
            `rc`.`CONSTRAINT_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
351
            `rc`.`TABLE_NAME` = :tableName
352
        SQL;
353
354
        try {
355 88
            $rows = $this->db->createCommand($sql, [
356 88
                ':schemaName' => $table->getSchemaName(),
357 88
                ':tableName' => $table->getName(),
358 88
            ])->queryAll();
359
360 88
            $constraints = [];
361
362
            /**  @psalm-var RowConstraint $row */
363 88
            foreach ($rows as $row) {
364 24
                $constraints[$row['constraint_name']]['referenced_table_name'] = $row['referenced_table_name'];
365 24
                $constraints[$row['constraint_name']]['columns'][$row['column_name']] = $row['referenced_column_name'];
366
            }
367
368 88
            $table->foreignKeys([]);
369
370
            /**
371
             * @var array{referenced_table_name: string, columns: array} $constraint
372
             */
373 88
            foreach ($constraints as $name => $constraint) {
374 24
                $table->foreignKey($name, array_merge(
375 24
                    [$constraint['referenced_table_name']],
376 24
                    $constraint['columns']
377
                ));
378
            }
379
        } catch (Exception $e) {
380
            $previous = $e->getPrevious();
381
382
            if (!$previous instanceof PDOException || !str_contains($previous->getMessage(), 'SQLSTATE[42S02')) {
383
                throw $e;
384
            }
385
386
            // table does not exist, try to determine the foreign keys using the table creation sql
387
            $sql = $this->getCreateTableSql($table);
388
            $regexp = '/FOREIGN KEY\s+\(([^)]+)\)\s+REFERENCES\s+([^(^\s]+)\s*\(([^)]+)\)/mi';
389
390
            if (preg_match_all($regexp, $sql, $matches, PREG_SET_ORDER)) {
391
                foreach ($matches as $match) {
392
                    $fks = array_map('trim', explode(',', str_replace('`', '', $match[1])));
393
                    $pks = array_map('trim', explode(',', str_replace('`', '', $match[3])));
394
                    $constraint = [str_replace('`', '', $match[2])];
395
396
                    foreach ($fks as $k => $name) {
397
                        $constraint[$name] = $pks[$k];
398
                    }
399
400
                    $table->foreignKey(md5(serialize($constraint)), $constraint);
401
                }
402
                $table->foreignKeys(array_values($table->getForeignKeys()));
403
            }
404
        }
405
    }
406
407
    /**
408
     * Returns all table names in the database.
409
     *
410
     * This method should be overridden by child classes in order to support this feature because the default
411
     * implementation simply throws an exception.
412
     *
413
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
414
     *
415
     * @throws Exception|InvalidConfigException|Throwable
416
     *
417
     * @return array all table names in the database. The names have NO schema name prefix.
418
     */
419 3
    protected function findTableNames(string $schema = ''): array
420
    {
421 3
        $sql = 'SHOW TABLES';
422
423 3
        if ($schema !== '') {
424
            $sql .= ' FROM ' . $this->db->getQuoter()->quoteSimpleTableName($schema);
425
        }
426
427 3
        return $this->db->createCommand($sql)->queryColumn();
428
    }
429
430
    /**
431
     * Returns the cache key for the specified table name.
432
     *
433
     * @param string $name the table name.
434
     *
435
     * @return array the cache key.
436
     */
437 154
    protected function getCacheKey(string $name): array
438
    {
439
        return [
440
            __CLASS__,
441 154
            $this->db->getDriver()->getDsn(),
442 154
            $this->db->getDriver()->getUsername(),
443 154
            $this->getRawTableName($name),
444
        ];
445
    }
446
447
    /**
448
     * Returns the cache tag name.
449
     *
450
     * This allows {@see refresh()} to invalidate all cached table schemas.
451
     *
452
     * @return string the cache tag name.
453
     */
454 154
    protected function getCacheTag(): string
455
    {
456 154
        return md5(serialize([
457
            __CLASS__,
458 154
            $this->db->getDriver()->getDsn(),
459 154
            $this->db->getDriver()->getUsername(),
460
        ]));
461
    }
462
463
    /**
464
     * Gets the CREATE TABLE sql string.
465
     *
466
     * @param TableSchema $table the table metadata.
467
     *
468
     * @throws Exception|InvalidConfigException|Throwable
469
     *
470
     * @return string $sql the result of 'SHOW CREATE TABLE'.
471
     */
472 1
    protected function getCreateTableSql(TableSchema $table): string
473
    {
474 1
        $tableName = $table->getFullName() ?? '';
475
476
        /** @var array<array-key, string> $row */
477 1
        $row = $this->db->createCommand(
478 1
            'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
479 1
        )->queryOne();
480
481 1
        if (isset($row['Create Table'])) {
482 1
            $sql = $row['Create Table'];
483
        } else {
484
            $row = array_values($row);
485
            $sql = $row[1];
486
        }
487
488 1
        return $sql;
489
    }
490
491
    /**
492
     * Loads the column information into a {@see ColumnSchema} object.
493
     *
494
     * @param array $info column information.
495
     *
496
     * @throws JsonException
497
     *
498
     * @return ColumnSchema the column schema object.
499
     */
500 89
    protected function loadColumnSchema(array $info): ColumnSchema
501
    {
502 89
        $column = $this->createColumnSchema();
503
504
        /** @psalm-var ColumnInfoArray $info */
505 89
        $column->name($info['field']);
506 89
        $column->allowNull($info['null'] === 'YES');
507 89
        $column->primaryKey(str_contains($info['key'], 'PRI'));
508 89
        $column->autoIncrement(stripos($info['extra'], 'auto_increment') !== false);
509 89
        $column->comment($info['comment']);
510 89
        $column->dbType($info['type']);
511 89
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
512 89
        $column->type(self::TYPE_STRING);
513
514 89
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
515 89
            $type = strtolower($matches[1]);
516
517 89
            if (isset($this->typeMap[$type])) {
518 89
                $column->type($this->typeMap[$type]);
519
            }
520
521 89
            if (!empty($matches[2])) {
522 81
                if ($type === 'enum') {
523 20
                    preg_match_all("/'[^']*'/", $matches[2], $values);
524
525 20
                    foreach ($values[0] as $i => $value) {
526 20
                        $values[$i] = trim($value, "'");
527
                    }
528
529 20
                    $column->enumValues($values);
530
                } else {
531 81
                    $values = explode(',', $matches[2]);
532 81
                    $column->precision((int) $values[0]);
533 81
                    $column->size((int) $values[0]);
534
535 81
                    if (isset($values[1])) {
536 32
                        $column->scale((int) $values[1]);
537
                    }
538
539 81
                    if ($column->getSize() === 1 && $type === 'tinyint') {
540 21
                        $column->type('boolean');
541 81
                    } elseif ($type === 'bit') {
542 20
                        if ($column->getSize() > 32) {
543
                            $column->type('bigint');
544 20
                        } elseif ($column->getSize() === 32) {
545
                            $column->type('integer');
546
                        }
547
                    }
548
                }
549
            }
550
        }
551
552 89
        $column->phpType($this->getColumnPhpType($column));
553
554 89
        if (!$column->isPrimaryKey()) {
555
            /**
556
             * When displayed in the INFORMATION_SCHEMA.COLUMNS table, a default CURRENT TIMESTAMP is displayed
557
             * as CURRENT_TIMESTAMP up until MariaDB 10.2.2, and as current_timestamp() from MariaDB 10.2.3.
558
             *
559
             * See details here: https://mariadb.com/kb/en/library/now/#description
560
             */
561
            if (
562 86
                ($column->getType() === 'timestamp' || $column->getType() === 'datetime')
563 86
                && preg_match('/^current_timestamp(?:\((\d*)\))?$/i', (string) $info['default'], $matches)
564
            ) {
565 23
                $column->defaultValue(new Expression('CURRENT_TIMESTAMP' . (!empty($matches[1])
566 23
                    ? '(' . $matches[1] . ')' : '')));
567 83
            } elseif (isset($type) && $type === 'bit') {
568 20
                $column->defaultValue(bindec(trim((string) $info['default'], 'b\'')));
569
            } else {
570 83
                $column->defaultValue($column->phpTypecast($info['default']));
571
            }
572
        }
573
574 89
        return $column;
575
    }
576
577
    /**
578
     * Loads all check constraints for the given table.
579
     *
580
     * @param string $tableName table name.
581
     *
582
     * @throws NotSupportedException
583
     *
584
     * @return array check constraints for the given table.
585
     */
586 12
    protected function loadTableChecks(string $tableName): array
587
    {
588 12
        throw new NotSupportedException('MySQL does not support check constraints.');
589
    }
590
591
    /**
592
     * Loads multiple types of constraints and returns the specified ones.
593
     *
594
     * @param string $tableName table name.
595
     * @param string $returnType return type:
596
     * - primaryKey
597
     * - foreignKeys
598
     * - uniques
599
     *
600
     * @throws Exception|InvalidConfigException|Throwable
601
     *
602
     * @return array|Constraint|null (Constraint|ForeignKeyConstraint)[]|Constraint|null constraints.
603
     */
604 48
    private function loadTableConstraints(string $tableName, string $returnType): array|Constraint|null
605
    {
606 48
        $sql = <<<SQL
607
        SELECT
608
            `kcu`.`CONSTRAINT_NAME` AS `name`,
609
            `kcu`.`COLUMN_NAME` AS `column_name`,
610
            `tc`.`CONSTRAINT_TYPE` AS `type`,
611
        CASE
612
            WHEN :schemaName IS NULL AND `kcu`.`REFERENCED_TABLE_SCHEMA` = DATABASE() THEN NULL
613
        ELSE `kcu`.`REFERENCED_TABLE_SCHEMA`
614
        END AS `foreign_table_schema`,
615
            `kcu`.`REFERENCED_TABLE_NAME` AS `foreign_table_name`,
616
            `kcu`.`REFERENCED_COLUMN_NAME` AS `foreign_column_name`,
617
            `rc`.`UPDATE_RULE` AS `on_update`,
618
            `rc`.`DELETE_RULE` AS `on_delete`,
619
            `kcu`.`ORDINAL_POSITION` AS `position`
620
        FROM `information_schema`.`KEY_COLUMN_USAGE` AS `kcu`
621
        JOIN `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc` ON
622
            `rc`.`CONSTRAINT_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
623
            `rc`.`TABLE_NAME` = `kcu`.`TABLE_NAME` AND
624
            `rc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME`
625
        JOIN `information_schema`.`TABLE_CONSTRAINTS` AS `tc` ON
626
            `tc`.`TABLE_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
627
            `tc`.`TABLE_NAME` = `kcu`.`TABLE_NAME` AND
628
            `tc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME` AND
629
            `tc`.`CONSTRAINT_TYPE` = 'FOREIGN KEY'
630
        WHERE
631
            `kcu`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
632
            `kcu`.`CONSTRAINT_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
633
            `kcu`.`TABLE_NAME` = :tableName
634
        UNION
635
        SELECT
636
            `kcu`.`CONSTRAINT_NAME` AS `name`,
637
            `kcu`.`COLUMN_NAME` AS `column_name`,
638
            `tc`.`CONSTRAINT_TYPE` AS `type`,
639
        NULL AS `foreign_table_schema`,
640
        NULL AS `foreign_table_name`,
641
        NULL AS `foreign_column_name`,
642
        NULL AS `on_update`,
643
        NULL AS `on_delete`,
644
            `kcu`.`ORDINAL_POSITION` AS `position`
645
        FROM `information_schema`.`KEY_COLUMN_USAGE` AS `kcu`
646
        JOIN `information_schema`.`TABLE_CONSTRAINTS` AS `tc` ON
647
            `tc`.`TABLE_SCHEMA` = `kcu`.`TABLE_SCHEMA` AND
648
            `tc`.`TABLE_NAME` = `kcu`.`TABLE_NAME` AND
649
            `tc`.`CONSTRAINT_NAME` = `kcu`.`CONSTRAINT_NAME` AND
650
            `tc`.`CONSTRAINT_TYPE` IN ('PRIMARY KEY', 'UNIQUE')
651
        WHERE
652
            `kcu`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
653
            `kcu`.`TABLE_NAME` = :tableName
654
        ORDER BY `position` ASC
655
        SQL;
656
657 48
        $resolvedName = $this->resolveTableName($tableName);
658
659 48
        $constraints = $this->db->createCommand($sql, [
660 48
            ':schemaName' => $resolvedName->getSchemaName(),
661 48
            ':tableName' => $resolvedName->getName(),
662 48
        ])->queryAll();
663
664
        /** @var array<array-key, array> $constraints */
665 48
        $constraints = $this->normalizePdoRowKeyCase($constraints, true);
666 48
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
667
668 48
        $result = [
669
            'primaryKey' => null,
670
            'foreignKeys' => [],
671
            'uniques' => [],
672
        ];
673
674
        /**
675
         * @var string $type
676
         * @var array $names
677
         */
678 48
        foreach ($constraints as $type => $names) {
679
            /**
680
             * @psalm-var object|string|null $name
681
             * @psalm-var ConstraintArray $constraint
682
             */
683 48
            foreach ($names as $name => $constraint) {
684 48
                switch ($type) {
685 48
                    case 'PRIMARY KEY':
686 37
                        $ct = (new Constraint())
687 37
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'));
688
689 37
                        $result['primaryKey'] = $ct;
690
691 37
                        break;
692 46
                    case 'FOREIGN KEY':
693 10
                        $fk = (new ForeignKeyConstraint())
694 10
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
695 10
                            ->foreignTableName($constraint[0]['foreign_table_name'])
696 10
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
697 10
                            ->onDelete($constraint[0]['on_delete'])
698 10
                            ->onUpdate($constraint[0]['on_update'])
699 10
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
700 10
                            ->name($name);
701
702 10
                        $result['foreignKeys'][] = $fk;
703
704 10
                        break;
705 37
                    case 'UNIQUE':
706 37
                        $ct = (new Constraint())
707 37
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
708 37
                            ->name($name);
709
710 37
                        $result['uniques'][] = $ct;
711
712 37
                        break;
713
                }
714
            }
715
        }
716
717 48
        foreach ($result as $type => $data) {
718 48
            $this->setTableMetadata($tableName, $type, $data);
719
        }
720
721 48
        return $result[$returnType];
722
    }
723
724
    /**
725
     * Loads all default value constraints for the given table.
726
     *
727
     * @param string $tableName table name.
728
     *
729
     * @throws NotSupportedException
730
     *
731
     * @return array default value constraints for the given table.
732
     */
733 12
    protected function loadTableDefaultValues(string $tableName): array
734
    {
735 12
        throw new NotSupportedException('MySQL does not support default value constraints.');
736
    }
737
738
    /**
739
     * Loads all foreign keys for the given table.
740
     *
741
     * @param string $tableName table name.
742
     *
743
     * @throws Exception|InvalidConfigException|Throwable
744
     *
745
     * @return array foreign keys for the given table.
746
     */
747 4
    protected function loadTableForeignKeys(string $tableName): array
748
    {
749 4
        $tableForeignKeys = $this->loadTableConstraints($tableName, 'foreignKeys');
750
751 4
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
752
    }
753
754
    /**
755
     * Loads all indexes for the given table.
756
     *
757
     * @param string $tableName table name.
758
     *
759
     * @throws Exception|InvalidConfigException|Throwable
760
     *
761
     * @return IndexConstraint[] indexes for the given table.
762
     */
763 28
    protected function loadTableIndexes(string $tableName): array
764
    {
765 28
        $sql = <<<SQL
766
        SELECT
767
            `s`.`INDEX_NAME` AS `name`,
768
            `s`.`COLUMN_NAME` AS `column_name`,
769
            `s`.`NON_UNIQUE` ^ 1 AS `index_is_unique`,
770
            `s`.`INDEX_NAME` = 'PRIMARY' AS `index_is_primary`
771
        FROM `information_schema`.`STATISTICS` AS `s`
772
        WHERE
773
            `s`.`TABLE_SCHEMA` = COALESCE(:schemaName, DATABASE()) AND
774
            `s`.`INDEX_SCHEMA` = `s`.`TABLE_SCHEMA` AND
775
            `s`.`TABLE_NAME` = :tableName
776
        ORDER BY `s`.`SEQ_IN_INDEX` ASC
777
        SQL;
778
779 28
        $resolvedName = $this->resolveTableName($tableName);
780
781 28
        $indexes = $this->db->createCommand($sql, [
782 28
            ':schemaName' => $resolvedName->getSchemaName(),
783 28
            ':tableName' => $resolvedName->getName(),
784 28
        ])->queryAll();
785
786
        /** @var array[] $indexes */
787 28
        $indexes = $this->normalizePdoRowKeyCase($indexes, true);
788 28
        $indexes = ArrayHelper::index($indexes, null, 'name');
789 28
        $result = [];
790
791
        /**
792
         * @psalm-var object|string|null $name
793
         * @psalm-var array[] $index
794
         */
795 28
        foreach ($indexes as $name => $index) {
796 28
            $ic = new IndexConstraint();
797
798 28
            $ic->primary((bool) $index[0]['index_is_primary']);
799 28
            $ic->unique((bool) $index[0]['index_is_unique']);
800 28
            $ic->name($name !== 'PRIMARY' ? $name : null);
801 28
            $ic->columnNames(ArrayHelper::getColumn($index, 'column_name'));
802
803 28
            $result[] = $ic;
804
        }
805
806 28
        return $result;
807
    }
808
809
    /**
810
     * Loads a primary key for the given table.
811
     *
812
     * @param string $tableName table name.
813
     *
814
     * @throws Exception|InvalidConfigException|Throwable
815
     *
816
     * @return Constraint|null primary key for the given table, `null` if the table has no primary key.*
817
     */
818 31
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
819
    {
820 31
        $tablePrimaryKey = $this->loadTableConstraints($tableName, 'primaryKey');
821
822 31
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
823
    }
824
825
    /**
826
     * Loads the metadata for the specified table.
827
     *
828
     * @param string $name table name.
829
     *
830
     * @throws Exception|Throwable
831
     *
832
     * @return TableSchema|null DBMS-dependent table metadata, `null` if the table does not exist.
833
     */
834 94
    protected function loadTableSchema(string $name): ?TableSchema
835
    {
836 94
        $table = new TableSchema();
837
838 94
        $this->resolveTableNames($table, $name);
839
840 94
        if ($this->findColumns($table)) {
841 88
            $this->findConstraints($table);
842
843 88
            return $table;
844
        }
845
846 15
        return null;
847
    }
848
849
    /**
850
     * Loads all unique constraints for the given table.
851
     *
852
     * @param string $tableName table name.
853
     *
854
     * @throws Exception|InvalidConfigException|Throwable
855
     *
856
     * @return array unique constraints for the given table.
857
     */
858 13
    protected function loadTableUniques(string $tableName): array
859
    {
860 13
        $tableUniques = $this->loadTableConstraints($tableName, 'uniques');
861
862 13
        return is_array($tableUniques) ? $tableUniques : [];
863
    }
864
865
    /**
866
     * Changes row's array key case to lower if PDO's one is set to uppercase.
867
     *
868
     * @param array $row row's array or an array of row's arrays.
869
     * @param bool $multiple whether multiple rows or a single row passed.
870
     *
871
     * @throws \Exception
872
     *
873
     * @return array normalized row or rows.
874
     */
875 58
    protected function normalizePdoRowKeyCase(array $row, bool $multiple): array
876
    {
877 58
        if ($this->db->getSlavePdo()?->getAttribute(PDO::ATTR_CASE) !== PDO::CASE_UPPER) {
878 46
            return $row;
879
        }
880
881 12
        if ($multiple) {
882 12
            return array_map(static function (array $row) {
883 12
                return array_change_key_case($row, CASE_LOWER);
884
            }, $row);
885
        }
886
887
        return array_change_key_case($row, CASE_LOWER);
888
    }
889
890
    /**
891
     * Resolves the table name and schema name (if any).
892
     *
893
     * @param string $name the table name.
894
     *
895
     * @return TableSchema
896
     *
897
     * {@see TableSchema}
898
     */
899 58
    protected function resolveTableName(string $name): TableSchema
900
    {
901 58
        $resolvedName = new TableSchema();
902
903 58
        $parts = explode('.', str_replace('`', '', $name));
904
905 58
        if (isset($parts[1])) {
906
            $resolvedName->schemaName($parts[0]);
907
            $resolvedName->name($parts[1]);
908
        } else {
909 58
            $resolvedName->schemaName($this->defaultSchema);
910 58
            $resolvedName->name($name);
911
        }
912
913 58
        $resolvedName->fullName(($resolvedName->getSchemaName() !== $this->defaultSchema ?
914 58
            (string) $resolvedName->getSchemaName() . '.' : '') . $resolvedName->getName());
915
916 58
        return $resolvedName;
917
    }
918
919
    /**
920
     * Resolves the table name and schema name (if any).
921
     *
922
     * @param TableSchema $table the table metadata object.
923
     * @param string $name the table name.
924
     */
925 94
    protected function resolveTableNames(TableSchema $table, string $name): void
926
    {
927 94
        $parts = explode('.', str_replace('`', '', $name));
928
929 94
        if (isset($parts[1])) {
930
            $table->schemaName($parts[0]);
931
            $table->name($parts[1]);
932
            $table->fullName((string) $table->getSchemaName() . '.' . $table->getName());
933
        } else {
934 94
            $table->name($parts[0]);
935 94
            $table->fullName($parts[0]);
936
        }
937
    }
938
939
    /**
940
     * Creates a column schema for the database.
941
     *
942
     * This method may be overridden by child classes to create a DBMS-specific column schema.
943
     *
944
     * @return ColumnSchema column schema instance.
945
     */
946 89
    private function createColumnSchema(): ColumnSchema
947
    {
948 89
        return new ColumnSchema();
949
    }
950
}
951