Passed
Pull Request — master (#123)
by Wilmer
03:24
created

Schema::findColumns()   B

Complexity

Conditions 10
Paths 36

Size

Total Lines 76
Code Lines 54

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 10.6352

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 10
eloc 54
c 2
b 0
f 0
nc 36
nop 1
dl 0
loc 76
ccs 22
cts 27
cp 0.8148
crap 10.6352
rs 7.1369

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|null $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 191
    protected function resolveTableName(string $name): TableSchemaInterface
135
    {
136 191
        $resolvedName = new TableSchema();
137
138 191
        $parts = array_reverse(
139 191
            $this->db->getQuoter()->getTableNameParts($name)
140
        );
141
142 191
        $resolvedName->name($parts[0] ?? '');
143 191
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
144 191
        $resolvedName->catalogName($parts[2] ?? null);
145 191
        $resolvedName->serverName($parts[3] ?? null);
146
147 191
        if (empty($parts[2]) && $resolvedName->getSchemaName() === $this->defaultSchema) {
148 185
            $resolvedName->fullName($parts[0]);
149
        } else {
150 6
            $resolvedName->fullName(implode('.', array_reverse($parts)));
151
        }
152
153 191
        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 116
    protected function loadTableSchema(string $name): TableSchemaInterface|null
221
    {
222 116
        $table = $this->resolveTableName($name);
223 116
        $this->findPrimaryKeys($table);
224
225 116
        if ($this->findColumns($table)) {
226 103
            $this->findForeignKeys($table);
227 103
            return $table;
228
        }
229
230 23
        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|null
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 103
    protected function createColumnSchema(): ColumnSchema
376
    {
377 103
        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 103
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
388
    {
389 103
        $column = $this->createColumnSchema();
390
391 103
        $column->name($info['column_name']);
392 103
        $column->allowNull($info['is_nullable'] === 'YES');
393 103
        $column->dbType($info['data_type']);
394 103
        $column->enumValues([]); // mssql has only vague equivalents to enum
395 103
        $column->primaryKey(false); // primary key will be determined in findColumns() method
396 103
        $column->autoIncrement($info['is_identity'] === '1');
397 103
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
398 103
        $column->comment($info['comment'] ?? '');
399 103
        $column->type(self::TYPE_STRING);
400
401 103
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
402 103
            $type = $matches[1];
403
404 103
            if (isset($this->typeMap[$type])) {
405 103
                $column->type($this->typeMap[$type]);
406
            }
407
408 103
            if (!empty($matches[2])) {
409 86
                $values = explode(',', $matches[2]);
410 86
                $column->precision((int) $values[0]);
411 86
                $column->size((int) $values[0]);
412
413 86
                if (isset($values[1])) {
414
                    $column->scale((int) $values[1]);
415
                }
416
417 86
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
418
                    $column->type(self::TYPE_BOOLEAN);
419 86
                } 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 103
        $column->phpType($this->getColumnPhpType($column));
430
431 103
        if ($info['column_default'] === '(NULL)') {
432 4
            $info['column_default'] = null;
433
        }
434
435 103
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
436 103
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
437
        }
438
439 103
        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 116
    protected function findColumns(TableSchemaInterface $table): bool
452
    {
453 116
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
454
455 116
        $whereParams = [':table_name' => $table->getName()];
456 116
        $whereSql = '[t1].[table_name] = :table_name';
457
458 116
        if ($table->getCatalogName() !== null) {
459
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
460
            $whereSql .= ' AND [t1].[table_catalog] = :catalog';
461
            $whereParams[':catalog'] = $table->getCatalogName();
462
        }
463
464 116
        if ($table->getSchemaName() !== null) {
465 116
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
466
        }
467
468 116
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
469
470 116
        $sql = <<<SQL
471
        SELECT
472
            [t1].[column_name],
473
            [t1].[is_nullable],
474
        CASE WHEN [t1].[data_type] IN ('char','varchar','nchar','nvarchar','binary','varbinary') THEN
475
        CASE WHEN [t1].[character_maximum_length] = NULL OR [t1].[character_maximum_length] = -1 THEN
476
            [t1].[data_type]
477
        ELSE
478
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[character_maximum_length]))) + ')'
479
        END
480
        ELSE
481
            [t1].[data_type]
482
        END AS 'data_type',
483
        [t1].[column_default],
484
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsIdentity') AS is_identity,
485
        (
486
        SELECT CONVERT(VARCHAR, [t2].[value])
487
        FROM [sys].[extended_properties] AS [t2]
488
        WHERE
489
        [t2].[class] = 1 AND
490
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
491
        [t2].[name] = 'MS_Description' AND
492
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
493
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
494
        ) as comment
495
        FROM $columnsTableName AS [t1]
496
        WHERE $whereSql
497
        SQL;
498
499
        try {
500
            /** @psalm-var ColumnArray[] */
501 116
            $columns = $this->db->createCommand($sql, $whereParams)->queryAll();
502
503 116
            if (empty($columns)) {
504 116
                return false;
505
            }
506
        } catch (Exception) {
507
            return false;
508
        }
509
510 103
        foreach ($columns as $column) {
511 103
            $column = $this->loadColumnSchema($column);
512 103
            foreach ($table->getPrimaryKey() as $primaryKey) {
513 70
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
514 70
                    $column->primaryKey(true);
515 70
                    break;
516
                }
517
            }
518
519 103
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
520 69
                $table->sequenceName('');
521
            }
522
523 103
            $table->columns($column->getName(), $column);
524
        }
