Passed
Pull Request — dev (#108)
by Def
07:19 queued 04:51
created

SchemaPDOMysql::loadColumnSchema()   D

Complexity

Conditions 19
Paths 100

Size

Total Lines 75
Code Lines 44

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 19.0389

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 19
eloc 44
c 2
b 0
f 0
nc 100
nop 1
dl 0
loc 75
ccs 40
cts 42
cp 0.9524
crap 19.0389
rs 4.5166

How to fix   Long Method    Complexity   

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

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