Passed
Pull Request — master (#212)
by Alexander
09:58 queued 04:41
created

Schema::loadColumnSchema()   C

Complexity

Conditions 16
Paths 138

Size

Total Lines 62
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 17.0078

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 32
cts 38
cp 0.8421
crap 17.0078
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 458
    protected function resolveTableName(string $name): TableSchemaInterface
132
    {
133 458
        $resolvedName = new TableSchema();
134
135 458
        $parts = array_reverse(
136 458
            $this->db->getQuoter()->getTableNameParts($name)
137 458
        );
138
139 458
        $resolvedName->name($parts[0] ?? '');
140 458
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
141 458
        $resolvedName->catalogName($parts[2] ?? null);
142 458
        $resolvedName->serverName($parts[3] ?? null);
143
144 458
        if (empty($parts[2]) && $resolvedName->getSchemaName() === $this->defaultSchema) {
145 452
            $resolvedName->fullName($parts[0]);
146
        } else {
147 7
            $resolvedName->fullName(implode('.', array_reverse($parts)));
148
        }
149
150 458
        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 384
    protected function findTableComment(TableSchemaInterface $tableSchema): void
179
    {
180 384
        $schemaName = $tableSchema->getSchemaName()
181 384
            ? "N'" . (string) $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
182 384
        $tableName = 'N' . (string) $this->db->getQuoter()->quoteValue($tableSchema->getName());
183
184 384
        $sql = <<<SQL
185 384
        SELECT [value]
186
        FROM fn_listextendedproperty (
187
            N'MS_description',
188 384
            'SCHEMA', $schemaName,
189 384
            'TABLE', $tableName,
190
            DEFAULT, DEFAULT)
191 384
        SQL;
192
193 384
        $comment = $this->db->createCommand($sql)->queryScalar();
194
195 384
        $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 384
    protected function loadTableSchema(string $name): TableSchemaInterface|null
236
    {
237 384
        $table = $this->resolveTableName($name);
238 384
        $this->findPrimaryKeys($table);
239 384
        $this->findTableComment($table);
240
241 384
        if ($this->findColumns($table)) {
242 364
            $this->findForeignKeys($table);
243 364
            return $table;
244
        }
245
246 115
        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 364
    protected function createColumnSchema(): ColumnSchema
392
    {
393 364
        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 364
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
404
    {
405 364
        $column = $this->createColumnSchema();
406
407 364
        $column->name($info['column_name']);
408 364
        $column->allowNull($info['is_nullable'] === 'YES');
409 364
        $column->dbType($info['data_type']);
410 364
        $column->enumValues([]); // mssql has only vague equivalents to enum
411 364
        $column->primaryKey(false); // primary key will be determined in findColumns() method
412 364
        $column->autoIncrement($info['is_identity'] === '1');
413 364
        $column->computed($info['is_computed'] === '1');
414 364
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
415 364
        $column->comment($info['comment'] ?? '');
416 364
        $column->type(self::TYPE_STRING);
417
418 364
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
419 364
            $type = $matches[1];
420
421 364
            if (isset($this->typeMap[$type])) {
422 364
                $column->type($this->typeMap[$type]);
423
            }
424
425 364
            if (!empty($matches[2])) {
426 233
                $values = explode(',', $matches[2]);
427 233
                $column->precision((int) $values[0]);
428 233
                $column->size((int) $values[0]);
429
430 233
                if (isset($values[1])) {
431
                    $column->scale((int) $values[1]);
432
                }
433
434 233
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
435
                    $column->type(self::TYPE_BOOLEAN);
436 233
                } 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 364
        $column->phpType($this->getColumnPhpType($column));
447
448 364
        if ($info['column_default'] === '(NULL)') {
449 8
            $column->defaultValue(null);
450
        }
451
452 364
        if (!$column->isPrimaryKey() && !$column->isComputed() && $info['column_default'] !== null) {
453
            /** @psalm-var mixed $value */
454 250
            $value = $this->parseDefaultValue($info['column_default']);
455
456 250
            if (is_numeric($value)) {
457
                /** @psalm-var mixed $value */
458 102
                $value = $column->phpTypeCast($value);
459
            }
460
461 250
            $column->defaultValue($value);
462
        }
463
464 364
        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 384
    protected function findColumns(TableSchemaInterface $table): bool
477
    {
478 384
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
479
480 384
        $whereParams = [':table_name' => $table->getName()];
481 384
        $whereSql = '[t1].[table_name] = :table_name';
482
483 384
        if ($table->getCatalogName() !== null) {
484
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
485
            $whereSql .= ' AND [t1].[table_catalog] = :catalog';
486
            $whereParams[':catalog'] = $table->getCatalogName();
487
        }
488
489 384
        if ($table->getSchemaName() !== null) {
490 384
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
491
        }
492
493 384
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
494
495 384
        $sql = <<<SQL
496 384
        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
        ELSE
506
            [t1].[data_type]
507
        END AS 'data_type',
508
        [t1].[column_default],
509
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsIdentity') AS is_identity,
510
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsComputed') AS is_computed,
511
        (
512
        SELECT CONVERT(VARCHAR, [t2].[value])
513
        FROM [sys].[extended_properties] AS [t2]
514
        WHERE
515
        [t2].[class] = 1 AND
516
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
517
        [t2].[name] = 'MS_Description' AND
518
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
519
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
520
        ) as comment
521 384
        FROM $columnsTableName AS [t1]
522 384
        WHERE $whereSql
523 384
        SQL;
524
525
        try {
526
            /** @psalm-var ColumnArray[] */
527 384
            $columns = $this->db->createCommand($sql, $whereParams)->queryAll();
528
529 384
            if (empty($columns)) {
530 384
                return false;
531
            }
532
        } catch (Exception) {
533
            return false;
534
        }
535
536 364
        foreach ($columns as $column) {
537 364
            $column = $this->loadColumnSchema($column);
538 364
            foreach ($table->getPrimaryKey() as $primaryKey) {
539 237
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
540 237
                    $column->primaryKey(true);
541 237
                    break;
542
                }
543
            }
544
545 364
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
546 230
                $table->sequenceName('');
547
            }
548
549 364
            $table->columns($column->getName(), $column);
550
        }
551
552 364
        return true;
553
    }
554
555
    /**
556
     * Collects the constraint details for the given table and constraint type.
557
     *
558
     * @param string $type either PRIMARY KEY or UNIQUE.
559
     *
560
     * @throws Exception|InvalidConfigException|Throwable
561
     *
562
     * @return array each entry contains index_name and field_name.
563
     */
564 384
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
565
    {
566 384
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
567 384
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
568
569 384
        $catalogName = $table->getCatalogName();
570 384
        if ($catalogName !== null) {
571
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
572
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
573
        }
574
575 384
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
576 384
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
577
578 384
        $sql = <<<SQL
579 384
        SELECT
580
            [kcu].[constraint_name] AS [index_name],
581
            [kcu].[column_name] AS [field_name]
582 384
        FROM $keyColumnUsageTableName AS [kcu]
583 384
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
584
            [kcu].[table_schema] = [tc].[table_schema] AND
585
            [kcu].[table_name] = [tc].[table_name] AND
586
            [kcu].[constraint_name] = [tc].[constraint_name]
587
        WHERE
588
            [tc].[constraint_type] = :type AND
589
            [kcu].[table_name] = :tableName AND
590
            [kcu].[table_schema] = :schemaName
591 384
        SQL;
592
593 384
        return $this->db->createCommand(
594 384
            $sql,
595 384
            [
596 384
                ':tableName' => $table->getName(),
597 384
                ':schemaName' => $table->getSchemaName(),
598 384
                ':type' => $type,
599 384
            ]
600 384
        )->queryAll();
601
    }
602
603
    /**
604
     * Collects the primary key column details for the given table.
605
     *
606
     * @param TableSchemaInterface $table the table metadata
607
     *
608
     * @throws Exception|InvalidConfigException|Throwable
609
     */
610 384
    protected function findPrimaryKeys(TableSchemaInterface $table): void
611
    {
612
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
613 384
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
614
615 384
        foreach ($primaryKeys as $row) {
616 237
            $table->primaryKey($row['field_name']);
617
        }
618
    }
619
620
    /**
621
     * Collects the foreign key column details for the given table.
622
     *
623
     * @param TableSchemaInterface $table the table metadata
624
     *
625
     * @throws Exception|InvalidConfigException|Throwable
626
     */
627 364
    protected function findForeignKeys(TableSchemaInterface $table): void
628
    {
629 364
        $catalogName = $table->getCatalogName();
630 364
        $fk = [];
631 364
        $object = $table->getName();
632 364
        $schemaName = $table->getSchemaName();
633
634 364
        if ($schemaName !== null) {
635 364
            $object = $schemaName . '.' . $object;
636
        }
637
638 364
        if ($catalogName !== null) {
639
            $object = $catalogName . '.' . $object;
640
        }
641
642
        /**
643
         * Please refer to the following page for more details:
644
         * {@see http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx}
645
         */
646 364
        $sql = <<<SQL
647
        SELECT
648
        [fk].[name] AS [fk_name],
649
        [cp].[name] AS [fk_column_name],
650
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
651
        [cr].[name] AS [uq_column_name]
652
        FROM [sys].[foreign_keys] AS [fk]
653
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
654
            ON [fk].[object_id] = [fkc].[constraint_object_id]
655
        INNER JOIN [sys].[columns] AS [cp]
656
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
657
        INNER JOIN [sys].[columns] AS [cr]
658
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
659
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
660 364
        SQL;
661
662
        /**
663
         * @psalm-var array<
664
         *   array-key,
665
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
666
         * > $rows
667
         */
668 364
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
669 364
        $table->foreignKeys([]);
670
671 364
        foreach ($rows as $row) {
672 10
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
673 10
                $fk[$row['fk_name']][] = $row['uq_table_name'];
674 10
                $table->foreignKeys($fk);
675
            }
676
677 10
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
678 10
            $table->foreignKeys($fk);
679
        }
680
    }
681
682
    /**
683
     * @throws Exception|InvalidConfigException|Throwable
684
     */
685 4
    protected function findViewNames(string $schema = ''): array
686
    {
687 4
        if ($schema === '') {
688 1
            $schema = $this->defaultSchema;
689
        }
690
691 4
        $sql = <<<SQL
692
        SELECT [t].[table_name]
693
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
694
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
695
        ORDER BY [t].[table_name]
696 4
        SQL;
697
698 4
        return $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
699
    }
700
701
    /**
702
     * Returns all unique indexes for the given table.
703
     *
704
     * Each array element is of the following structure:
705
     *
706
     * ```php
707
     * [
708
     *     'IndexName1' => ['col1' [, ...]],
709
     *     'IndexName2' => ['col2' [, ...]],
710
     * ]
711
     * ```
712
     *
713
     * @param TableSchemaInterface $table the table metadata.
714
     *
715
     * @throws Exception|InvalidConfigException|Throwable
716
     *
717
     * @return array all unique indexes for the given table.
718
     */
719 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
720
    {
721 1
        $result = [];
722
723
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
724 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
725
726 1
        foreach ($tableUniqueConstraints as $row) {
727 1
            $result[$row['index_name']][] = $row['field_name'];
728
        }
729
730 1
        return $result;
731
    }
732
733
    /**
734
     * Loads multiple types of constraints and returns the specified ones.
735
     *
736
     * @param string $tableName table name.
737
     * @param string $returnType return type:
738
     * - primaryKey
739
     * - foreignKeys
740
     * - uniques
741
     * - checks
742
     * - defaults
743
     *
744
     * @throws Exception|InvalidConfigException|Throwable
745
     *
746
     * @return mixed constraints.
747
     */
748 108
    private function loadTableConstraints(string $tableName, string $returnType): mixed
749
    {
750 108
        $sql = <<<SQL
751
        SELECT
752
            [o].[name] AS [name],
753
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
754
            RTRIM([o].[type]) AS [type],
755
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
756
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
757
            [ffccol].[name] AS [foreign_column_name],
758
            [f].[update_referential_action_desc] AS [on_update],
759
            [f].[delete_referential_action_desc] AS [on_delete],
760
            [c].[definition] AS [check_expr],
761
            [d].[definition] AS [default_expr]
762
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
763
        INNER JOIN [sys].[objects] AS [o]
764
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
765
        LEFT JOIN [sys].[check_constraints] AS [c]
766
            ON [c].[object_id] = [o].[object_id]
767
        LEFT JOIN [sys].[columns] AS [ccol]
768
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
769
        LEFT JOIN [sys].[default_constraints] AS [d]
770
            ON [d].[object_id] = [o].[object_id]
771
        LEFT JOIN [sys].[columns] AS [dcol]
772
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
773
        LEFT JOIN [sys].[key_constraints] AS [k]
774
            ON [k].[object_id] = [o].[object_id]
775
        LEFT JOIN [sys].[index_columns] AS [kic]
776
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
777
        LEFT JOIN [sys].[columns] AS [kiccol]
778
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
779
        LEFT JOIN [sys].[foreign_keys] AS [f]
780
            ON [f].[object_id] = [o].[object_id]
781
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
782
            ON [fc].[constraint_object_id] = [o].[object_id]
783
        LEFT JOIN [sys].[columns] AS [fccol]
784
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
785
        LEFT JOIN [sys].[columns] AS [ffccol]
786
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
787
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
788 108
        SQL;
789
790 108
        $resolvedName = $this->resolveTableName($tableName);
791 108
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
792
793
        /** @psalm-var array[] $constraints */
794 108
        $constraints = $this->normalizeRowKeyCase($constraints, true);
795 108
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
796
797 108
        $result = [
798 108
            self::PRIMARY_KEY => null,
799 108
            self::FOREIGN_KEYS => [],
800 108
            self::UNIQUES => [],
801 108
            self::CHECKS => [],
802 108
            self::DEFAULTS => [],
803 108
        ];
804
805
        /** @psalm-var array<array-key, array> $constraints */
806 108
        foreach ($constraints as $type => $names) {
807
            /**
808
             * @psalm-var object|string|null $name
809
             * @psalm-var ConstraintArray $constraint
810
             */
811 99
            foreach ($names as $name => $constraint) {
812
                switch ($type) {
813 99
                    case 'PK':
814
                        /** @var Constraint */
815 65
                        $result[self::PRIMARY_KEY] = (new Constraint())
816 65
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
817 65
                            ->name($name);
818 65
                        break;
819 93
                    case 'F':
820 23
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
821 23
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
822 23
                            ->foreignTableName($constraint[0]['foreign_table_name'])
823 23
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
824 23
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
825 23
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
826 23
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
827 23
                            ->name($name);
828 23
                        break;
829 77
                    case 'UQ':
830 71
                        $result[self::UNIQUES][] = (new Constraint())
831 71
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
832 71
                            ->name($name);
833 71
                        break;
834 41
                    case 'C':
835 19
                        $result[self::CHECKS][] = (new CheckConstraint())
836 19
                            ->expression($constraint[0]['check_expr'])
837 19
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
838 19
                            ->name($name);
839 19
                        break;
840 38
                    case 'D':
841 38
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
842 38
                            ->value($constraint[0]['default_expr'])
843 38
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
844 38
                            ->name($name);
845 38
                        break;
846
                }
847
            }
848
        }
849
850 108
        foreach ($result as $type => $data) {
851 108
            $this->setTableMetadata($tableName, $type, $data);
852
        }
853
854 108
        return $result[$returnType];
855
    }
856
857 6
    public function createColumnSchemaBuilder(
858
        string $type,
859
        array|int|string $length = null
860
    ): ColumnSchemaBuilderInterface {
861 6
        return new ColumnSchemaBuilder($type, $length);
862
    }
863
864
    /**
865
     * Returns the cache key for the specified table name.
866
     *
867
     * @param string $name the table name.
868
     *
869
     * @return array the cache key.
870
     */
871 486
    protected function getCacheKey(string $name): array
872
    {
873 486
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
874
    }
875
876
    /**
877
     * Returns the cache tag name.
878
     *
879
     * This allows {@see refresh()} to invalidate all cached table schemas.
880
     *
881
     * @return string the cache tag name.
882
     */
883 487
    protected function getCacheTag(): string
884
    {
885 487
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
886
    }
887
888 250
    private function parseDefaultValue(mixed $value): mixed
889
    {
890 250
        $value = (string) $value;
891
892 250
        if (preg_match('/^\'(.*)\'$/', $value, $matches)) {
893 47
            return $matches[1];
894
        }
895
896 250
        if (preg_match('/^\((.*)\)$/', $value, $matches)) {
897 250
            return $this->parseDefaultValue($matches[1]);
898
        }
899
900 207
        return $value;
901
    }
902
}
903