Passed
Pull Request — master (#211)
by Wilmer
04:20
created

Schema::loadColumnSchema()   C

Complexity

Conditions 14
Paths 138

Size

Total Lines 62
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 14.7716

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 14
eloc 37
c 5
b 1
f 0
nc 138
nop 1
dl 0
loc 62
ccs 32
cts 38
cp 0.8421
crap 14.7716
rs 5.95

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