525
526 103
        return true;
527
    }
528
529
    /**
530
     * Collects the constraint details for the given table and constraint type.
531
     *
532
     * @param string $type either PRIMARY KEY or UNIQUE.
533
     *
534
     * @throws Exception|InvalidConfigException|Throwable
535
     *
536
     * @return array each entry contains index_name and field_name.
537
     */
538 116
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
539
    {
540 116
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
541 116
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
542
543 116
        $catalogName = $table->getCatalogName();
544 116
        if ($catalogName !== null) {
545
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
546
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
547
        }
548
549 116
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
550 116
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
551
552 116
        $sql = <<<SQL
553
        SELECT
554
            [kcu].[constraint_name] AS [index_name],
555
            [kcu].[column_name] AS [field_name]
556
        FROM $keyColumnUsageTableName AS [kcu]
557
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
558
            [kcu].[table_schema] = [tc].[table_schema] AND
559
            [kcu].[table_name] = [tc].[table_name] AND
560
            [kcu].[constraint_name] = [tc].[constraint_name]
561
        WHERE
562
            [tc].[constraint_type] = :type AND
563
            [kcu].[table_name] = :tableName AND
564
            [kcu].[table_schema] = :schemaName
565
        SQL;
566
567 116
        return $this->db->createCommand(
568
            $sql,
569
            [
570 116
                ':tableName' => $table->getName(),
571 116
                ':schemaName' => $table->getSchemaName(),
572
                ':type' => $type,
573
            ]
574 116
        )->queryAll();
575
    }
576
577
    /**
578
     * Collects the primary key column details for the given table.
579
     *
580
     * @param TableSchemaInterface $table the table metadata
581
     *
582
     * @throws Exception|InvalidConfigException|Throwable
583
     */
584 116
    protected function findPrimaryKeys(TableSchemaInterface $table): void
585
    {
586
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
587 116
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
588
589 116
        foreach ($primaryKeys as $row) {
590 70
            $table->primaryKey($row['field_name']);
591
        }
592
    }
593
594
    /**
595
     * Collects the foreign key column details for the given table.
596
     *
597
     * @param TableSchemaInterface $table the table metadata
598
     *
599
     * @throws Exception|InvalidConfigException|Throwable
600
     */
601 103
    protected function findForeignKeys(TableSchemaInterface $table): void
