Passed
Pull Request — master (#124)
by Def
24:26 queued 20:17
created

Schema::findColumns()   B

Complexity

Conditions 10
Paths 36

Size

Total Lines 76
Code Lines 54

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 54
c 2
b 0
f 0
nc 36
nop 1
dl 0
loc 76
ccs 22
cts 27
cp 0.8148
crap 10.6352
rs 7.1369

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