Passed
Pull Request — master (#190)
by Def
04:08 queued 15s
created

Schema::findTableConstraints()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 37
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 2.003

Importance

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