Passed
Push — master ( 977cc1...3b4501 )
by Def
29:03 queued 25:40
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 $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 191
    protected function resolveTableName(string $name): TableSchemaInterface
135
    {
136 191
        $resolvedName = new TableSchema();
137
138 191
        $parts = array_reverse(
139 191
            $this->db->getQuoter()->getTableNameParts($name)
140
        );
141
142 191
        $resolvedName->name($parts[0] ?? '');
143 191
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
144 191
        $resolvedName->catalogName($parts[2] ?? null);
145 191
        $resolvedName->serverName($parts[3] ?? null);
146
147 191
        if (empty($parts[2]) && $resolvedName->getSchemaName() === $this->defaultSchema) {
148 185
            $resolvedName->fullName($parts[0]);
149
        } else {
150 6
            $resolvedName->fullName(implode('.', array_reverse($parts)));
151
        }
152
153 191
        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 116
    protected function loadTableSchema(string $name): ?TableSchemaInterface
221
    {
222 116
        $table = $this->resolveTableName($name);
223 116
        $this->findPrimaryKeys($table);
224
225 116
        if ($this->findColumns($table)) {
226 107
            $this->findForeignKeys($table);
227 107
            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
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 107
    protected function createColumnSchema(): ColumnSchema
376
    {
377 107
        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 107
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
388
    {
389 107
        $column = $this->createColumnSchema();
390
391 107
        $column->name($info['column_name']);
392 107
        $column->allowNull($info['is_nullable'] === 'YES');
393 107
        $column->dbType($info['data_type']);
394 107
        $column->enumValues([]); // mssql has only vague equivalents to enum
395 107
        $column->primaryKey(false); // primary key will be determined in findColumns() method
396 107
        $column->autoIncrement($info['is_identity'] === '1');
397 107
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
398 107
        $column->comment($info['comment'] ?? '');
399 107
        $column->type(self::TYPE_STRING);
400
401 107
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
402 107
            $type = $matches[1];
403
404 107
            if (isset($this->typeMap[$type])) {
405 107
                $column->type($this->typeMap[$type]);
406
            }
407
408 107
            if (!empty($matches[2])) {
409 90
                $values = explode(',', $matches[2]);
410 90
                $column->precision((int) $values[0]);
411 90
                $column->size((int) $values[0]);
412
413 90
                if (isset($values[1])) {
414
                    $column->scale((int) $values[1]);
415
                }
416
417 90
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
418
                    $column->type(self::TYPE_BOOLEAN);
419 90
                } elseif ($type === 'bit') {
420
                    if ($column->getSize() > 32) {
421
                        $column->type(self::TYPE_BIGINT);
422
                    } elseif ($column->getSize() === 32) {
423
                        $column->type(self::TYPE_INTEGER);
424
                    }
425
                }
426
            }
427
        }
428
429 107
        $column->phpType($this->getColumnPhpType($column));
430
431 107
        if ($info['column_default'] === '(NULL)') {
432 4
            $info['column_default'] = null;
433
        }
434
435 107
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
436 107
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
437
        }
438
439 107
        return $column;
440
    }
441
442
    /**
443
     * Collects the metadata of table columns.
444
     *
445
     * @param TableSchemaInterface $table the table metadata.
446
     *
447
     * @throws Throwable
448
     *
449
     * @return bool whether the table exists in the database.
450
     */
451 116
    protected function findColumns(TableSchemaInterface $table): bool
452
    {
453 116
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
454
455 116
        $whereParams = [':table_name' => $table->getName()];
456 116
        $whereSql = '[t1].[table_name] = :table_name';
457
458 116
        if ($table->getCatalogName() !== null) {
459
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
460
            $whereSql .= ' AND [t1].[table_catalog] = :catalog';
461
            $whereParams[':catalog'] = $table->getCatalogName();
462
        }
463
464 116
        if ($table->getSchemaName() !== null) {
465 116
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
466
        }
467
468 116
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
469
470 116
        $sql = <<<SQL
471
        SELECT
472
            [t1].[column_name],
473
            [t1].[is_nullable],
474
        CASE WHEN [t1].[data_type] IN ('char','varchar','nchar','nvarchar','binary','varbinary') THEN
475
        CASE WHEN [t1].[character_maximum_length] = NULL OR [t1].[character_maximum_length] = -1 THEN
476
            [t1].[data_type]
477
        ELSE
478
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[character_maximum_length]))) + ')'
479
        END