602
    {
603 103
        $catalogName = $table->getCatalogName();
604 103
        $fk = [];
605 103
        $object = $table->getName();
606 103
        $schemaName = $table->getSchemaName();
607
608 103
        if ($schemaName !== null) {
609 103
            $object = $schemaName . '.' . $object;
610
        }
611
612 103
        if ($catalogName !== null) {
613
            $object = $catalogName . '.' . $object;
614
        }
615
616
        /**
617
         * Please refer to the following page for more details:
618
         * {@see http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx}
619
         */
620 103
        $sql = <<<SQL
621
        SELECT
622
        [fk].[name] AS [fk_name],
623
        [cp].[name] AS [fk_column_name],
624
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
625
        [cr].[name] AS [uq_column_name]
626
        FROM [sys].[foreign_keys] AS [fk]
627
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
628
            ON [fk].[object_id] = [fkc].[constraint_object_id]
629
        INNER JOIN [sys].[columns] AS [cp]
630
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
631
        INNER JOIN [sys].[columns] AS [cr]
632
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
633
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
634
        SQL;
635
636
        /**
637
         * @psalm-var array<
638
         *   array-key,
639
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
640
         * > $rows
641
         */
642 103
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
643 103
        $table->foreignKeys([]);
644
645 103
        foreach ($rows as $row) {
646 6
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
647 6
                $fk[$row['fk_name']][] = $row['uq_table_name'];
648 6
                $table->foreignKeys($fk);
649
            }
650
651 6
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
652 6
            $table->foreignKeys($fk);
653
        }
654
    }
655
656
    /**
657
     * @throws Exception|InvalidConfigException|Throwable
658
     */
659 3
    protected function findViewNames(string $schema = ''): array
660
    {
661 3
        if ($schema === '') {
662 1
            $schema = $this->defaultSchema;
663
        }
664
665 3
        $sql = <<<SQL
666
        SELECT [t].[table_name]
667
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
668
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
669
        ORDER BY [t].[table_name]
670
        SQL;
671
672 3
        $views = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
673
674 3
        return array_map(static fn (string $item): string => '[' . $item . ']', $views);
675
    }
676
677
    /**
678
     * Returns all unique indexes for the given table.
679
     *
680
     * Each array element is of the following structure:
681
     *
682
     * ```php
683
     * [
684
     *     'IndexName1' => ['col1' [, ...]],
685
     *     'IndexName2' => ['col2' [, ...]],
686
     * ]
687
     * ```
688
     *
689
     * @param TableSchemaInterface $table the table metadata.
690
     *
691
     * @throws Exception|InvalidConfigException|Throwable
692
     *
693
     * @return array all unique indexes for the given table.
694
     */
695 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
696
    {
697 1
        $result = [];
698
699
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
700 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
701
702 1
        foreach ($tableUniqueConstraints as $row) {
703 1
            $result[$row['index_name']][] = $row['field_name'];
704
        }
705
706 1
        return $result;
707
    }
708
709
    /**
710
     * Loads multiple types of constraints and returns the specified ones.
711
     *
712
     * @param string $tableName table name.
713
     * @param string $returnType return type:
714
     * - primaryKey
715
     * - foreignKeys
716
     * - uniques
717
     * - checks
718
     * - defaults
719
     *
720
     * @throws Exception|InvalidConfigException|Throwable
721
     *
722
     * @return mixed constraints.
723
     */
724 88
    private function loadTableConstraints(string $tableName, string $returnType): mixed
