Passed
Branch master (eabc99)
by Wilmer
06:42 queued 03:13
created

Schema::findColumns()   B

Complexity

Conditions 10
Paths 36

Size

Total Lines 77
Code Lines 55

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 10.6352

Importance

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

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

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