480
        ELSE
481
            [t1].[data_type]
482
        END AS 'data_type',
483
        [t1].[column_default],
484
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsIdentity') AS is_identity,
485
        (
486
        SELECT CONVERT(VARCHAR, [t2].[value])
487
        FROM [sys].[extended_properties] AS [t2]
488
        WHERE
489
        [t2].[class] = 1 AND
490
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
491
        [t2].[name] = 'MS_Description' AND
492
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
493
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
494
        ) as comment
495
        FROM $columnsTableName AS [t1]
496
        WHERE $whereSql
497
        SQL;
498
499
        try {
500
            /** @psalm-var ColumnArray[] */
501 116
            $columns = $this->db->createCommand($sql, $whereParams)->queryAll();
502
503 116
            if (empty($columns)) {
504 116
                return false;
505
            }
506
        } catch (Exception) {
507
            return false;
508
        }
509
510 107
        foreach ($columns as $column) {
511 107
            $column = $this->loadColumnSchema($column);
512 107
            foreach ($table->getPrimaryKey() as $primaryKey) {
513 70
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
514 70
                    $column->primaryKey(true);
515 70
                    break;
516
                }
517
            }
518
519 107
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
520 69
                $table->sequenceName('');
521
            }
522
523 107
            $table->columns($column->getName(), $column);
524
        }
525
526 107
        return true;
527
    }
528
529
    /**
530
     * Collects the constraint details for the given table and constraint type.
531
     *
532
     * @param TableSchemaInterface $table
533
     * @param string $type either PRIMARY KEY or UNIQUE.
534
     *
535
     * @throws Exception|InvalidConfigException|Throwable
536
     *
537
     * @return array each entry contains index_name and field_name.
538
     */
539 116
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
540
    {
541 116
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
542 116
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
543
544 116
        $catalogName = $table->getCatalogName();
545 116
        if ($catalogName !== null) {
546
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
547
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
548
        }
549
550 116
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
551 116
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
552
553 116
        $sql = <<<SQL
554
        SELECT
555
            [kcu].[constraint_name] AS [index_name],
556
            [kcu].[column_name] AS [field_name]
557
        FROM $keyColumnUsageTableName AS [kcu]
558
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
559
            [kcu].[table_schema] = [tc].[table_schema] AND
560
            [kcu].[table_name] = [tc].[table_name] AND
561
            [kcu].[constraint_name] = [tc].[constraint_name]
562
        WHERE
563
            [tc].[constraint_type] = :type AND
564
            [kcu].[table_name] = :tableName AND
565
            [kcu].[table_schema] = :schemaName
566
        SQL;
567
568 116
        return $this->db->createCommand(
569
            $sql,
570
            [
571 116
                ':tableName' => $table->getName(),
572 116
                ':schemaName' => $table->getSchemaName(),
573
                ':type' => $type,
574
            ]
575 116
        )->queryAll();
576
    }
577
578
    /**
579
     * Collects the primary key column details for the given table.
580
     *
581
     * @param TableSchemaInterface $table the table metadata
582
     *
583
     * @throws Exception|InvalidConfigException|Throwable
584
     */
585 116
    protected function findPrimaryKeys(TableSchemaInterface $table): void
586
    {
587
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
588 116
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
589
590 116
        foreach ($primaryKeys as $row) {
591 70
            $table->primaryKey($row['field_name']);
592
        }
593
    }
594
595
    /**
596
     * Collects the foreign key column details for the given table.
597
     *
598
     * @param TableSchemaInterface $table the table metadata
599
     *
600
     * @throws Exception|InvalidConfigException|Throwable
601
     */
602 107
    protected function findForeignKeys(TableSchemaInterface $table): void
