Passed
Push — dev ( 812003...c3a781 )
by Def
19:13 queued 16:35
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
        return [
447 155
            __CLASS__,
448 155
            $this->db->getDriver()->getDsn(),
449 155
            $this->db->getDriver()->getUsername(),
450 155
            $this->getRawTableName($name),
451
        ];
452
    }
453
454
    /**
455
     * Returns the cache tag name.
456
     *
457
     * This allows {@see refresh()} to invalidate all cached table schemas.
458
     *
459
     * @return string the cache tag name.
460
     */
461 155
    protected function getCacheTag(): string
462
    {
463 155
        return md5(serialize([
464
            __CLASS__,
465 155
            $this->db->getDriver()->getDsn(),
466 155
            $this->db->getDriver()->getUsername(),
467
        ]));
468
    }
469
470
    /**
471
     * Gets the CREATE TABLE sql string.
472
     *
473
     * @param TableSchema $table the table metadata.
474
     *
475
     * @throws Exception|InvalidConfigException|Throwable
476
     *
477
     * @return string $sql the result of 'SHOW CREATE TABLE'.
478
     */
479 1
    protected function getCreateTableSql(TableSchema $table): string
480
    {
481 1
        $tableName = $table->getFullName() ?? '';
482
483
        /** @var array<array-key, string> $row */
484 1
        $row = $this->db->createCommand(
485 1
            'SHOW CREATE TABLE ' . $this->db->getQuoter()->quoteTableName($tableName)
486 1
        )->queryOne();
487
488 1
        if (isset($row['Create Table'])) {
489 1
            $sql = $row['Create Table'];
490
        } else {
491
            $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

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