Passed
Push — dev ( 7238d4...ac8c0f )
by Def
03:37
created

Schema::findForeignKeys()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 52
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 5.0042

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 31
c 1
b 0
f 0
nc 12
nop 1
dl 0
loc 52
ccs 17
cts 18
cp 0.9444
crap 5.0042
rs 9.1128

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\Mssql;
6
7
use Throwable;
8
use Yiisoft\Arrays\ArrayHelper;
9
use Yiisoft\Db\Cache\SchemaCache;
10
use Yiisoft\Db\Connection\ConnectionInterface;
11
use Yiisoft\Db\Constraint\CheckConstraint;
12
use Yiisoft\Db\Constraint\Constraint;
13
use Yiisoft\Db\Constraint\DefaultValueConstraint;
14
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
15
use Yiisoft\Db\Constraint\IndexConstraint;
16
use Yiisoft\Db\Exception\Exception;
17
use Yiisoft\Db\Exception\InvalidConfigException;
18
use Yiisoft\Db\Schema\ColumnSchemaBuilder;
19
use Yiisoft\Db\Schema\Schema as AbstractSchema;
20
use Yiisoft\Db\Schema\TableSchemaInterface;
21
22
use function array_change_key_case;
23
use function array_map;
24
use function count;
25
use function explode;
26
use function is_array;
27
use function md5;
28
use function preg_match;
29
use function preg_match_all;
30
use function preg_replace;
31
use function serialize;
32
use function str_contains;
33
use function str_replace;
34
use function strcasecmp;
35
use function stripos;
36
37
/**
38
 * Schema is the class for retrieving metadata from MS SQL Server databases (version 2008 and above).
39
 *
40
 * @psalm-type ColumnArray = array{
41
 *   column_name: string,
42
 *   is_nullable: string,
43
 *   data_type: string,
44
 *   column_default: mixed,
45
 *   is_identity: string,
46
 *   comment: null|string
47
 * }
48
 *
49
 * @psalm-type ConstraintArray = array<
50
 *   array-key,
51
 *   array {
52
 *     name: string,
53
 *     column_name: string,
54
 *     type: string,
55
 *     foreign_table_schema: string|null,
56
 *     foreign_table_name: string|null,
57
 *     foreign_column_name: string|null,
58
 *     on_update: string,
59
 *     on_delete: string,
60
 *     check_expr: string,
61
 *     default_expr: string
62
 *   }
63
 * >
64
 */
