Passed
Pull Request — master (#211)
by Alexander
08:23 queued 03:53
created

Schema::findForeignKeys()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 52
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 5.0035

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 31
c 1
b 0
f 0
nc 12
nop 1
dl 0
loc 52
ccs 18
cts 19
cp 0.9474
crap 5.0035
rs 9.1128

How to fix   Long Method   

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