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