603
    {
604 107
        $catalogName = $table->getCatalogName();
605 107
        $fk = [];
606 107
        $object = $table->getName();
607 107
        $schemaName = $table->getSchemaName();
608
609 107
        if ($schemaName !== null) {
610 107
            $object = $schemaName . '.' . $object;
611
        }
612
613 107
        if ($catalogName !== null) {
614
            $object = $catalogName . '.' . $object;
615
        }
616
617
        /**
618
         * Please refer to the following page for more details:
619
         * {@see http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx}
620
         */
621 107
        $sql = <<<SQL
622
        SELECT
623
        [fk].[name] AS [fk_name],
624
        [cp].[name] AS [fk_column_name],
625
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
626
        [cr].[name] AS [uq_column_name]
627
        FROM [sys].[foreign_keys] AS [fk]
628
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
629
            ON [fk].[object_id] = [fkc].[constraint_object_id]
630
        INNER JOIN [sys].[columns] AS [cp]
631
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
632
        INNER JOIN [sys].[columns] AS [cr]
633
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
634
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
635
        SQL;
636
637
        /**
638
         * @psalm-var array<
639
         *   array-key,
640
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
641
         * > $rows
642
         */
643 107
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
644 107
        $table->foreignKeys([]);
645
646 107
        foreach ($rows as $row) {
647 6
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
648 6
                $fk[$row['fk_name']][] = $row['uq_table_name'];
649 6
                $table->foreignKeys($fk);
650
            }
651
652 6
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
653 6
            $table->foreignKeys($fk);
654
        }
655
    }
656
657
    /**
658
     * @throws Exception|InvalidConfigException|Throwable
659
     */
660 3
    protected function findViewNames(string $schema = ''): array
661
    {
662 3
        if ($schema === '') {
663 1
            $schema = $this->defaultSchema;
664
        }
665
666 3
        $sql = <<<SQL
667
        SELECT [t].[table_name]
668
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
669
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
670
        ORDER BY [t].[table_name]
671
        SQL;
672
673 3
        $views = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
674
675 3
        return array_map(static fn (string $item): string => '[' . $item . ']', $views);
676
    }
677
678
    /**
679
     * Returns all unique indexes for the given table.
680
     *
681
     * Each array element is of the following structure:
682
     *
683
     * ```php
684
     * [
685
     *     'IndexName1' => ['col1' [, ...]],
686
     *     'IndexName2' => ['col2' [, ...]],
687
     * ]
688
     * ```
689
     *
690
     * @param TableSchemaInterface $table the table metadata.
691
     *
692
     * @throws Exception|InvalidConfigException|Throwable
693
     *
694
     * @return array all unique indexes for the given table.
695
     */
696 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
697
    {
698 1
        $result = [];
699
700
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
701 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
702
703 1
        foreach ($tableUniqueConstraints as $row) {
704 1
            $result[$row['index_name']][] = $row['field_name'];
705
        }
706
707 1
        return $result;
708
    }
709
710
    /**
711
     * Loads multiple types of constraints and returns the specified ones.
712
     *
713
     * @param string $tableName table name.
714
     * @param string $returnType return type:
715
     * - primaryKey
716
     * - foreignKeys
717
     * - uniques
718
     * - checks
719
     * - defaults
720
     *
721
     * @throws Exception|InvalidConfigException|Throwable
722
     *
723
     * @return mixed constraints.
724
     */
725 88
    private function loadTableConstraints(string $tableName, string $returnType): mixed
