Test Failed
Pull Request — master (#214)
by Wilmer
04:00
created

Schema::findColumns()   C

Complexity

Conditions 10
Paths 36

Size

Total Lines 83
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 10.4196

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 10
eloc 61
c 2
b 0
f 0
nc 36
nop 1
dl 0
loc 83
ccs 26
cts 31
cp 0.8387
crap 10.4196
rs 6.9842

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