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