65
final class Schema extends AbstractSchema
66
{
67
    public const DEFAULTS = 'defaults';
68
69
    /**
70
     * @var string|null the default schema used for the current session.
71
     */
72
    protected ?string $defaultSchema = 'dbo';
73
74
    /**
75
     * @var array mapping from physical column types (keys) to abstract column types (values)
76
     *
77
     * @psalm-var string[]
78
     */
79
    private array $typeMap = [
80
        /** exact numbers */
81
        'bigint' => self::TYPE_BIGINT,
82
        'numeric' => self::TYPE_DECIMAL,
83
        'bit' => self::TYPE_SMALLINT,
84
        'smallint' => self::TYPE_SMALLINT,
85
        'decimal' => self::TYPE_DECIMAL,
86
        'smallmoney' => self::TYPE_MONEY,
87
        'int' => self::TYPE_INTEGER,
88
        'tinyint' => self::TYPE_TINYINT,
89
        'money' => self::TYPE_MONEY,
90
91
        /** approximate numbers */
92
        'float' => self::TYPE_FLOAT,
93
        'double' => self::TYPE_DOUBLE,
94
        'real' => self::TYPE_FLOAT,
95
96
        /** date and time */
97
        'date' => self::TYPE_DATE,
98
        'datetimeoffset' => self::TYPE_DATETIME,
99
        'datetime2' => self::TYPE_DATETIME,
100
        'smalldatetime' => self::TYPE_DATETIME,
101
        'datetime' => self::TYPE_DATETIME,
102
        'time' => self::TYPE_TIME,
103
104
        /** character strings */
105
        'char' => self::TYPE_CHAR,
106
        'varchar' => self::TYPE_STRING,
107
        'text' => self::TYPE_TEXT,
108
109
        /** unicode character strings */
110
        'nchar' => self::TYPE_CHAR,
111
        'nvarchar' => self::TYPE_STRING,
112
        'ntext' => self::TYPE_TEXT,
113
114
        /** binary strings */
115
        'binary' => self::TYPE_BINARY,
116
        'varbinary' => self::TYPE_BINARY,
117
        'image' => self::TYPE_BINARY,
118
119
        /**
120
         * other data types 'cursor' type cannot be used with tables
121
         */
122
        'timestamp' => self::TYPE_TIMESTAMP,
123
        'hierarchyid' => self::TYPE_STRING,
124
        'uniqueidentifier' => self::TYPE_STRING,
125
        'sql_variant' => self::TYPE_STRING,
126
        'xml' => self::TYPE_STRING,
127
        'table' => self::TYPE_STRING,
128
    ];
129
130 387
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
131
    {
132 387
        parent::__construct($schemaCache);
133
    }
134
135
    /**
136
     * Resolves the table name and schema name (if any).
137
     *
138
     * @param string $name the table name.
139
     *
140
     * @return TableSchemaInterface resolved table, schema, etc. names.
141
     *
142
     * @todo Review this method and see if it can be simplified @darkdef.
143
     */
144 90
    protected function resolveTableName(string $name): TableSchemaInterface
145
    {
146 90
        $resolvedName = new TableSchema();
147 90
        $parts = $this->getTableNameParts($name);
148 90
        $partCount = count($parts);
149
150 90
        if ($partCount === 4) {
151
            /** server name, catalog name, schema name and table name passed - not coverage tests */
152
            $resolvedName->catalogName($parts[1]);
153
            $resolvedName->schemaName($parts[2]);
154
            $resolvedName->name($parts[3]);
155
            $resolvedName->fullName(
156
                (string) $resolvedName->getCatalogName() . '.' .
157
                (string) $resolvedName->getSchemaName() . '.' .
158
                $resolvedName->getName()
159
            );
160 90
        } elseif ($partCount === 3) {
161
            /** catalog name, schema name and table name passed - not coverage tests */
162
            $resolvedName->catalogName($parts[0]);
163
            $resolvedName->schemaName($parts[1]);
164
            $resolvedName->name($parts[2]);
165
            $resolvedName->fullName(
166
                (string) $resolvedName->getCatalogName() . '.' .
167
                (string) $resolvedName->getSchemaName() . '.' .
168
                $resolvedName->getName()
169
            );
170 90
        } elseif ($partCount === 2) {
171
            /** only schema name and table name passed - not coverage tests */
172
            $resolvedName->schemaName($parts[0]);
173
            $resolvedName->name($parts[1]);
174
            $resolvedName->fullName(
175
                (
176
                    $resolvedName->getSchemaName() !== $this->defaultSchema
177
                    ? (string) $resolvedName->getSchemaName() . '.' : ''
178
                ) . $resolvedName->getName()
179
            );
180
        } else {
181
            /** only table name passed */
182 90
            $resolvedName->schemaName($this->defaultSchema);
183 90
            $resolvedName->name($parts[0]);
184 90
            $resolvedName->fullName($resolvedName->getName());
185
        }
186
187 90
        return $resolvedName;
188
    }
189
190
    /**
191
     * Splits full table name into parts.
192
     *
193
     * @param string $name
194
     *
195
     * @return array
196
     *
197
     * @psalm-return string[]
198
     */
199 177
    protected function getTableNameParts(string $name): array
200
    {
201 177
        $parts = [$name];
202
203 177
        preg_match_all('/([^.\[\]]+)|\[([^\[\]]+)]/', $name, $matches);
204
205 177
        if (isset($matches[0]) && !empty($matches[0])) {
206 177
            $parts = $matches[0];
207
        }
208
209
        /** @psalm-var string[] */
210 177
        return str_replace(['[', ']'], '', $parts);
211
    }
212
213
    /**
214
     * Returns all schema names in the database, including the default one but not system schemas.
215
     *
216
     * This method should be overridden by child classes in order to support this feature because the default
217
     * implementation simply throws an exception.
218
     *
219
     * @throws Exception|InvalidConfigException|Throwable
220
     *
221
     * @return array all schema names in the database, except system schemas.
222
     *
223
     * {@see https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-principals-transact-sql}
224
     */
225 1
    protected function findSchemaNames(): array
226
    {
227 1
        $sql = <<<SQL
228
        SELECT [s].[name]
229
        FROM [sys].[schemas] AS [s]
230
        INNER JOIN [sys].[database_principals] AS [p] ON [p].[principal_id] = [s].[principal_id]
231
        WHERE [p].[is_fixed_role] = 0 AND [p].[sid] IS NOT NULL
232
        ORDER BY [s].[name] ASC
233
        SQL;
234
235 1
        $schemaNames = $this->db->createCommand($sql)->queryColumn();
236 1
        if (!$schemaNames) {
237
            return [];
238
        }
239
240 1
        return $schemaNames;
241
    }
242
243
    /**
244
     * Returns all table names in the database.
245
     *
246
     * This method should be overridden by child classes in order to support this feature because the default
247
     * implementation simply throws an exception.
248
     *
249
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
250
     *
251
     * @throws Exception|InvalidConfigException|Throwable
252
     *
253
     * @return array all table names in the database. The names have NO schema name prefix.
254
     */
255 11
    protected function findTableNames(string $schema = ''): array
256
    {
257 11
        if ($schema === '') {
258 11
            $schema = $this->defaultSchema;
259
        }
260
261 11
        $sql = <<<SQL
262
        SELECT [t].[table_name]
263
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
264
        WHERE [t].[table_schema] = :schema AND [t].[table_type] IN ('BASE TABLE', 'VIEW')
265
        ORDER BY [t].[table_name]
266
        SQL;
267
268 11
        $tables = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
269 11
        if (!$tables) {
270
            return [];
271
        }
272
273 11
        return array_map(static fn (string $item): string => '[' . $item . ']', $tables);
274
    }
275
276
    /**
277
     * Loads the metadata for the specified table.
278
     *
279
     * @param string $name table name.
280
     *
281
     * @throws Exception|InvalidConfigException|Throwable
282
     *
283
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table does not exist.
284
     */
285 111
    protected function loadTableSchema(string $name): ?TableSchemaInterface
286
    {
287 111
        $table = new TableSchema();
288
289 111
        $this->resolveTableNames($table, $name);
290 111
        $this->findPrimaryKeys($table);
291
292 111
        if ($this->findColumns($table)) {
293 102
            $this->findForeignKeys($table);
294 102
            return $table;
295
        }
296
297 19
        return null;
298
    }
299
300
    /**
301
     * Loads a primary key for the given table.
302
     *
303
     * @param string $tableName table name.
304
     *
305
     * @throws Exception|InvalidConfigException|Throwable
306
     *
307
     * @return Constraint|null The primary key for the given table, `null` if the table has no primary key.
308
     */
309 32
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
310
    {
311
        /** @var mixed */
312 32
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
313 32
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
314
    }
315
316
    /**
317
     * Loads all foreign keys for the given table.
318
     *
319
     * @param string $tableName table name.
320
     *
321
     * @throws Exception|InvalidConfigException|Throwable
322
     *
323
     * @return array The foreign keys for the given table.
324
     */
325 5
    protected function loadTableForeignKeys(string $tableName): array
326
    {
327
        /** @var mixed */
328 5
        $tableForeingKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
329 5
        return is_array($tableForeingKeys) ? $tableForeingKeys : [];
330
    }
331
332
    /**
333
     * Loads all indexes for the given table.
334
     *
335
     * @param string $tableName table name.
336
     *
337
     * @throws Exception|InvalidConfigException|Throwable
338
     *
339
     * @return array indexes for the given table.
340
     */
341 30
    protected function loadTableIndexes(string $tableName): array
342
    {
343 30
        $sql = <<<SQL
344
        SELECT
345
            [i].[name] AS [name],
346
            [iccol].[name] AS [column_name],
347
            [i].[is_unique] AS [index_is_unique],
348
            [i].[is_primary_key] AS [index_is_primary]
349
        FROM [sys].[indexes] AS [i]
350
        INNER JOIN [sys].[index_columns] AS [ic]
351
            ON [ic].[object_id] = [i].[object_id] AND [ic].[index_id] = [i].[index_id]
352
        INNER JOIN [sys].[columns] AS [iccol]
353
            ON [iccol].[object_id] = [ic].[object_id] AND [iccol].[column_id] = [ic].[column_id]
354
        WHERE [i].[object_id] = OBJECT_ID(:fullName)
355
        ORDER BY [ic].[key_ordinal] ASC
356
        SQL;
357
358 30
        $resolvedName = $this->resolveTableName($tableName);
359 30
        $indexes = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
360
361
        /** @psalm-var array[] $indexes */
362 30
        $indexes = $this->normalizeRowKeyCase($indexes, true);
363 30
        $indexes = ArrayHelper::index($indexes, null, 'name');
364
365 30
        $result = [];
366
367
        /**
368
         * @psalm-var array<
369
         *   string,
370
         *   array<
371
         *     array-key,
372
         *     array{array-key, name: string, column_name: string, index_is_unique: string, index_is_primary: string}
373
         *   >
374
         * > $indexes
375
         */
376 30
        foreach ($indexes as $name => $index) {
377 27
            $result[] = (new IndexConstraint())
378 27
                ->primary((bool) $index[0]['index_is_primary'])
379 27
                ->unique((bool) $index[0]['index_is_unique'])
380 27
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
381 27
                ->name($name);
382
        }
383
384 30
        return $result;
385
    }
386
387
    /**
388
     * Loads all unique constraints for the given table.
389
     *
390
     * @param string $tableName table name.
391
     *
392
     * @throws Exception|InvalidConfigException|Throwable
393
     *
394
     * @return array The unique constraints for the given table.
395
     */
396 14
    protected function loadTableUniques(string $tableName): array
397
    {
398
        /** @var mixed */
399 14
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
400 14
        return is_array($tableUniques) ? $tableUniques : [];
401
    }
402
403
    /**
404
     * Loads all check constraints for the given table.
405
     *
406
     * @param string $tableName table name.
407
     *
408
     * @throws Exception|InvalidConfigException|Throwable
409
     *
410
     * @return array The check constraints for the given table.
411
     */
412 14
    protected function loadTableChecks(string $tableName): array
413
    {
414
        /** @var mixed */
415 14
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
416 14
        return is_array($tableCheck) ? $tableCheck : [];
417
    }
418
419
    /**
420
     * Loads all default value constraints for the given table.
421
     *
422
     * @param string $tableName table name.
423
     *
424
     * @throws Exception|InvalidConfigException|Throwable
425
     *
426
     * @return array The default value constraints for the given table.
427
     */
428 14
    protected function loadTableDefaultValues(string $tableName): array
429
    {
430
        /** @var mixed */
431 14
        $tableDefault = $this->loadTableConstraints($tableName, self::DEFAULTS);
432 14
        return is_array($tableDefault) ? $tableDefault : [];
433
    }
434
435
    /**
436
     * Creates a column schema for the database.
437
     *
438
     * This method may be overridden by child classes to create a DBMS-specific column schema.
439
     *
440
     * @return ColumnSchema column schema instance.
441
     */
442 102
    protected function createColumnSchema(): ColumnSchema
443
    {
444 102
        return new ColumnSchema();
445
    }
446
447
    /**
448
     * Resolves the table name and schema name (if any).
449
     *
450
     * @param TableSchemaInterface $table the table metadata object.
451
     * @param string $name the table name
452
     *
453
     * @todo Review this method and see if it can be simplified @darkdef.
454
     */
455 111
    protected function resolveTableNames(TableSchemaInterface $table, string $name): void
456
    {
457 111
        $parts = $this->getTableNameParts($name);
458
459 111
        $partCount = count($parts);
460
461 111
        if ($partCount === 4) {
462
            /** server name, catalog name, schema name and table name passed - no coverage tests */
463
            $table->catalogName($parts[1]);
464
            $table->schemaName($parts[2]);
465
            $table->name($parts[3]);
466
            $table->fullName(
467
                (string) $table->getCatalogName() . '.' . (string) $table->getSchemaName() . '.' . $table->getName()
468
            );
469 111
        } elseif ($partCount === 3) {
470
            /** catalog name, schema name and table name passed - no coverage tests */
471
            $table->catalogName($parts[0]);
472
            $table->schemaName($parts[1]);
473
            $table->name($parts[2]);
474
            $table->fullName(
475
                (string) $table->getCatalogName() . '.' . (string) $table->getSchemaName() . '.' . $table->getName()
476
            );
477 111
        } elseif ($partCount === 2) {
478
            /** only schema name and table name passed - no coverage tests */
479 3
            $table->schemaName($parts[0]);
480 3
            $table->name($parts[1]);
481 3
            $table->fullName(
482 3
                $table->getSchemaName() !== $this->defaultSchema
483 3
                ? (string) $table->getSchemaName() . '.' . $table->getName() : $table->getName()
484
            );
485
        } else {
486
            /** only table name passed */
487 108
            $table->schemaName($this->defaultSchema);
488 108
            $table->name($parts[0]);
489 108
            $table->fullName($table->getName());
490
        }
491
    }
492
493
    /**
494
     * Loads the column information into a {@see ColumnSchema} object.
495
     *
496
     * @psalm-param ColumnArray $info The column information.
497
     *
498
     * @return ColumnSchema the column schema object.
499
     */
500 102
    protected function loadColumnSchema(array $info): ColumnSchema
501
    {
502 102
        $column = $this->createColumnSchema();
503
504 102
        $column->name($info['column_name']);
505 102
        $column->allowNull($info['is_nullable'] === 'YES');
506 102
        $column->dbType($info['data_type']);
507 102
        $column->enumValues([]); // mssql has only vague equivalents to enum
508 102
        $column->primaryKey(false); // primary key will be determined in findColumns() method
509 102
        $column->autoIncrement($info['is_identity'] === '1');
510 102
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
511 102
        $column->comment($info['comment'] ?? '');
512 102
        $column->type(self::TYPE_STRING);
513
514 102
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
515 102
            $type = $matches[1];
516
517 102
            if (isset($this->typeMap[$type])) {
518 102
                $column->type($this->typeMap[$type]);
519
            }
520
521 102
            if (!empty($matches[2])) {
522 86
                $values = explode(',', $matches[2]);
523 86
                $column->precision((int) $values[0]);
524 86
                $column->size((int) $values[0]);
525
526 86
                if (isset($values[1])) {
527
                    $column->scale((int) $values[1]);
528
                }
529
530 86
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
531
                    $column->type(self::TYPE_BOOLEAN);
532 86
                } elseif ($type === 'bit') {
533
                    if ($column->getSize() > 32) {
534
                        $column->type(self::TYPE_BIGINT);
535
                    } elseif ($column->getSize() === 32) {
536
                        $column->type(self::TYPE_INTEGER);
537
                    }
538
                }
539
            }
540
        }
