Passed
Branch master (fa28a2)
by Wilmer
06:39 queued 03:01
created

Schema::findForeignKeys()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 52
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 5.0035

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 31
c 1
b 0
f 0
nc 12
nop 1
dl 0
loc 52
ccs 18
cts 19
cp 0.9474
crap 5.0035
rs 9.1128

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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