Passed
Push — master ( bc27c0...544315 )
by Wilmer
04:46
created

Schema::findColumns()   C

Complexity

Conditions 10
Paths 36

Size

Total Lines 83
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 10.4196

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 10
eloc 61
c 3
b 0
f 0
nc 36
nop 1
dl 0
loc 83
ccs 26
cts 31
cp 0.8387
crap 10.4196
rs 6.9842

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\Builder\ColumnInterface;
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
 * Implements the MSSQL Server specific schema, supporting MSSQL Server 2017 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
 * @psalm-type ConstraintArray = array<
43
 *   array-key,
44
 *   array {
45
 *     name: string,
46
 *     column_name: string,
47
 *     type: string,
48
 *     foreign_table_schema: string|null,
49
 *     foreign_table_name: string|null,
50
 *     foreign_column_name: string|null,
51
 *     on_update: string,
52
 *     on_delete: string,
53
 *     check_expr: string,
54
 *     default_expr: string
55
 *   }
56
 * >
57
 */
58
final class Schema extends AbstractSchema
59
{
60
    /**
61
     * @var string|null The default schema used for the current session.
62
     */
63
    protected string|null $defaultSchema = 'dbo';
64
65
    /**
66
     * @var array Mapping from physical column types (keys) to abstract column types (values).
67
     *
68
     * @psalm-var string[]
69
     */
70
    private array $typeMap = [
71
        /** Exact numbers */
72
        'bigint' => self::TYPE_BIGINT,
73
        'numeric' => self::TYPE_DECIMAL,
74
        'bit' => self::TYPE_SMALLINT,
75
        'smallint' => self::TYPE_SMALLINT,
76
        'decimal' => self::TYPE_DECIMAL,
77
        'smallmoney' => self::TYPE_MONEY,
78
        'int' => self::TYPE_INTEGER,
79
        'tinyint' => self::TYPE_TINYINT,
80
        'money' => self::TYPE_MONEY,
81
82
        /** Approximate numbers */
83
        'float' => self::TYPE_FLOAT,
84
        'double' => self::TYPE_DOUBLE,
85
        'real' => self::TYPE_FLOAT,
86
87
        /** Date and time */
88
        'date' => self::TYPE_DATE,
89
        'datetimeoffset' => self::TYPE_DATETIME,
90
        'datetime2' => self::TYPE_DATETIME,
91
        'smalldatetime' => self::TYPE_DATETIME,
92
        'datetime' => self::TYPE_DATETIME,
93
        'time' => self::TYPE_TIME,
94
95
        /** Character strings */
96
        'char' => self::TYPE_CHAR,
97
        'varchar' => self::TYPE_STRING,
98
        'text' => self::TYPE_TEXT,
99
100
        /** Unicode character strings */
101
        'nchar' => self::TYPE_CHAR,
102
        'nvarchar' => self::TYPE_STRING,
103
        'ntext' => self::TYPE_TEXT,
104
105
        /** Binary strings */
106
        'binary' => self::TYPE_BINARY,
107
        'varbinary' => self::TYPE_BINARY,
108
        'image' => self::TYPE_BINARY,
109
110
        /**
111
         * Other data types 'cursor' type can't be used with tables
112
         */
113
        'timestamp' => self::TYPE_TIMESTAMP,
114
        'hierarchyid' => self::TYPE_STRING,
115
        'uniqueidentifier' => self::TYPE_STRING,
116
        'sql_variant' => self::TYPE_STRING,
117
        'xml' => self::TYPE_STRING,
118
        'table' => self::TYPE_STRING,
119
    ];
120
121 14
    public function createColumn(string $type, array|int|string|null $length = null): ColumnInterface
122
    {
123 14
        return new Column($type, $length);
124
    }
125
126
    /**
127
     * Resolves the table name and schema name (if any).
128
     *
129
     * @param string $name The table name.
130
     *
131
     * @return TableSchemaInterface The resolved table name.
132
     */
133 579
    protected function resolveTableName(string $name): TableSchemaInterface
134
    {
135 579
        $resolvedName = new TableSchema();
136
137 579
        $parts = array_reverse(
138 579
            $this->db->getQuoter()->getTableNameParts($name)
139 579
        );
140
141 579
        $resolvedName->name($parts[0] ?? '');
142 579
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
143 579
        $resolvedName->catalogName($parts[2] ?? null);
144 579
        $resolvedName->serverName($parts[3] ?? null);
145
146 579
        if (empty($parts[2]) && $resolvedName->getSchemaName() === $this->defaultSchema) {
147 573
            $resolvedName->fullName($parts[0]);
148
        } else {
149 7
            $resolvedName->fullName(implode('.', array_reverse($parts)));
150
        }
151
152 579
        return $resolvedName;
153
    }
154
155
    /**
156
     * Returns all schema names in the database, including the default one but not system schemas.
157
     *
158
     * This method should be overridden by child classes to support this feature because the default implementation
159
     * simply throws an exception.
160
     *
161
     * @throws Exception
162
     * @throws InvalidConfigException
163
     * @throws Throwable
164
     *
165
     * @return array All schema name in the database, except system schemas.
166
     *
167
     * @link https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-principals-transact-sql
168
     */
169 1
    protected function findSchemaNames(): array
170
    {
171 1
        $sql = <<<SQL
172
        SELECT [s].[name]
173
        FROM [sys].[schemas] AS [s]
174
        INNER JOIN [sys].[database_principals] AS [p] ON [p].[principal_id] = [s].[principal_id]
175
        WHERE [p].[is_fixed_role] = 0 AND [p].[sid] IS NOT NULL
176
        ORDER BY [s].[name] ASC
177 1
        SQL;
178
179 1
        return $this->db->createCommand($sql)->queryColumn();
180
    }
181
182
    /**
183
     * @throws Exception
184
     * @throws InvalidConfigException
185
     * @throws Throwable
186
     */
187 505
    protected function findTableComment(TableSchemaInterface $tableSchema): void
188
    {
189 505
        $schemaName = $tableSchema->getSchemaName()
190 505
            ? "N'" . (string) $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
191 505
        $tableName = 'N' . (string) $this->db->getQuoter()->quoteValue($tableSchema->getName());
192
193 505
        $sql = <<<SQL
194 505
        SELECT [value]
195
        FROM fn_listextendedproperty (
196
            N'MS_description',
197 505
            'SCHEMA', $schemaName,
198 505
            'TABLE', $tableName,
199
            DEFAULT, DEFAULT)
200 505
        SQL;
201
202 505
        $comment = $this->db->createCommand($sql)->queryScalar();
203
204 505
        $tableSchema->comment(is_string($comment) ? $comment : null);
205
    }
206
207
    /**
208
     * Returns all table names in the database.
209
     *
210
     * This method should be overridden by child classes to support this feature because the default implementation
211
     * simply throws an exception.
212
     *
213
     * @param string $schema The schema of the tables.
214
     * Defaults to empty string, meaning the current or default schema.
215
     *
216
     * @throws Exception
217
     * @throws InvalidConfigException
218
     * @throws Throwable
219
     *
220
     * @return array All tables name in the database. The names have NO schema name prefix.
221
     */
222 11
    protected function findTableNames(string $schema = ''): array
223
    {
224 11
        if ($schema === '') {
225 11
            $schema = $this->defaultSchema;
226
        }
227
228 11
        $sql = <<<SQL
229
        SELECT [t].[table_name]
230
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
231
        WHERE [t].[table_schema] = :schema AND [t].[table_type] IN ('BASE TABLE', 'VIEW')
232
        ORDER BY [t].[table_name]
233 11
        SQL;
234
235 11
        return $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
236
    }
237
238
    /**
239
     * Loads the metadata for the specified table.
240
     *
241
     * @param string $name The table name.
242
     *
243
     * @throws Exception
244
     * @throws InvalidConfigException
245
     * @throws Throwable
246
     *
247
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table doesn't exist.
248
     */
249 505
    protected function loadTableSchema(string $name): TableSchemaInterface|null
250
    {
251 505
        $table = $this->resolveTableName($name);
252 505
        $this->findPrimaryKeys($table);
253 505
        $this->findTableComment($table);
254
255 505
        if ($this->findColumns($table)) {
256 485
            $this->findForeignKeys($table);
257 485
            return $table;
258
        }
259
260 166
        return null;
261
    }
262
263
    /**
264
     * Loads a primary key for the given table.
265
     *
266
     * @param string $tableName The table name.
267
     *
268
     * @throws Exception
269
     * @throws InvalidConfigException
270
     * @throws Throwable
271
     *
272
     * @return Constraint|null The primary key for the given table, `null` if the table has no primary key.
273
     */
274 50
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
275
    {
276
        /** @psalm-var mixed $tablePrimaryKey */
277 50
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
278 50
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
279
    }
280
281
    /**
282
     * Loads all foreign keys for the given table.
283
     *
284
     * @param string $tableName The table name.
285
     *
286
     * @throws Exception
287
     * @throws InvalidConfigException
288
     * @throws Throwable
289
     *
290
     * @return array The foreign keys for the given table.
291
     */
292 8
    protected function loadTableForeignKeys(string $tableName): array
293
    {
294
        /** @psalm-var mixed $tableForeignKeys */
295 8
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
296 8
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
297
    }
298
299
    /**
300
     * Loads all indexes for the given table.
301
     *
302
     * @param string $tableName The table name.
303
     *
304
     * @throws Exception
305
     * @throws InvalidConfigException
306
     * @throws Throwable
307
     *
308
     * @return array Indexes for the given table.
309
     */
310 39
    protected function loadTableIndexes(string $tableName): array
311
    {
312 39
        $sql = <<<SQL
313
        SELECT
314
            [i].[name] AS [name],
315
            [iccol].[name] AS [column_name],
316
            [i].[is_unique] AS [index_is_unique],
317
            [i].[is_primary_key] AS [index_is_primary]
318
        FROM [sys].[indexes] AS [i]
319
        INNER JOIN [sys].[index_columns] AS [ic]
320
            ON [ic].[object_id] = [i].[object_id] AND [ic].[index_id] = [i].[index_id]
321
        INNER JOIN [sys].[columns] AS [iccol]
322
            ON [iccol].[object_id] = [ic].[object_id] AND [iccol].[column_id] = [ic].[column_id]
323
        WHERE [i].[object_id] = OBJECT_ID(:fullName)
324
        ORDER BY [ic].[key_ordinal] ASC
325 39
        SQL;
326
327 39
        $resolvedName = $this->resolveTableName($tableName);
328 39
        $indexes = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
329
330
        /** @psalm-var array[] $indexes */
331 39
        $indexes = $this->normalizeRowKeyCase($indexes, true);
332 39
        $indexes = ArrayHelper::index($indexes, null, ['name']);
333
334 39
        $result = [];
335
336
        /**
337
         * @psalm-var array<
338
         *   string,
339
         *   array<
340
         *     array-key,
341
         *     array{name: string, column_name: string, index_is_unique: string, index_is_primary: string}
342
         *   >
343
         * > $indexes
344
         */
345 39
        foreach ($indexes as $name => $index) {
346 36
            $result[] = (new IndexConstraint())
347 36
                ->primary((bool) $index[0]['index_is_primary'])
348 36
                ->unique((bool) $index[0]['index_is_unique'])
349 36
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
350 36
                ->name($name);
351
        }
352
353 39
        return $result;
354
    }
355
356
    /**
357
     * Loads all unique constraints for the given table.
358
     *
359
     * @param string $tableName The table name.
360
     *
361
     * @throws Exception|InvalidConfigException|Throwable
362
     *
363
     * @return array The unique constraints for the given table.
364
     */
365 17
    protected function loadTableUniques(string $tableName): array
366
    {
367
        /** @psalm-var mixed $tableUniques */
368 17
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
369 17
        return is_array($tableUniques) ? $tableUniques : [];
370
    }
371
372
    /**
373
     * Loads all check constraints for the given table.
374
     *
375
     * @param string $tableName The table name.
376
     *
377
     * @throws Exception
378
     * @throws InvalidConfigException
379
     * @throws Throwable
380
     *
381
     * @return array The check constraints for the given table.
382
     */
383 17
    protected function loadTableChecks(string $tableName): array
384
    {
385
        /** @psalm-var mixed $tableCheck */
386 17
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
387 17
        return is_array($tableCheck) ? $tableCheck : [];
388
    }
389
390
    /**
391
     * Loads all default value constraints for the given table.
392
     *
393
     * @param string $tableName The table name.
394
     *
395
     * @throws Exception
396
     * @throws InvalidConfigException
397
     * @throws Throwable
398
     *
399
     * @return array The default value constraints for the given table.
400
     */
401 16
    protected function loadTableDefaultValues(string $tableName): array
402
    {
403
        /** @psalm-var mixed $tableDefault */
404 16
        $tableDefault = $this->loadTableConstraints($tableName, self::DEFAULTS);
405 16
        return is_array($tableDefault) ? $tableDefault : [];
406
    }
407
408
    /**
409
     * Creates a column schema for the database.
410
     *
411
     * This method may be overridden by child classes to create a DBMS-specific column schema.
412
     *
413
     * @param string $name Name of the column.
414
     *
415
     * @return ColumnSchema
416
     */
417 485
    protected function createColumnSchema(string $name): ColumnSchema
418
    {
419 485
        return new ColumnSchema($name);
0 ignored issues
show
Unused Code introduced by
The call to Yiisoft\Db\Mssql\ColumnSchema::__construct() has too many arguments starting with $name. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

419
        return /** @scrutinizer ignore-call */ new ColumnSchema($name);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
420
    }
421
422
    /**
423
     * Loads the column information into a {@see ColumnSchemaInterface} object.
424
     *
425
     * @psalm-param ColumnArray $info The column information.
426
     */
427 485
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
428
    {
429 485
        $column = $this->createColumnSchema($info['column_name']);
430 485
        $column->allowNull($info['is_nullable'] === 'YES');
431 485
        $column->dbType($info['data_type']);
432 485
        $column->enumValues([]); // MSSQL has only vague equivalents to enum.
433 485
        $column->primaryKey(false); // The primary key will be determined in the `findColumns()` method.
434 485
        $column->autoIncrement($info['is_identity'] === '1');
435 485
        $column->computed($info['is_computed'] === '1');
436 485
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
437 485
        $column->comment($info['comment'] ?? '');
438 485
        $column->type(self::TYPE_STRING);
439
440 485
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
441 485
            $type = $matches[1];
442
443 485
            if (isset($this->typeMap[$type])) {
444 485
                $column->type($this->typeMap[$type]);
445
            }
446
447 485
            if (!empty($matches[2])) {
448 318
                $values = explode(',', $matches[2]);
449 318
                $column->precision((int) $values[0]);
450 318
                $column->size((int) $values[0]);
451
452 318
                if (isset($values[1])) {
453 91
                    $column->scale((int) $values[1]);
454
                }
455
456 318
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
457
                    $column->type(self::TYPE_BOOLEAN);
458 318
                } elseif ($type === 'bit') {
459
                    if ($column->getSize() > 32) {
460
                        $column->type(self::TYPE_BIGINT);
461
                    } elseif ($column->getSize() === 32) {
462
                        $column->type(self::TYPE_INTEGER);
463
                    }
464
                }
465
            }
466
        }
