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

Schema   F

Complexity

Total Complexity 78

Size/Duplication

Total Lines 890
Duplicated Lines 0 %

Test Coverage

Coverage 94.76%

Importance

Changes 11
Bugs 1 Features 1
Metric Value
wmc 78
eloc 416
c 11
b 1
f 1
dl 0
loc 890
ccs 253
cts 267
cp 0.9476
rs 2.16

24 Methods

Rating   Name   Duplication   Size   Complexity  
A findSchemaNames() 0 11 1
A loadTableDefaultValues() 0 5 2
C loadTableConstraints() 0 107 9
A loadTableUniques() 0 5 2
A loadTableForeignKeys() 0 5 2
A createColumnSchema() 0 3 1
A findTableComment() 0 18 3
A findViewNames() 0 14 2
B findColumns() 0 77 10
A resolveTableName() 0 20 3
A findTableNames() 0 14 2
C loadColumnSchema() 0 62 14
A getCacheKey() 0 3 1
A parseDefaultValue() 0 19 5
A findUniqueIndexes() 0 12 2
A loadTablePrimaryKey() 0 5 2
A loadTableSchema() 0 12 2
A findForeignKeys() 0 52 5
A findPrimaryKeys() 0 7 2
A getCacheTag() 0 3 1
A createColumnSchemaBuilder() 0 5 1
A findTableConstraints() 0 37 2
A loadTableIndexes() 0 44 2
A loadTableChecks() 0 5 2

How to fix   Complexity   

Complex Class

Complex classes like Schema often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Schema, and based on these observations, apply Extract Interface, too.

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