Passed
Pull Request — master (#271)
by
unknown
26:31 queued 12:22
created

Schema::loadColumnSchema()   B

Complexity

Conditions 11
Paths 23

Size

Total Lines 47
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 11.4611

Importance

Changes 7
Bugs 1 Features 0
Metric Value
cc 11
eloc 31
c 7
b 1
f 0
nc 23
nop 1
dl 0
loc 47
ccs 27
cts 32
cp 0.8438
crap 11.4611
rs 7.3166

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