Passed
Pull Request — master (#212)
by Wilmer
04:03
created

Schema::loadColumnSchema()   C

Complexity

Conditions 16
Paths 138

Size

Total Lines 62
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 16.5834

Importance

Changes 4
Bugs 1 Features 0
Metric Value
cc 16
eloc 37
c 4
b 1
f 0
nc 138
nop 1
dl 0
loc 62
ccs 33
cts 38
cp 0.8684
crap 16.5834
rs 5.25

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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