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