Passed
Pull Request — master (#126)
by Wilmer
03:44 queued 12s
created

Schema::loadColumnSchema()   C

Complexity

Conditions 15
Paths 92

Size

Total Lines 54
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 16.1329

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 15
eloc 34
c 3
b 0
f 0
nc 92
nop 1
dl 0
loc 54
ccs 29
cts 35
cp 0.8286
crap 16.1329
rs 5.9166

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