467
468 485
        $column->phpType($this->getColumnPhpType($column));
469
470 485
        if ($info['column_default'] === '(NULL)') {
471 8
            $column->defaultValue(null);
472
        }
473
474 485
        if (!$column->isPrimaryKey() && !$column->isComputed() && $info['column_default'] !== null) {
475
            /** @psalm-var mixed $value */
476 367
            $value = $this->parseDefaultValue($info['column_default']);
477
478 367
            if (is_numeric($value)) {
479
                /** @psalm-var mixed $value */
480 128
                $value = $column->phpTypeCast($value);
481
            }
482
483 367
            $column->defaultValue($value);
484
        }
485
486 485
        return $column;
487
    }
488
489
    /**
490
     * Collects the metadata of table columns.
491
     *
492
     * @param TableSchemaInterface $table The table metadata.
493
     *
494
     * @throws Throwable
495
     *
496
     * @return bool Whether the table exists in the database.
497
     */
498 505
    protected function findColumns(TableSchemaInterface $table): bool
499
    {
500 505
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
501
502 505
        $whereParams = [':table_name' => $table->getName()];
503 505
        $whereSql = '[t1].[table_name] = :table_name';
504
505 505
        if ($table->getCatalogName() !== null) {
506
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
507
            $whereSql .= ' AND [t1].[table_catalog] = :catalog';
508
            $whereParams[':catalog'] = $table->getCatalogName();
509
        }
510
511 505
        if ($table->getSchemaName() !== null) {
512 505
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
513
        }
514
515 505
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
516
517 505
        $sql = <<<SQL
518 505
        SELECT
519
            [t1].[column_name],
520
            [t1].[is_nullable],
521
        CASE WHEN [t1].[data_type] IN ('char','varchar','nchar','nvarchar','binary','varbinary') THEN
522
        CASE WHEN [t1].[character_maximum_length] = NULL OR [t1].[character_maximum_length] = -1 THEN
523
            [t1].[data_type]
524
        ELSE
525
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[character_maximum_length]))) + ')'
526
        END