541
542 102
        $column->phpType($this->getColumnPhpType($column));
543
544 102
        if ($info['column_default'] === '(NULL)') {
545 4
            $info['column_default'] = null;
546
        }
547
548 102
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
549 102
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
550
        }
551
552 102
        return $column;
553
    }
554
555
    /**
556
     * Collects the metadata of table columns.
557
     *
558
     * @param TableSchemaInterface $table the table metadata.
559
     *
560
     * @throws Throwable
561
     *
562
     * @return bool whether the table exists in the database.
563
     */
564 111
    protected function findColumns(TableSchemaInterface $table): bool
565
    {
566 111
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
567 111
        $whereSql = '[t1].[table_name] = ' . (string) $this->db->getQuoter()->quoteValue($table->getName());
568
569 111
        if ($table->getCatalogName() !== null) {
570
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
571
            $whereSql .= " AND [t1].[table_catalog] = '{$table->getCatalogName()}'";
572
        }
573
574 111
        if ($table->getSchemaName() !== null) {
575 111
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
576
        }
577
578 111
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
579
580 111
        $sql = <<<SQL
581
        SELECT
582
            [t1].[column_name],
583
            [t1].[is_nullable],
584
        CASE WHEN [t1].[data_type] IN ('char','varchar','nchar','nvarchar','binary','varbinary') THEN
585
        CASE WHEN [t1].[character_maximum_length] = NULL OR [t1].[character_maximum_length] = -1 THEN
586
            [t1].[data_type]
587
        ELSE
588
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[character_maximum_length]))) + ')'
589
        END
