Passed
Branch master (e888a2)
by Wilmer
30:54 queued 17:34
created

Schema::loadColumnSchema()   C

Complexity

Conditions 15
Paths 92

Size

Total Lines 53
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 16.2371

Importance

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