Test Failed
Pull Request — master (#235)
by Def
14:02 queued 01:40
created

Schema::loadColumnSchema()   C

Complexity

Conditions 16
Paths 138

Size

Total Lines 62
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 30
CRAP Score 16.747

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 16
eloc 37
c 5
b 1
f 0
nc 138
nop 1
dl 0
loc 62
ccs 30
cts 35
cp 0.8571
crap 16.747
rs 5.25

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\Builder\ColumnInterface;
0 ignored issues
show
Bug introduced by
The type Yiisoft\Db\Schema\Builder\ColumnInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

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