590
        ELSE
591
            [t1].[data_type]
592
        END AS 'data_type',
593
        [t1].[column_default],
594
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsIdentity') AS is_identity,
595
        (
596
        SELECT CONVERT(VARCHAR, [t2].[value])
597
        FROM [sys].[extended_properties] AS [t2]
598
        WHERE
599
        [t2].[class] = 1 AND
600
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
601
        [t2].[name] = 'MS_Description' AND
602
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
603
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
604
        ) as comment
605
        FROM $columnsTableName AS [t1]
606
        WHERE $whereSql
607
        SQL;
608
609
        try {
610
            /** @psalm-var ColumnArray[] */
611 111
            $columns = $this->db->createCommand($sql)->queryAll();
612
613 111
            if (empty($columns)) {
614 111
                return false;
615
            }
616
        } catch (Exception) {
617
            return false;
618
        }
619
620 102
        foreach ($columns as $column) {
621 102
            $column = $this->loadColumnSchema($column);
622 102
            foreach ($table->getPrimaryKey() as $primaryKey) {
623 68
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
624 68
                    $column->primaryKey(true);
625 68
                    break;
626
                }
627
            }
628
629 102
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
630 67
                $table->sequenceName('');
631
            }
632
633 102
            $table->columns($column->getName(), $column);