527
        WHEN [t1].[data_type] IN ('decimal','numeric') THEN
528
        CASE WHEN [t1].[numeric_precision] = NULL OR [t1].[numeric_precision] = -1 THEN
529
            [t1].[data_type]
530
        ELSE
531
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[numeric_precision]))) + ',' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[numeric_scale]))) + ')'
532
        END
533
        ELSE
534
            [t1].[data_type]
535
        END AS 'data_type',
536
        [t1].[column_default],
537
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsIdentity') AS is_identity,
538
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsComputed') AS is_computed,
539
        (
540
        SELECT CONVERT(VARCHAR, [t2].[value])
541
        FROM [sys].[extended_properties] AS [t2]
542
        WHERE
543
        [t2].[class] = 1 AND
544
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
545
        [t2].[name] = 'MS_Description' AND
546
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
547
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
548
        ) as comment
549 505
        FROM $columnsTableName AS [t1]
550 505
        WHERE $whereSql
551 505
        SQL;
552
553
        try {
554
            /** @psalm-var ColumnArray[] $columns */
555 505
            $columns = $this->db->createCommand($sql, $whereParams)->queryAll();
556
557 505
            if (empty($columns)) {
558 505
                return false;
559
            }
560
        } catch (Exception) {
561
            return false;
562
        }
