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