Test Failed
Pull Request — master (#111)
by Def
02:45
created

Schema::findColumns()   B

Complexity

Conditions 10
Paths 36

Size

Total Lines 73
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 10.4096

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 10
eloc 52
c 1
b 0
f 0
nc 36
nop 1
dl 0
loc 73
ccs 21
cts 25
cp 0.84
crap 10.4096
rs 7.1806

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