563
564 485
        foreach ($columns as $column) {
565 485
            $column = $this->loadColumnSchema($column);
566 485
            foreach ($table->getPrimaryKey() as $primaryKey) {
567 297
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
568 297
                    $column->primaryKey(true);
569 297
                    break;
570
                }
571
            }
572
573 485
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
574 286
                $table->sequenceName('');
575
            }
576
577 485
            $table->columns($column->getName(), $column);
578
        }
579
580 485
        return true;
581
    }
582
583
    /**
584
     * Collects the constraint details for the given table and constraint type.
585
     *
586
     * @param string $type Either PRIMARY KEY or UNIQUE.
587
     *
588
     * @throws Exception
589
     * @throws InvalidConfigException
590
     * @throws Throwable
591
     *
592
     * @return array Each entry has index_name and field_name.
593
     */
594 505
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
595
    {
596 505
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
597 505
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
598
599 505
        $catalogName = $table->getCatalogName();
600 505
        if ($catalogName !== null) {
601
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
602
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
603
        }
604
605 505
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
606 505
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
607
608 505
        $sql = <<<SQL
609 505
        SELECT
610
            [kcu].[constraint_name] AS [index_name],
611
            [kcu].[column_name] AS [field_name]
612 505
        FROM $keyColumnUsageTableName AS [kcu]
613 505
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
614
            [kcu].[table_schema] = [tc].[table_schema] AND
615
            [kcu].[table_name] = [tc].[table_name] AND
616
            [kcu].[constraint_name] = [tc].[constraint_name]
617
        WHERE
618
            [tc].[constraint_type] = :type AND
619
            [kcu].[table_name] = :tableName AND
620
            [kcu].[table_schema] = :schemaName
621 505
        SQL;
622
623 505
        return $this->db->createCommand(
624 505
            $sql,
625 505
            [
626 505
                ':tableName' => $table->getName(),
627 505
                ':schemaName' => $table->getSchemaName(),
628 505
                ':type' => $type,
629 505
            ]
630 505
        )->queryAll();
631
    }
