Test Failed
Pull Request — master (#256)
by Wilmer
11:05 queued 06:51
created

Schema   F

Complexity

Total Complexity 80

Size/Duplication

Total Lines 894
Duplicated Lines 0 %

Test Coverage

Coverage 95.08%

Importance

Changes 13
Bugs 1 Features 1
Metric Value
eloc 389
c 13
b 1
f 1
dl 0
loc 894
ccs 251
cts 264
cp 0.9508
rs 2
wmc 80

25 Methods

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

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

933
            $cacheKey = [$this->db->/** @scrutinizer ignore-call */ getDriver()->getDsn(), $this->db->getDriver()->getUsername()];

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
934
        } else {
935
            throw new NotSupportedException('Only PDO connections are supported.');
936
        }
937
938
        return $cacheKey;
939
    }
940
941
    private function parseDefaultValue(mixed $value): mixed
942
    {
943
        $value = (string) $value;
944
945
        if (preg_match('/^\'(.*)\'$/', $value, $matches)) {
946
            return $matches[1];
947
        }
948
949
        if (preg_match('/^\((.*)\)$/', $value, $matches)) {
950
            return $this->parseDefaultValue($matches[1]);
951
        }
952
953
        return $value;
954
    }
955
}
956