726
    {
727 88
        $sql = <<<SQL
728
        SELECT
729
            [o].[name] AS [name],
730
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
731
            RTRIM([o].[type]) AS [type],
732
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
733
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
734
            [ffccol].[name] AS [foreign_column_name],
735
            [f].[update_referential_action_desc] AS [on_update],
736
            [f].[delete_referential_action_desc] AS [on_delete],
737
            [c].[definition] AS [check_expr],
738
            [d].[definition] AS [default_expr]
739
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
740
        INNER JOIN [sys].[objects] AS [o]
741
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
742
        LEFT JOIN [sys].[check_constraints] AS [c]
743
            ON [c].[object_id] = [o].[object_id]
744
        LEFT JOIN [sys].[columns] AS [ccol]
745
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
746
        LEFT JOIN [sys].[default_constraints] AS [d]
747
            ON [d].[object_id] = [o].[object_id]
748
        LEFT JOIN [sys].[columns] AS [dcol]
749
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
750
        LEFT JOIN [sys].[key_constraints] AS [k]
751
            ON [k].[object_id] = [o].[object_id]
752
        LEFT JOIN [sys].[index_columns] AS [kic]
753
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
754
        LEFT JOIN [sys].[columns] AS [kiccol]
755
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
756
        LEFT JOIN [sys].[foreign_keys] AS [f]
757
            ON [f].[object_id] = [o].[object_id]
758
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
759
            ON [fc].[constraint_object_id] = [o].[object_id]
760
        LEFT JOIN [sys].[columns] AS [fccol]
761
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
762
        LEFT JOIN [sys].[columns] AS [ffccol]
763
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
764
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
765
        SQL;
766
767 88
        $resolvedName = $this->resolveTableName($tableName);
768 88
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
769
770
        /** @psalm-var array[] $constraints */
771 88
        $constraints = $this->normalizeRowKeyCase($constraints, true);
772 88
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
773
774 88
        $result = [
775
            self::PRIMARY_KEY => null,
776 88
            self::FOREIGN_KEYS => [],
777 88
            self::UNIQUES => [],
778 88
            self::CHECKS => [],
779 88
            self::DEFAULTS => [],
780
        ];
781
782
        /** @psalm-var array<array-key, array> $constraints */
783 88
        foreach ($constraints as $type => $names) {
784
            /**
785
             * @psalm-var object|string|null $name
786
             * @psalm-var ConstraintArray $constraint
787
             */
788 79
            foreach ($names as $name => $constraint) {
789 79
                switch ($type) {
790 79
                    case 'PK':
791
                        /** @var Constraint */
792 58
                        $result[self::PRIMARY_KEY] = (new Constraint())
793 58
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
794 58
                            ->name($name);
795 58
                        break;
796 79
                    case 'F':
797 21
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
798 21
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
799 21
                            ->foreignTableName($constraint[0]['foreign_table_name'])
800 21
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
801 21
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
802 21
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
803 21
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
804 21
                            ->name($name);
805 21
                        break;
806 64
                    case 'UQ':
807 62
                        $result[self::UNIQUES][] = (new Constraint())
808 62
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
809 62
                            ->name($name);
810 62
                        break;
811 36
                    case 'C':
812 18
                        $result[self::CHECKS][] = (new CheckConstraint())
813 18
                            ->expression($constraint[0]['check_expr'])
814 18
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
815 18
                            ->name($name);
816 18
                        break;
817 35
                    case 'D':
818 35
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
819 35
                            ->value($constraint[0]['default_expr'])
820 35
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
821 35
                            ->name($name);
822 35
                        break;
823
                }
824
            }
825
        }
826
827 88
        foreach ($result as $type => $data) {
828 88
            $this->setTableMetadata($tableName, $type, $data);
829
        }
830
831 88
        return $result[$returnType];
832
    }
833
834
    /**
835
     * Create a column schema builder instance giving the type and value precision.
836
     *
837
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
838
     *
839
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
840
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
841
     *
842
     * @return ColumnSchemaBuilder column schema builder instance
843
     *
844
     * @psalm-param array<array-key, string>|int|null|string $length
845
     */
846 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
847
    {
848 3
        return new ColumnSchemaBuilder($type, $length);
849
    }
850
851
    /**
852
     * Returns the cache key for the specified table name.
853
     *
854
     * @param string $name the table name.
855
     *
856
     * @return array the cache key.
857
     */
858 191
    protected function getCacheKey(string $name): array
859
    {
860 191
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
861
    }
862
863
    /**
864
     * Returns the cache tag name.
865
     *
866
     * This allows {@see refresh()} to invalidate all cached table schemas.
867
     *
868
     * @return string the cache tag name.
869
     */
870 191
    protected function getCacheTag(): string
871
    {
872 191
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
873
    }
874
875
    /**
876
     * @return bool whether this DBMS supports [savepoint](http://en.wikipedia.org/wiki/Savepoint).
877
     */
878 5
    public function supportsSavepoint(): bool
879
    {
880 5
        return $this->db->isSavepointEnabled();
881
    }
882
883
    /**
884
     * Changes row's array key case to lower.
885
     *
886
     * @param array $row row's array or an array of row's arrays.
887
     * @param bool $multiple whether multiple rows or a single row passed.
888
     *
889
     * @return array normalized row or rows.
890
     */
891 99
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
892
    {
893 99
        if ($multiple) {
894 99
            return array_map(static function (array $row) {
895 87
                return array_change_key_case($row, CASE_LOWER);
896
            }, $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