632
633
    /**
634
     * Collects the primary key column details for the given table.
635
     *
636
     * @param TableSchemaInterface $table The table metadata
637
     *
638
     * @throws Exception
639
     * @throws InvalidConfigException
640
     * @throws Throwable
641
     */
642 505
    protected function findPrimaryKeys(TableSchemaInterface $table): void
643
    {
644
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
645 505
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
646
647 505
        foreach ($primaryKeys as $row) {
648 297
            $table->primaryKey($row['field_name']);
649
        }
650
    }
651
652
    /**
653
     * Collects the foreign key column details for the given table.
654
     *
655
     * @param TableSchemaInterface $table The table metadata
656
     *
657
     * @throws Exception
658
     * @throws InvalidConfigException
659
     * @throws Throwable
660
     */
661 485
    protected function findForeignKeys(TableSchemaInterface $table): void
662
    {
663 485
        $catalogName = $table->getCatalogName();
664 485
        $fk = [];
665 485
        $object = $table->getName();
666 485
        $schemaName = $table->getSchemaName();
667
668 485
        if ($schemaName !== null) {
669 485
            $object = $schemaName . '.' . $object;
670
        }
671
672 485
        if ($catalogName !== null) {
673
            $object = $catalogName . '.' . $object;
674
        }
675
676 485
        $sql = <<<SQL
677
        SELECT
678
        [fk].[name] AS [fk_name],
679
        [cp].[name] AS [fk_column_name],
680
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
681
        [cr].[name] AS [uq_column_name]
682
        FROM [sys].[foreign_keys] AS [fk]
683
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
684
            ON [fk].[object_id] = [fkc].[constraint_object_id]
685
        INNER JOIN [sys].[columns] AS [cp]
686
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
687
        INNER JOIN [sys].[columns] AS [cr]
688
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
689
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
690 485
        SQL;
691
692
        /**
693
         * @psalm-var array<
694
         *   array-key,
695
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
696
         * > $rows
697
         */
698 485
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
699 485
        $table->foreignKeys([]);
700
701 485
        foreach ($rows as $row) {
702 10
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
703 10
                $fk[$row['fk_name']][] = $row['uq_table_name'];
704 10
                $table->foreignKeys($fk);
705
            }
706
707 10
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
708 10
            $table->foreignKeys($fk);
709
        }
