Passed
Branch master (b4e1d1)
by Wilmer
26:50 queued 23:25
created

Schema::loadColumnSchema()   C

Complexity

Conditions 15
Paths 92

Size

Total Lines 54
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 16.1329

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 15
eloc 34
c 3
b 0
f 0
nc 92
nop 1
dl 0
loc 54
ccs 29
cts 35
cp 0.8286
crap 16.1329
rs 5.9166

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_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 232
    protected function resolveTableName(string $name): TableSchemaInterface
135
    {
136 232
        $resolvedName = new TableSchema();
137
138 232
        $parts = array_reverse(
139 232
            $this->db->getQuoter()->getTableNameParts($name)
140
        );
141
142 232
        $resolvedName->name($parts[0] ?? '');
143 232
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
144 232
        $resolvedName->catalogName($parts[2] ?? null);
145 232
        $resolvedName->serverName($parts[3] ?? null);
146
147 232
        if (empty($parts[2]) && $resolvedName->getSchemaName() === $this->defaultSchema) {
148 226
            $resolvedName->fullName($parts[0]);
149
        } else {
150 7
            $resolvedName->fullName(implode('.', array_reverse($parts)));
151
        }
152
153 232
        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 157
    protected function findTableComment(TableSchemaInterface $tableSchema): void
182
    {
183 157
        $schemaName = $tableSchema->getSchemaName()
184 157
            ? "N'" . (string) $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
185 157
        $tableName = 'N' . (string) $this->db->getQuoter()->quoteValue($tableSchema->getName());
186
187 157
        $sql = <<<SQL
188
        SELECT [value]
189
        FROM fn_listextendedproperty (
190
            N'MS_description',
191
            'SCHEMA', $schemaName,
192
            'TABLE', $tableName,
193
            DEFAULT, DEFAULT)
194
        SQL;
195
196 157
        $comment = $this->db->createCommand($sql)->queryScalar();
197
198 157
        $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
        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 157
    protected function loadTableSchema(string $name): TableSchemaInterface|null
241
    {
242 157
        $table = $this->resolveTableName($name);
243 157
        $this->findPrimaryKeys($table);
244 157
        $this->findTableComment($table);
245
246 157
        if ($this->findColumns($table)) {
247 138
            $this->findForeignKeys($table);
248 138
            return $table;
249
        }
250
251 30
        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
        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 15
    protected function loadTableChecks(string $tableName): array
367
    {
368
        /** @var mixed */
369 15
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
370 15
        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 138
    protected function createColumnSchema(): ColumnSchema
397
    {
398 138
        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 138
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
409
    {
410 138
        $column = $this->createColumnSchema();
411
412 138
        $column->name($info['column_name']);
413 138
        $column->allowNull($info['is_nullable'] === 'YES');
414 138
        $column->dbType($info['data_type']);
415 138
        $column->enumValues([]); // mssql has only vague equivalents to enum
416 138
        $column->primaryKey(false); // primary key will be determined in findColumns() method
417 138
        $column->autoIncrement($info['is_identity'] === '1');
418 138
        $column->computed($info['is_computed'] === '1');
419 138
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
420 138
        $column->comment($info['comment'] ?? '');
421 138
        $column->type(self::TYPE_STRING);
422
423 138
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
424 138
            $type = $matches[1];
425
426 138
            if (isset($this->typeMap[$type])) {
427 138
                $column->type($this->typeMap[$type]);
428
            }
429
430 138
            if (!empty($matches[2])) {
431 101
                $values = explode(',', $matches[2]);
432 101
                $column->precision((int) $values[0]);
433 101
                $column->size((int) $values[0]);
434
435 101
                if (isset($values[1])) {
436
                    $column->scale((int) $values[1]);
437
                }
438
439 101
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
440
                    $column->type(self::TYPE_BOOLEAN);
441 101
                } 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 138
        $column->phpType($this->getColumnPhpType($column));
452
453 138
        if ($info['column_default'] === '(NULL)') {
454 7
            $info['column_default'] = null;
455
        }
456
457 138
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
458 138
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
459
        }
460
461 138
        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 157
    protected function findColumns(TableSchemaInterface $table): bool
474
    {
475 157
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
476
477 157
        $whereParams = [':table_name' => $table->getName()];
478 157
        $whereSql = '[t1].[table_name] = :table_name';
479
480 157
        if ($table->getCatalogName() !== null) {
481
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
482
            $whereSql .= ' AND [t1].[table_catalog] = :catalog';
483
            $whereParams[':catalog'] = $table->getCatalogName();
484
        }
485
486 157
        if ($table->getSchemaName() !== null) {
487 157
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
488
        }
489
490 157
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
491
492 157
        $sql = <<<SQL
493
        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
        FROM $columnsTableName AS [t1]
519
        WHERE $whereSql
520
        SQL;
521
522
        try {
523
            /** @psalm-var ColumnArray[] */
524 157
            $columns = $this->db->createCommand($sql, $whereParams)->queryAll();
525
526 157
            if (empty($columns)) {
527 157
                return false;
528
            }
529
        } catch (Exception) {
530
            return false;
531
        }
532
533 138
        foreach ($columns as $column) {
534 138
            $column = $this->loadColumnSchema($column);
535 138
            foreach ($table->getPrimaryKey() as $primaryKey) {
536 88
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
537 88
                    $column->primaryKey(true);
538 88
                    break;
539
                }
540
            }
541
542 138
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
543 84
                $table->sequenceName('');
544
            }
545
546 138
            $table->columns($column->getName(), $column);
547
        }
548
549 138
        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 157
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
562
    {
563 157
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
564 157
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
565
566 157
        $catalogName = $table->getCatalogName();
567 157
        if ($catalogName !== null) {
568
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
569
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
570
        }
571
572 157
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
573 157
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
574
575 157
        $sql = <<<SQL
576
        SELECT
577
            [kcu].[constraint_name] AS [index_name],
578
            [kcu].[column_name] AS [field_name]
579
        FROM $keyColumnUsageTableName AS [kcu]
580
        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
        SQL;
589
590 157
        return $this->db->createCommand(
591
            $sql,
592
            [
593 157
                ':tableName' => $table->getName(),
594 157
                ':schemaName' => $table->getSchemaName(),
595
                ':type' => $type,
596
            ]
597 157
        )->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 157
    protected function findPrimaryKeys(TableSchemaInterface $table): void
608
    {
609
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
610 157
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
611
612 157
        foreach ($primaryKeys as $row) {
613 88
            $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 138
    protected function findForeignKeys(TableSchemaInterface $table): void
625
    {
626 138
        $catalogName = $table->getCatalogName();
627 138
        $fk = [];
628 138
        $object = $table->getName();
629 138
        $schemaName = $table->getSchemaName();
630
631 138
        if ($schemaName !== null) {
632 138
            $object = $schemaName . '.' . $object;
633
        }
634
635 138
        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 138
        $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
        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 138
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
666 138
        $table->foreignKeys([]);
667
668 138
        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
        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 100
    private function loadTableConstraints(string $tableName, string $returnType): mixed
748
    {
749 100
        $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
        SQL;
788
789 100
        $resolvedName = $this->resolveTableName($tableName);
790 100
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
791
792
        /** @psalm-var array[] $constraints */
793 100
        $constraints = $this->normalizeRowKeyCase($constraints, true);
794 100
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
795
796 100
        $result = [
797
            self::PRIMARY_KEY => null,
798
            self::FOREIGN_KEYS => [],
799
            self::UNIQUES => [],
800
            self::CHECKS => [],
801
            self::DEFAULTS => [],
802
        ];
803
804
        /** @psalm-var array<array-key, array> $constraints */
805 100
        foreach ($constraints as $type => $names) {
806
            /**
807
             * @psalm-var object|string|null $name
808
             * @psalm-var ConstraintArray $constraint
809
             */
810 91
            foreach ($names as $name => $constraint) {
811
                switch ($type) {
812 91
                    case 'PK':
813
                        /** @var Constraint */
814 62
                        $result[self::PRIMARY_KEY] = (new Constraint())
815 62
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
816 62
                            ->name($name);
817 62
                        break;
818 88
                    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 72
                    case 'UQ':
829 68
                        $result[self::UNIQUES][] = (new Constraint())
830 68
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
831 68
                            ->name($name);
832 68
                        break;
833 38
                    case 'C':
834 19
                        $result[self::CHECKS][] = (new CheckConstraint())
835 19
                            ->expression($constraint[0]['check_expr'])
836 19
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
837 19
                            ->name($name);
838 19
                        break;
839 36
                    case 'D':
840 36
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
841 36
                            ->value($constraint[0]['default_expr'])
842 36
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
843 36
                            ->name($name);
844 36
                        break;
845
                }
846
            }
847
        }
848
849 100
        foreach ($result as $type => $data) {
850 100
            $this->setTableMetadata($tableName, $type, $data);
851
        }
852
853 100
        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 8
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
869
    {
870 8
        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 232
    protected function getCacheKey(string $name): array
881
    {
882 232
        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 232
    protected function getCacheTag(): string
893
    {
894 232
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
895
    }
896
}
897