634
        }
635
636 102
        return true;
637
    }
638
639
    /**
640
     * Collects the constraint details for the given table and constraint type.
641
     *
642
     * @param TableSchemaInterface $table
643
     * @param string $type either PRIMARY KEY or UNIQUE.
644
     *
645
     * @throws Exception|InvalidConfigException|Throwable
646
     *
647
     * @return array each entry contains index_name and field_name.
648
     */
649 111
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
650
    {
651 111
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
652 111
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
653
654 111
        $catalogName = $table->getCatalogName();
655 111
        if ($catalogName !== null) {
656
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
657
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
658
        }
659
660 111
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
661 111
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
662
663 111
        $sql = <<<SQL
664
        SELECT
665
            [kcu].[constraint_name] AS [index_name],
666
            [kcu].[column_name] AS [field_name]
667
        FROM $keyColumnUsageTableName AS [kcu]
668
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
669
            [kcu].[table_schema] = [tc].[table_schema] AND
670
            [kcu].[table_name] = [tc].[table_name] AND
671
            [kcu].[constraint_name] = [tc].[constraint_name]
672
        WHERE
673
            [tc].[constraint_type] = :type AND
674
            [kcu].[table_name] = :tableName AND
675
            [kcu].[table_schema] = :schemaName
676
        SQL;
677
678 111
        return $this->db->createCommand(
679
            $sql,
680
            [
681 111
                ':tableName' => $table->getName(),
682 111
                ':schemaName' => $table->getSchemaName(),
683
                ':type' => $type,
684
            ]
685 111
        )->queryAll();
686
    }