710
    }
711
712
    /**
713
     * @throws Exception
714
     * @throws InvalidConfigException
715
     * @throws Throwable
716
     */
717 4
    protected function findViewNames(string $schema = ''): array
718
    {
719 4
        if ($schema === '') {
720 1
            $schema = $this->defaultSchema;
721
        }
722
723 4
        $sql = <<<SQL
724
        SELECT [t].[table_name]
725
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
726
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
727
        ORDER BY [t].[table_name]
728 4
        SQL;
729
730 4
        return $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
731
    }
732
733
    /**
734
     * Returns all unique indexes for the given table.
735
     *
736
     * Each array element is of the following structure:
737
     *
738
     * ```php
739
     * [
740
     *     'IndexName1' => ['col1' [, ...]],
741
     *     'IndexName2' => ['col2' [, ...]],
742
     * ]
743
     * ```
744
     *
745
     * @param TableSchemaInterface $table The table metadata.
746
     *
747
     * @throws Exception
748
     * @throws InvalidConfigException
749
     * @throws Throwable
750
     *
751
     * @return array All unique indexes for the given table.
752
     */
753 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
754
    {
755 1
        $result = [];
756
757
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
758 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
759
760 1
        foreach ($tableUniqueConstraints as $row) {
761 1
            $result[$row['index_name']][] = $row['field_name'];
762
        }
763
764 1
        return $result;
765
    }
766
767
    /**
768
     * Loads multiple types of constraints and returns the specified ones.
769
     *
770
     * @param string $tableName table name.
771
     * @param string $returnType return type:
772
     * - primaryKey
773
     * - foreignKeys
774
     * - uniques
775
     * - checks
776
     * - defaults
777
     *
778
     * @throws Exception
779
     * @throws InvalidConfigException
780
     * @throws Throwable
781
     *
782
     * @return mixed Constraints of the specified type.
783
     */
784 108
    private function loadTableConstraints(string $tableName, string $returnType): mixed
