Schema   F
last analyzed

Complexity

Total Complexity 74

Size/Duplication

Total Lines 877
Duplicated Lines 0 %

Test Coverage

Coverage 96.95%

Importance

Changes 16
Bugs 1 Features 1
Metric Value
wmc 74
eloc 381
c 16
b 1
f 1
dl 0
loc 877
ccs 254
cts 262
cp 0.9695
rs 2.48

25 Methods

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

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

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

910
        return array_merge([self::class], $this->generateCacheKey(), [/** @scrutinizer ignore-deprecated */ $this->getRawTableName($name)]);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
911
    }
912
913
    /**
914
     * Returns the cache tag name.
915
     *
916
     * This allows {@see refresh()} to invalidate all cached table schemas.
917
     *
918
     * @return string The cache tag name.
919
     */
920 579
    protected function getCacheTag(): string
921
    {
922 579
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
923
    }
924
925 322
    private function parseDefaultValue(string $value): string
926
    {
927 322
        if (preg_match('/^\'(.*)\'$/', $value, $matches)) {
928 74
            return $matches[1];
929
        }
930
931 322
        if (preg_match('/^\((.*)\)$/', $value, $matches)) {
932 322
            return $this->parseDefaultValue($matches[1]);
933
        }
934
935 257
        return $value;
936
    }
937
}
938