Passed
Push — master ( 6bbdcb...f9cfec )
by Def
05:50 queued 02:16
created

Schema::loadColumnSchema()   F

Complexity

Conditions 18
Paths 276

Size

Total Lines 65
Code Lines 39

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 34
CRAP Score 19.0935

Importance

Changes 4
Bugs 1 Features 0
Metric Value
cc 18
eloc 39
c 4
b 1
f 0
nc 276
nop 1
dl 0
loc 65
ccs 34
cts 40
cp 0.85
crap 19.0935
rs 3.0833

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