785
    {
786 108
        $sql = <<<SQL
787
        SELECT
788
            [o].[name] AS [name],
789
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
790
            RTRIM([o].[type]) AS [type],
791
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
792
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
793
            [ffccol].[name] AS [foreign_column_name],
794
            [f].[update_referential_action_desc] AS [on_update],
795
            [f].[delete_referential_action_desc] AS [on_delete],
796
            [c].[definition] AS [check_expr],
797
            [d].[definition] AS [default_expr]
798
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
799
        INNER JOIN [sys].[objects] AS [o]
800
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
801
        LEFT JOIN [sys].[check_constraints] AS [c]
802
            ON [c].[object_id] = [o].[object_id]
803
        LEFT JOIN [sys].[columns] AS [ccol]
804
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
805
        LEFT JOIN [sys].[default_constraints] AS [d]
806
            ON [d].[object_id] = [o].[object_id]
807
        LEFT JOIN [sys].[columns] AS [dcol]
808
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
809
        LEFT JOIN [sys].[key_constraints] AS [k]
810
            ON [k].[object_id] = [o].[object_id]
811
        LEFT JOIN [sys].[index_columns] AS [kic]
812
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
813
        LEFT JOIN [sys].[columns] AS [kiccol]
814
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
815
        LEFT JOIN [sys].[foreign_keys] AS [f]
816
            ON [f].[object_id] = [o].[object_id]
817
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
818
            ON [fc].[constraint_object_id] = [o].[object_id]
819
        LEFT JOIN [sys].[columns] AS [fccol]
820
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
821
        LEFT JOIN [sys].[columns] AS [ffccol]
822
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
823
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
824 108
        SQL;
825
826 108
        $resolvedName = $this->resolveTableName($tableName);
827 108
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
828
829
        /** @psalm-var array[] $constraints */
830 108
        $constraints = $this->normalizeRowKeyCase($constraints, true);
831 108
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
832
833 108
        $result = [
834 108
            self::PRIMARY_KEY => null,
835 108
            self::FOREIGN_KEYS => [],
836 108
            self::UNIQUES => [],
837 108
            self::CHECKS => [],
838 108
            self::DEFAULTS => [],
839 108
        ];
840
841
        /** @psalm-var array<array-key, array> $constraints */
842 108
        foreach ($constraints as $type => $names) {
843
            /**
844
             * @psalm-var object|string|null $name
845
             * @psalm-var ConstraintArray $constraint
846
             */
847 99
            foreach ($names as $name => $constraint) {
848
                switch ($type) {
849 99
                    case 'PK':
850
                        /** @psalm-var Constraint */
851 65
                        $result[self::PRIMARY_KEY] = (new Constraint())
852 65
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
853 65
                            ->name($name);
854 65
                        break;
855 93
                    case 'F':
856 23
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
857 23
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
858 23
                            ->foreignTableName($constraint[0]['foreign_table_name'])
859 23
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
860 23
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
861 23
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
862 23
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
863 23
                            ->name($name);
864 23
                        break;
865 77
                    case 'UQ':
866 71
                        $result[self::UNIQUES][] = (new Constraint())
867 71
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
868 71
                            ->name($name);
869 71
                        break;
870 41
                    case 'C':
871 19
                        $result[self::CHECKS][] = (new CheckConstraint())
872 19
                            ->expression($constraint[0]['check_expr'])
873 19
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
874 19
                            ->name($name);
875 19
                        break;
876 38
                    case 'D':
877 38
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
878 38
                            ->value($constraint[0]['default_expr'])
879 38
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
880 38
                            ->name($name);
881 38
                        break;
882
                }
883
            }
884
        }
885
886 108
        foreach ($result as $type => $data) {
887 108
            $this->setTableMetadata($tableName, $type, $data);
888
        }
889
890 108
        return $result[$returnType];
891
    }
892
893
    /**
894
     * Returns the cache key for the specified table name.
895
     *
896
     * @param string $name The table name.
897
     *
898
     * @return array The cache key.
899
     */
900 607
    protected function getCacheKey(string $name): array
901
    {
902 607
        return array_merge([self::class], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
903
    }
904
905
    /**
906
     * Returns the cache tag name.
907
     *
908
     * This allows {@see refresh()} to invalidate all cached table schemas.
909
     *
910
     * @return string The cache tag name.
911
     */
912 608
    protected function getCacheTag(): string
913
    {
914 608
        return md5(serialize(array_merge([self::class], $this->db->getCacheKey())));
915
    }
916
917 367
    private function parseDefaultValue(mixed $value): mixed
918
    {
919 367
        $value = (string) $value;
920
921 367
        if (preg_match('/^\'(.*)\'$/', $value, $matches)) {
922 83
            return $matches[1];
923
        }
924
925 367
        if (preg_match('/^\((.*)\)$/', $value, $matches)) {
926 367
            return $this->parseDefaultValue($matches[1]);
927
        }
928
929 295
        return $value;
930
    }
931
}
932