Passed
Push — master ( f244ed...0973aa )
by Def
13:01 queued 08:48
created

Schema::loadColumnSchema()   C

Complexity

Conditions 16
Paths 138

Size

Total Lines 62
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 16.5834

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 33
cts 38
cp 0.8684
crap 16.5834
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;
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 6
    public function createColumn(string $type, array|int|string|null $length = null): ColumnInterface
123
    {
124 6
        return new Column($type, $length);
125
    }
126
127
    /**
128
     * Resolves the table name and schema name (if any).
129
     *
130
     * @param string $name The table name.
131
     *
132
     * @return TableSchemaInterface The resolved table name.
133
     */
134 571
    protected function resolveTableName(string $name): TableSchemaInterface
135
    {
136 571
        $resolvedName = new TableSchema();
137
138 571
        $parts = array_reverse(
139 571
            $this->db->getQuoter()->getTableNameParts($name)
140 571
        );
141
142 571
        $resolvedName->name($parts[0] ?? '');
143 571
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
144 571
        $resolvedName->catalogName($parts[2] ?? null);
145 571
        $resolvedName->serverName($parts[3] ?? null);
146
147 571
        if (empty($parts[2]) && $resolvedName->getSchemaName() === $this->defaultSchema) {
148 565
            $resolvedName->fullName($parts[0]);
149
        } else {
150 7
            $resolvedName->fullName(implode('.', array_reverse($parts)));
151
        }
152
153 571
        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
     *
166
     * @return array All schema name in the database, except system schemas.
167
     *
168
     * @link https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-principals-transact-sql
169
     */
170 1
    protected function findSchemaNames(): array
171
    {
172 1
        $sql = <<<SQL
173
        SELECT [s].[name]
174
        FROM [sys].[schemas] AS [s]
175
        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 1
        SQL;
179
180 1
        return $this->db->createCommand($sql)->queryColumn();
181
    }
182
183
    /**
184
     * @throws Exception
185
     * @throws InvalidConfigException
186
     * @throws Throwable
187
     */
188 497
    protected function findTableComment(TableSchemaInterface $tableSchema): void
189
    {
190 497
        $schemaName = $tableSchema->getSchemaName()
191 497
            ? "N'" . (string) $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
192 497
        $tableName = 'N' . (string) $this->db->getQuoter()->quoteValue($tableSchema->getName());
193
194 497
        $sql = <<<SQL
195 497
        SELECT [value]
196
        FROM fn_listextendedproperty (
197
            N'MS_description',
198 497
            'SCHEMA', $schemaName,
199 497
            'TABLE', $tableName,
200
            DEFAULT, DEFAULT)
201 497
        SQL;
202
203 497
        $comment = $this->db->createCommand($sql)->queryScalar();
204
205 497
        $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
     * @throws InvalidConfigException
219
     * @throws Throwable
220
     *
221
     * @return array All tables name in the database. The names have NO schema name prefix.
222
     */
223 11
    protected function findTableNames(string $schema = ''): array
224
    {
225 11
        if ($schema === '') {
226 11
            $schema = $this->defaultSchema;
227
        }
228
229 11
        $sql = <<<SQL
230
        SELECT [t].[table_name]
231
        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 11
        SQL;
235
236 11
        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
     * @throws InvalidConfigException
246
     * @throws Throwable
247
     *
248
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table doesn't exist.
249
     */
250 497
    protected function loadTableSchema(string $name): TableSchemaInterface|null
251
    {
252 497
        $table = $this->resolveTableName($name);
253 497
        $this->findPrimaryKeys($table);
254 497
        $this->findTableComment($table);
255
256 497
        if ($this->findColumns($table)) {
257 477
            $this->findForeignKeys($table);
258 477
            return $table;
259
        }
260
261 165
        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
     * @throws InvalidConfigException
271
     * @throws Throwable
272
     *
273
     * @return Constraint|null The primary key for the given table, `null` if the table has no primary key.
274
     */
275 50
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
276
    {
277
        /** @psalm-var mixed $tablePrimaryKey */
278 50
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
279 50
        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
     * @throws InvalidConfigException
289
     * @throws Throwable
290
     *
291
     * @return array The foreign keys for the given table.
292
     */
293 8
    protected function loadTableForeignKeys(string $tableName): array
294
    {
295
        /** @psalm-var mixed $tableForeignKeys */
296 8
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
297 8
        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
     * @throws InvalidConfigException
307
     * @throws Throwable
308
     *
309
     * @return array Indexes for the given table.
310
     */
311 39
    protected function loadTableIndexes(string $tableName): array
312
    {
313 39
        $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
            ON [ic].[object_id] = [i].[object_id] AND [ic].[index_id] = [i].[index_id]
322
        INNER JOIN [sys].[columns] AS [iccol]
323
            ON [iccol].[object_id] = [ic].[object_id] AND [iccol].[column_id] = [ic].[column_id]
324
        WHERE [i].[object_id] = OBJECT_ID(:fullName)
325
        ORDER BY [ic].[key_ordinal] ASC
326 39
        SQL;
327
328 39
        $resolvedName = $this->resolveTableName($tableName);
329 39
        $indexes = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
330
331
        /** @psalm-var array[] $indexes */
332 39
        $indexes = $this->normalizeRowKeyCase($indexes, true);
333 39
        $indexes = ArrayHelper::index($indexes, null, ['name']);
334
335 39
        $result = [];
336
337
        /**
338
         * @psalm-var array<
339
         *   string,
340
         *   array<
341
         *     array-key,
342
         *     array{name: string, column_name: string, index_is_unique: string, index_is_primary: string}
343
         *   >
344
         * > $indexes
345
         */
346 39
        foreach ($indexes as $name => $index) {
347 36
            $result[] = (new IndexConstraint())
348 36
                ->primary((bool) $index[0]['index_is_primary'])
349 36
                ->unique((bool) $index[0]['index_is_unique'])
350 36
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
351 36
                ->name($name);
352
        }
353
354 39
        return $result;
355
    }
356
357
    /**
358
     * Loads all unique constraints for the given table.
359
     *
360
     * @param string $tableName The table name.
361
     *
362
     * @throws Exception|InvalidConfigException|Throwable
363
     *
364
     * @return array The unique constraints for the given table.
365
     */
366 17
    protected function loadTableUniques(string $tableName): array
367
    {
368
        /** @psalm-var mixed $tableUniques */
369 17
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
370 17
        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
     * @throws InvalidConfigException
380
     * @throws Throwable
381
     *
382
     * @return array The check constraints for the given table.
383
     */
384 17
    protected function loadTableChecks(string $tableName): array
385
    {
386
        /** @psalm-var mixed $tableCheck */
387 17
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
388 17
        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
     * @throws InvalidConfigException
398
     * @throws Throwable
399
     *
400
     * @return array The default value constraints for the given table.
401
     */
402 16
    protected function loadTableDefaultValues(string $tableName): array
403
    {
404
        /** @psalm-var mixed $tableDefault */
405 16
        $tableDefault = $this->loadTableConstraints($tableName, self::DEFAULTS);
406 16
        return is_array($tableDefault) ? $tableDefault : [];
407
    }
408
409
    /**
410
     * Creates a column schema for the database.
411
     *
412
     * This method may be overridden by child classes to create a DBMS-specific column schema.
413
     */
414 477
    protected function createColumnSchema(): ColumnSchema
415
    {
416 477
        return new ColumnSchema();
417
    }
418
419
    /**
420
     * Loads the column information into a {@see ColumnSchemaInterface} object.
421
     *
422
     * @psalm-param ColumnArray $info The column information.
423
     */
424 477
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
425
    {
426 477
        $column = $this->createColumnSchema();
427
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 477
        $column->autoIncrement($info['is_identity'] === '1');
434 477
        $column->computed($info['is_computed'] === '1');
435 477
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
436 477
        $column->comment($info['comment'] ?? '');
437 477
        $column->type(self::TYPE_STRING);
438
439 477
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
440 477
            $type = $matches[1];
441
442 477
            if (isset($this->typeMap[$type])) {
443 477
                $column->type($this->typeMap[$type]);
444
            }
445
446 477
            if (!empty($matches[2])) {
447 318
                $values = explode(',', $matches[2]);
448 318
                $column->precision((int) $values[0]);
449 318
                $column->size((int) $values[0]);
450
451 318
                if (isset($values[1])) {
452 91
                    $column->scale((int) $values[1]);
453
                }
454
455 318
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
456
                    $column->type(self::TYPE_BOOLEAN);
457 318
                } 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
                    }
463
                }
464
            }
465
        }
466
467 477
        $column->phpType($this->getColumnPhpType($column));
468
469 477
        if ($info['column_default'] === '(NULL)') {
470 8
            $column->defaultValue(null);
471
        }
472
473 477
        if (!$column->isPrimaryKey() && !$column->isComputed() && $info['column_default'] !== null) {
474
            /** @psalm-var mixed $value */
475 363
            $value = $this->parseDefaultValue($info['column_default']);
476
477 363
            if (is_numeric($value)) {
478
                /** @psalm-var mixed $value */
479 128
                $value = $column->phpTypeCast($value);
480
            }
481
482 363
            $column->defaultValue($value);
483
        }
484
485 477
        return $column;
486
    }
487
488
    /**
489
     * Collects the metadata of table columns.
490
     *
491
     * @param TableSchemaInterface $table The table metadata.
492
     *
493
     * @throws Throwable
494
     *
495
     * @return bool Whether the table exists in the database.
496
     */
497 497
    protected function findColumns(TableSchemaInterface $table): bool
498
    {
499 497
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
500
501 497
        $whereParams = [':table_name' => $table->getName()];
502 497
        $whereSql = '[t1].[table_name] = :table_name';
503
504 497
        if ($table->getCatalogName() !== null) {
505
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
506
            $whereSql .= ' AND [t1].[table_catalog] = :catalog';
507
            $whereParams[':catalog'] = $table->getCatalogName();
508
        }
509
510 497
        if ($table->getSchemaName() !== null) {
511 497
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
512
        }
513
514 497
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
515
516 497
        $sql = <<<SQL
517 497
        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
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
544
        [t2].[name] = 'MS_Description' AND
545
        [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 497
        FROM $columnsTableName AS [t1]
549 497
        WHERE $whereSql
550 497
        SQL;
551
552
        try {
553
            /** @psalm-var ColumnArray[] $columns */
554 497
            $columns = $this->db->createCommand($sql, $whereParams)->queryAll();
555
556 497
            if (empty($columns)) {
557 497
                return false;
558
            }
559
        } catch (Exception) {
560
            return false;
561
        }
562
563 477
        foreach ($columns as $column) {
564 477
            $column = $this->loadColumnSchema($column);
565 477
            foreach ($table->getPrimaryKey() as $primaryKey) {
566 293
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
567 293
                    $column->primaryKey(true);
568 293
                    break;
569
                }
570
            }
571
572 477
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
573 286
                $table->sequenceName('');
574
            }
575
576 477
            $table->columns($column->getName(), $column);
577
        }
578
579 477
        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
     * @throws InvalidConfigException
589
     * @throws Throwable
590
     *
591
     * @return array Each entry has index_name and field_name.
592
     */
593 497
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
594
    {
595 497
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
596 497
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
597
598 497
        $catalogName = $table->getCatalogName();
599 497
        if ($catalogName !== null) {
600
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
601
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
602
        }
603
604 497
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
605 497
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
606
607 497
        $sql = <<<SQL
608 497
        SELECT
609
            [kcu].[constraint_name] AS [index_name],
610
            [kcu].[column_name] AS [field_name]
611 497
        FROM $keyColumnUsageTableName AS [kcu]
612 497
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
613
            [kcu].[table_schema] = [tc].[table_schema] AND
614
            [kcu].[table_name] = [tc].[table_name] AND
615
            [kcu].[constraint_name] = [tc].[constraint_name]
616
        WHERE
617
            [tc].[constraint_type] = :type AND
618
            [kcu].[table_name] = :tableName AND
619
            [kcu].[table_schema] = :schemaName
620 497
        SQL;
621
622 497
        return $this->db->createCommand(
623 497
            $sql,
624 497
            [
625 497
                ':tableName' => $table->getName(),
626 497
                ':schemaName' => $table->getSchemaName(),
627 497
                ':type' => $type,
628 497
            ]
629 497
        )->queryAll();
630
    }
631
632
    /**
633
     * Collects the primary key column details for the given table.
634
     *
635
     * @param TableSchemaInterface $table The table metadata
636
     *
637
     * @throws Exception
638
     * @throws InvalidConfigException
639
     * @throws Throwable
640
     */
641 497
    protected function findPrimaryKeys(TableSchemaInterface $table): void
642
    {
643
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
644 497
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
645
646 497
        foreach ($primaryKeys as $row) {
647 293
            $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
     *
656
     * @throws Exception
657
     * @throws InvalidConfigException
658
     * @throws Throwable
659
     */
660 477
    protected function findForeignKeys(TableSchemaInterface $table): void
661
    {
662 477
        $catalogName = $table->getCatalogName();
663 477
        $fk = [];
664 477
        $object = $table->getName();
665 477
        $schemaName = $table->getSchemaName();
666
667 477
        if ($schemaName !== null) {
668 477
            $object = $schemaName . '.' . $object;
669
        }
670
671 477
        if ($catalogName !== null) {
672
            $object = $catalogName . '.' . $object;
673
        }
674
675 477
        $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
        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 477
        SQL;
690
691
        /**
692
         * @psalm-var array<
693
         *   array-key,
694
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
695
         * > $rows
696
         */
697 477
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
698 477
        $table->foreignKeys([]);
699
700 477
        foreach ($rows as $row) {
701 10
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
702 10
                $fk[$row['fk_name']][] = $row['uq_table_name'];
703 10
                $table->foreignKeys($fk);
704
            }
705
706 10
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
707 10
            $table->foreignKeys($fk);
708
        }
709
    }
710
711
    /**
712
     * @throws Exception
713
     * @throws InvalidConfigException
714
     * @throws Throwable
715
     */
716 4
    protected function findViewNames(string $schema = ''): array
717
    {
718 4
        if ($schema === '') {
719 1
            $schema = $this->defaultSchema;
720
        }
721
722 4
        $sql = <<<SQL
723
        SELECT [t].[table_name]
724
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
725
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
726
        ORDER BY [t].[table_name]
727 4
        SQL;
728
729 4
        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
     * @throws InvalidConfigException
748
     * @throws Throwable
749
     *
750
     * @return array All unique indexes for the given table.
751
     */
752 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
753
    {
754 1
        $result = [];
755
756
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
757 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
758
759 1
        foreach ($tableUniqueConstraints as $row) {
760 1
            $result[$row['index_name']][] = $row['field_name'];
761
        }
762
763 1
        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
     * @throws InvalidConfigException
779
     * @throws Throwable
780
     *
781
     * @return mixed Constraints of the specified type.
782
     */
783 108
    private function loadTableConstraints(string $tableName, string $returnType): mixed
784
    {
785 108
        $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
        LEFT JOIN [sys].[columns] AS [fccol]
819
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
820
        LEFT JOIN [sys].[columns] AS [ffccol]
821
            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 108
        SQL;
824
825 108
        $resolvedName = $this->resolveTableName($tableName);
826 108
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
827
828
        /** @psalm-var array[] $constraints */
829 108
        $constraints = $this->normalizeRowKeyCase($constraints, true);
830 108
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
831
832 108
        $result = [
833 108
            self::PRIMARY_KEY => null,
834 108
            self::FOREIGN_KEYS => [],
835 108
            self::UNIQUES => [],
836 108
            self::CHECKS => [],
837 108
            self::DEFAULTS => [],
838 108
        ];
839
840
        /** @psalm-var array<array-key, array> $constraints */
841 108
        foreach ($constraints as $type => $names) {
842
            /**
843
             * @psalm-var object|string|null $name
844
             * @psalm-var ConstraintArray $constraint
845
             */
846 99
            foreach ($names as $name => $constraint) {
847
                switch ($type) {
848 99
                    case 'PK':
849
                        /** @psalm-var Constraint */
850 65
                        $result[self::PRIMARY_KEY] = (new Constraint())
851 65
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
852 65
                            ->name($name);
853 65
                        break;
854 93
                    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 23
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
860 23
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
861 23
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
862 23
                            ->name($name);
863 23
                        break;
864 77
                    case 'UQ':
865 71
                        $result[self::UNIQUES][] = (new Constraint())
866 71
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
867 71
                            ->name($name);
868 71
                        break;
869 41
                    case 'C':
870 19
                        $result[self::CHECKS][] = (new CheckConstraint())
871 19
                            ->expression($constraint[0]['check_expr'])
872 19
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
873 19
                            ->name($name);
874 19
                        break;
875 38
                    case 'D':
876 38
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
877 38
                            ->value($constraint[0]['default_expr'])
878 38
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
879 38
                            ->name($name);
880 38
                        break;
881
                }
882
            }
883
        }
884
885 108
        foreach ($result as $type => $data) {
886 108
            $this->setTableMetadata($tableName, $type, $data);
887
        }
888
889 108
        return $result[$returnType];
890
    }
891
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 599
    protected function getCacheKey(string $name): array
900
    {
901 599
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
902
    }
903
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 600
    protected function getCacheTag(): string
912
    {
913 600
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
914
    }
915
916 363
    private function parseDefaultValue(mixed $value): mixed
917
    {
918 363
        $value = (string) $value;
919
920 363
        if (preg_match('/^\'(.*)\'$/', $value, $matches)) {
921 81
            return $matches[1];
922
        }
923
924 363
        if (preg_match('/^\((.*)\)$/', $value, $matches)) {
925 363
            return $this->parseDefaultValue($matches[1]);
926
        }
927
928 293
        return $value;
929
    }
930
}
931