725
    {
726 88
        $sql = <<<SQL
727
        SELECT
728
            [o].[name] AS [name],
729
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
730
            RTRIM([o].[type]) AS [type],
731
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
732
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
733
            [ffccol].[name] AS [foreign_column_name],
734
            [f].[update_referential_action_desc] AS [on_update],
735
            [f].[delete_referential_action_desc] AS [on_delete],
736
            [c].[definition] AS [check_expr],
737
            [d].[definition] AS [default_expr]
738
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
739
        INNER JOIN [sys].[objects] AS [o]
740
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
741
        LEFT JOIN [sys].[check_constraints] AS [c]
742
            ON [c].[object_id] = [o].[object_id]
743
        LEFT JOIN [sys].[columns] AS [ccol]
744
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
745
        LEFT JOIN [sys].[default_constraints] AS [d]
746
            ON [d].[object_id] = [o].[object_id]
747
        LEFT JOIN [sys].[columns] AS [dcol]
748
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
749
        LEFT JOIN [sys].[key_constraints] AS [k]
750
            ON [k].[object_id] = [o].[object_id]
751
        LEFT JOIN [sys].[index_columns] AS [kic]
752
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
753
        LEFT JOIN [sys].[columns] AS [kiccol]
754
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
755
        LEFT JOIN [sys].[foreign_keys] AS [f]
756
            ON [f].[object_id] = [o].[object_id]
757
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
758
            ON [fc].[constraint_object_id] = [o].[object_id]
759
        LEFT JOIN [sys].[columns] AS [fccol]
760
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
761
        LEFT JOIN [sys].[columns] AS [ffccol]
762
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
763
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
764
        SQL;
765
766 88
        $resolvedName = $this->resolveTableName($tableName);
767 88
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
768
769
        /** @psalm-var array[] $constraints */
770 88
        $constraints = $this->normalizeRowKeyCase($constraints, true);
771 88
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
772
773 88
        $result = [
774
            self::PRIMARY_KEY => null,
775 88
            self::FOREIGN_KEYS => [],
776 88
            self::UNIQUES => [],
777 88
            self::CHECKS => [],
778
            self::DEFAULTS => [],
779
        ];
780
781
        /** @psalm-var array<array-key, array> $constraints */
782 88
        foreach ($constraints as $type => $names) {
783
            /**
784
             * @psalm-var object|string|null $name
785
             * @psalm-var ConstraintArray $constraint
786
             */
787 79
            foreach ($names as $name => $constraint) {
788 79
                switch ($type) {
789 79
                    case 'PK':
790
                        /** @var Constraint */
791 58
                        $result[self::PRIMARY_KEY] = (new Constraint())
792 58
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
793 58
                            ->name($name);
794 58
                        break;
795 79
                    case 'F':
796 21
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
797 21
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
798 21
                            ->foreignTableName($constraint[0]['foreign_table_name'])
799 21
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
800 21
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
801 21
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
802 21
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
803 21
                            ->name($name);
804 21
                        break;
805 64
                    case 'UQ':
806 62
                        $result[self::UNIQUES][] = (new Constraint())
807 62
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
808 62
                            ->name($name);
809 62
                        break;
810 36
                    case 'C':
811 18
                        $result[self::CHECKS][] = (new CheckConstraint())
812 18
                            ->expression($constraint[0]['check_expr'])
813 18
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
814 18
                            ->name($name);
815 18
                        break;
816 35
                    case 'D':
817 35
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
818 35
                            ->value($constraint[0]['default_expr'])
819 35
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
820 35
                            ->name($name);
821 35
                        break;
822
                }
823
            }
824
        }
825
826 88
        foreach ($result as $type => $data) {
827 88
            $this->setTableMetadata($tableName, $type, $data);
828
        }
829
830 88
        return $result[$returnType];
831
    }
832
833
    /**
834
     * Create a column schema builder instance giving the type and value precision.
835
     *
836
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
837
     *
838
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
839
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
840
     *
841
     * @return ColumnSchemaBuilder column schema builder instance
842
     *
843
     * @psalm-param array<array-key, string>|int|null|string $length
844
     */
845 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
846
    {
847 3
        return new ColumnSchemaBuilder($type, $length);
848
    }
849
850
    /**
851
     * Returns the cache key for the specified table name.
852
     *
853
     * @param string $name the table name.
854
     *
855
     * @return array the cache key.
856
     */
857 191
    protected function getCacheKey(string $name): array
858
    {
859 191
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
860
    }
861
862
    /**
863
     * Returns the cache tag name.
864
     *
865
     * This allows {@see refresh()} to invalidate all cached table schemas.
866
     *
867
     * @return string the cache tag name.
868
     */
869 191
    protected function getCacheTag(): string
870
    {
871 191
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
872
    }
873
874
    /**
875
     * @return bool whether this DBMS supports [savepoint](http://en.wikipedia.org/wiki/Savepoint).
876
     */
877 5
    public function supportsSavepoint(): bool
878
    {
879 5
        return $this->db->isSavepointEnabled();
880
    }
881
882
    /**
883
     * Changes row's array key case to lower.
884
     *
885
     * @param array $row row's array or an array of row's arrays.
886
     * @param bool $multiple whether multiple rows or a single row passed.
887
     *
888
     * @return array normalized row or rows.
889
     */
890 99
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
891
    {
892 99
        if ($multiple) {
893 99
            return array_map(static fn (array $row) => array_change_key_case($row, CASE_LOWER), $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