687
688
    /**
689
     * Collects the primary key column details for the given table.
690
     *
691
     * @param TableSchemaInterface $table the table metadata
692
     *
693
     * @throws Exception|InvalidConfigException|Throwable
694
     */
695 111
    protected function findPrimaryKeys(TableSchemaInterface $table): void
696
    {
697
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
698 111
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
699
700 111
        foreach ($primaryKeys as $row) {
701 68
            $table->primaryKey($row['field_name']);
702
        }
703
    }
704
705
    /**
706
     * Collects the foreign key column details for the given table.
707
     *
708
     * @param TableSchemaInterface $table the table metadata
709
     *
710
     * @throws Exception|InvalidConfigException|Throwable
711
     */
712 102
    protected function findForeignKeys(TableSchemaInterface $table): void
713
    {
714 102
        $catalogName = $table->getCatalogName();
715 102
        $fk = [];
716 102
        $object = $table->getName();
717 102
        $schemaName = $table->getSchemaName();
718
719 102
        if ($schemaName !== null) {
720 102
            $object = $schemaName . '.' . $object;
721
        }
722
723 102
        if ($catalogName !== null) {
724
            $object = $catalogName . '.' . $object;
725
        }
726
727
        /**
728
         * Please refer to the following page for more details:
729
         * {@see http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx}
730
         */
731 102
        $sql = <<<SQL
732
        SELECT
733
        [fk].[name] AS [fk_name],
734
        [cp].[name] AS [fk_column_name],
735
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
736
        [cr].[name] AS [uq_column_name]
737
        FROM [sys].[foreign_keys] AS [fk]
738
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
739
            ON [fk].[object_id] = [fkc].[constraint_object_id]
740
        INNER JOIN [sys].[columns] AS [cp]
741
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
742
        INNER JOIN [sys].[columns] AS [cr]
743
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
744
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
745
        SQL;
746
747
        /**
748
         * @psalm-var array<
749
         *   array-key,
750
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
751
         * > $rows
752
         */
753 102
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
754 102
        $table->foreignKeys([]);
755
756 102
        foreach ($rows as $row) {
757 5
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
758 5
                $fk[$row['fk_name']][] = $row['uq_table_name'];
759 5
                $table->foreignKeys($fk);
760
            }
761
762 5
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
763 5
            $table->foreignKeys($fk);
764
        }
