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

Schema::findColumns()   C

Complexity

Conditions 10
Paths 36

Size

Total Lines 83
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 10.4632

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 10
eloc 61
c 3
b 0
f 0
nc 36
nop 1
dl 0
loc 83
ccs 25
cts 30
cp 0.8333
crap 10.4632
rs 6.9842

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Mssql;
6
7
use Throwable;
8
use Yiisoft\Db\Constraint\CheckConstraint;
9
use Yiisoft\Db\Constraint\Constraint;
10
use Yiisoft\Db\Constraint\DefaultValueConstraint;
11
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
12
use Yiisoft\Db\Constraint\IndexConstraint;
13
use Yiisoft\Db\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