Passed
Push — master ( 9edd54...684548 )
by Def
27:15 queued 21:44
created

Schema::findColumns()   B

Complexity

Conditions 10
Paths 36

Size

Total Lines 77
Code Lines 55

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 10.6352

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 10
eloc 55
c 2
b 0
f 0
nc 36
nop 1
dl 0
loc 77
ccs 22
cts 27
cp 0.8148
crap 10.6352
rs 7.1151

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