765
    }
766
767
    /**
768
     * @throws Exception|InvalidConfigException|Throwable
769
     */
770 2
    protected function findViewNames(string $schema = ''): array
771
    {
772 2
        if ($schema === '') {
773 1
            $schema = $this->defaultSchema;
774
        }
775
776 2
        $sql = <<<SQL
777
        SELECT [t].[table_name]
778
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
779
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
780
        ORDER BY [t].[table_name]
781
        SQL;
782
783 2
        $views = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
784 2
        if (!$views) {
785
            return [];
786
        }
787
788 2
        return array_map(static fn (string $item): string => '[' . $item . ']', $views);
789
    }
790
791
    /**
792
     * Returns all unique indexes for the given table.
793
     *
794
     * Each array element is of the following structure:
795
     *
796
     * ```php
797
     * [
798
     *     'IndexName1' => ['col1' [, ...]],
799
     *     'IndexName2' => ['col2' [, ...]],
800
     * ]
801
     * ```
802
     *
803
     * @param TableSchemaInterface $table the table metadata.
804
     *
805
     * @throws Exception|InvalidConfigException|Throwable
806
     *
807
     * @return array all unique indexes for the given table.
808
     */
809 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
810
    {
811 1
        $result = [];
812
813
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
814 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
815
816 1
        foreach ($tableUniqueConstraints as $row) {
817 1
            $result[$row['index_name']][] = $row['field_name'];
818
        }
819
820 1
        return $result;
821
    }
822
823
    /**
824
     * Loads multiple types of constraints and returns the specified ones.
825
     *
826
     * @param string $tableName table name.
827
     * @param string $returnType return type:
828
     * - primaryKey
829
     * - foreignKeys
830
     * - uniques
831
     * - checks
832
     * - defaults
833
     *
834
     * @throws Exception|InvalidConfigException|Throwable
835
     *
836
     * @return mixed constraints.
837
     */
838 79
    private function loadTableConstraints(string $tableName, string $returnType): mixed
839
    {
840 79
        $sql = <<<SQL
841
        SELECT
842
            [o].[name] AS [name],
843
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
844
            RTRIM([o].[type]) AS [type],
845
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
846
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
847
            [ffccol].[name] AS [foreign_column_name],
848
            [f].[update_referential_action_desc] AS [on_update],
849
            [f].[delete_referential_action_desc] AS [on_delete],
850
            [c].[definition] AS [check_expr],
851
            [d].[definition] AS [default_expr]
852
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
853
        INNER JOIN [sys].[objects] AS [o]
854
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
855
        LEFT JOIN [sys].[check_constraints] AS [c]
856
            ON [c].[object_id] = [o].[object_id]
857
        LEFT JOIN [sys].[columns] AS [ccol]
858
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
859
        LEFT JOIN [sys].[default_constraints] AS [d]
860
            ON [d].[object_id] = [o].[object_id]
861
        LEFT JOIN [sys].[columns] AS [dcol]
862
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
863
        LEFT JOIN [sys].[key_constraints] AS [k]
864
            ON [k].[object_id] = [o].[object_id]
865
        LEFT JOIN [sys].[index_columns] AS [kic]
866
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
867
        LEFT JOIN [sys].[columns] AS [kiccol]
868
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
869
        LEFT JOIN [sys].[foreign_keys] AS [f]
870
            ON [f].[object_id] = [o].[object_id]
871
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
872
            ON [fc].[constraint_object_id] = [o].[object_id]
873
        LEFT JOIN [sys].[columns] AS [fccol]
874
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
875
        LEFT JOIN [sys].[columns] AS [ffccol]
876
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
877
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
878
        SQL;
879
880 79
        $resolvedName = $this->resolveTableName($tableName);
881 79
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
882
883
        /** @psalm-var array[] $constraints */
884 79
        $constraints = $this->normalizeRowKeyCase($constraints, true);
885 79
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
886
887 79
        $result = [
888
            self::PRIMARY_KEY => null,
889 79
            self::FOREIGN_KEYS => [],
890 79
            self::UNIQUES => [],
891 79
            self::CHECKS => [],
892 79
            self::DEFAULTS => [],
893
        ];
894
895
        /** @psalm-var array<array-key, array> $constraints */
896 79
        foreach ($constraints as $type => $names) {
897
            /**
898
             * @psalm-var object|string|null $name
899
             * @psalm-var ConstraintArray $constraint
900
             */
901 79
            foreach ($names as $name => $constraint) {
902 79
                switch ($type) {
903 79
                    case 'PK':
904
                        /** @var Constraint */
905 58
                        $result[self::PRIMARY_KEY] = (new Constraint())
906 58
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
907 58
                            ->name($name);
908 58
                        break;
909 79
                    case 'F':
910 21
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
911 21
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
912 21
                            ->foreignTableName($constraint[0]['foreign_table_name'])
913 21
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
914 21
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
915 21
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
916 21
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
917 21
                            ->name($name);
918 21
                        break;
919 64
                    case 'UQ':
920 62
                        $result[self::UNIQUES][] = (new Constraint())
921 62
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
922 62
                            ->name($name);
923 62
                        break;
924 36
                    case 'C':
925 18
                        $result[self::CHECKS][] = (new CheckConstraint())
926 18
                            ->expression($constraint[0]['check_expr'])
927 18
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
928 18
                            ->name($name);
929 18
                        break;
930 35
                    case 'D':
931 35
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
932 35
                            ->value($constraint[0]['default_expr'])
933 35
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
934 35
                            ->name($name);
935 35
                        break;
936
                }
937
            }
938
        }
939
940 79
        foreach ($result as $type => $data) {
941 79
            $this->setTableMetadata($tableName, $type, $data);
942
        }
943
944 79
        return $result[$returnType];
945
    }
946
947
    /**
948
     * Create a column schema builder instance giving the type and value precision.
949
     *
950
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
951
     *
952
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
953
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
954
     *
955
     * @return ColumnSchemaBuilder column schema builder instance
956
     *
957
     * @psalm-param array<array-key, string>|int|null|string $length
958
     */
959 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
960
    {
961 3
        return new ColumnSchemaBuilder($type, $length);
962
    }
963
964
    /**
965
     * Returns the actual name of a given table name.
966
     *
967
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
968
     * {@see ConnectionInterface::tablePrefix}.
969
     *
970
     * @param string $name the table name to be converted.
971
     *
972
     * @return string the real name of the given table name.
973
     */
974 177
    public function getRawTableName(string $name): string
975
    {
976 177
        if (str_contains($name, '{{')) {
977 24
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
978
979 24
            return str_replace('%', $this->db->getTablePrefix(), $name);
980
        }
981
982 177
        return $name;
983
    }
984
985
    /**
986
     * Returns the cache key for the specified table name.
987
     *
988
     * @param string $name the table name.
989
     *
990
     * @return array the cache key.
991
     */
992 177
    protected function getCacheKey(string $name): array
993
    {
994 177
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
995
    }
996
997
    /**
998
     * Returns the cache tag name.
999
     *
1000
     * This allows {@see refresh()} to invalidate all cached table schemas.
1001
     *
1002
     * @return string the cache tag name.
1003
     */
1004 177
    protected function getCacheTag(): string
1005
    {
1006 177
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
1007
    }
1008
1009
    /**
1010
     * @return bool whether this DBMS supports [savepoint](http://en.wikipedia.org/wiki/Savepoint).
1011
     */
1012 5
    public function supportsSavepoint(): bool
1013
    {
1014 5
        return $this->db->isSavepointEnabled();
1015
    }
1016
1017
    /**
1018
     * Changes row's array key case to lower.
1019
     *
1020
     * @param array $row row's array or an array of row's arrays.
1021
     * @param bool $multiple whether multiple rows or a single row passed.
1022
     *
1023
     * @return array normalized row or rows.
1024
     */
1025 90
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
1026
    {
1027 90
        if ($multiple) {
1028 90
            return array_map(static function (array $row) {
1029 87
                return array_change_key_case($row, CASE_LOWER);
1030
            }, $row);
1031
        }
1032
1033
        return array_change_key_case($row, CASE_LOWER);
1034
    }
1035
1036
    /**
1037
     * @inheritDoc
1038
     */
1039
    public function getLastInsertID(?string $sequenceName = null): string
1040
    {
1041
        return $this->db->getLastInsertID($sequenceName);
1042
    }
1043
}
1044