Test Failed
Pull Request — master (#277)
by Sergei
19:11 queued 04:37
created

Schema::findColumns()   C

Complexity

Conditions 10
Paths 36

Size

Total Lines 83
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 10.0658

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 21
cts 23
cp 0.913
crap 10.0658
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\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\Column\ColumnSchemaInterface;
0 ignored issues
show
Bug introduced by
The type Yiisoft\Db\Schema\Column\ColumnSchemaInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
19
use Yiisoft\Db\Schema\TableSchemaInterface;
20
21
use function explode;
22
use function is_array;
23
use function md5;
24
use function preg_match;
25
use function serialize;
26
use function str_replace;
27
use function strcasecmp;
28
use function stripos;
29
30
/**
31
 * Implements the MSSQL Server specific schema, supporting MSSQL Server 2017 and above.
32
 *
33
 * @psalm-type ColumnArray = array{
34
 *   column_name: string,
35
 *   is_nullable: string,
36
 *   data_type: string,
37
 *   column_default: string|null,
38
 *   is_identity: string,
39
 *   is_computed: string,
40
 *   comment: null|string,
41
 *   size?: int,
42
 *   precision?: int,
43
 *   scale?: int,
44
 * }
45
 * @psalm-type ConstraintArray = array<
46
 *   array-key,
47
 *   array {
48
 *     name: string,
49
 *     column_name: string,
50
 *     type: string,
51
 *     foreign_table_schema: string|null,
52
 *     foreign_table_name: string|null,
53
 *     foreign_column_name: string|null,
54
 *     on_update: string,
55
 *     on_delete: string,
56
 *     check_expr: string,
57
 *     default_expr: string
58
 *   }
59
 * >
60
 */
61
final class Schema extends AbstractPdoSchema
62
{
63
    /**
64
     * @var string|null The default schema used for the current session.
65
     */
66
    protected string|null $defaultSchema = 'dbo';
67
68
    /**
69
     * @var array Mapping from physical column types (keys) to abstract column types (values).
70
     *
71
     * @psalm-var string[]
72
     */
73
    private array $typeMap = [
74
        /** Exact numbers */
75
        'bigint' => self::TYPE_BIGINT,
76
        'numeric' => self::TYPE_DECIMAL,
77
        'bit' => self::TYPE_SMALLINT,
78
        'smallint' => self::TYPE_SMALLINT,
79
        'decimal' => self::TYPE_DECIMAL,
80
        'smallmoney' => self::TYPE_MONEY,
81
        'int' => self::TYPE_INTEGER,
82
        'tinyint' => self::TYPE_TINYINT,
83
        'money' => self::TYPE_MONEY,
84
85
        /** Approximate numbers */
86
        'float' => self::TYPE_FLOAT,
87
        'double' => self::TYPE_DOUBLE,
88
        'real' => self::TYPE_FLOAT,
89
90
        /** Date and time */
91
        'date' => self::TYPE_DATE,
92
        'datetimeoffset' => self::TYPE_DATETIME,
93
        'datetime2' => self::TYPE_DATETIME,
94
        'smalldatetime' => self::TYPE_DATETIME,
95
        'datetime' => self::TYPE_DATETIME,
96
        'time' => self::TYPE_TIME,
97
98
        /** Character strings */
99
        'char' => self::TYPE_CHAR,
100
        'varchar' => self::TYPE_STRING,
101
        'text' => self::TYPE_TEXT,
102
103
        /** Unicode character strings */
104
        'nchar' => self::TYPE_CHAR,
105
        'nvarchar' => self::TYPE_STRING,
106
        'ntext' => self::TYPE_TEXT,
107
108
        /** Binary strings */
109
        'binary' => self::TYPE_BINARY,
110
        'varbinary' => self::TYPE_BINARY,
111
        'image' => self::TYPE_BINARY,
112
113
        /**
114
         * Other data types 'cursor' type can't be used with tables
115
         */
116
        'timestamp' => self::TYPE_TIMESTAMP,
117
        'hierarchyid' => self::TYPE_STRING,
118
        'uniqueidentifier' => self::TYPE_STRING,
119
        'sql_variant' => self::TYPE_STRING,
120
        'xml' => self::TYPE_STRING,
121 14
        'table' => self::TYPE_STRING,
122
    ];
123 14
124
    public function createColumn(string $type, array|int|string|null $length = null): ColumnInterface
125
    {
126
        return new Column($type, $length);
127
    }
128
129
    /**
130
     * Resolves the table name and schema name (if any).
131
     *
132
     * @param string $name The table name.
133 580
     *
134
     * @return TableSchemaInterface The resolved table name.
135 580
     */
136
    protected function resolveTableName(string $name): TableSchemaInterface
137 580
    {
138 580
        $resolvedName = new TableSchema();
139 580
140
        $parts = array_reverse(
141 580
            $this->db->getQuoter()->getTableNameParts($name)
142 580
        );
143 580
144 580
        $resolvedName->name($parts[0] ?? '');
145
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
146 580
        $resolvedName->catalogName($parts[2] ?? null);
147 574
        $resolvedName->serverName($parts[3] ?? null);
148
149 7
        if (empty($parts[2]) && $resolvedName->getSchemaName() === $this->defaultSchema) {
150
            $resolvedName->fullName($parts[0]);
151
        } else {
152 580
            $resolvedName->fullName(implode('.', array_reverse($parts)));
153
        }
154
155
        return $resolvedName;
156
    }
157
158
    /**
159
     * Returns all schema names in the database, including the default one but not system schemas.
160
     *
161
     * This method should be overridden by child classes to support this feature because the default implementation
162
     * simply throws an exception.
163
     *
164
     * @throws Exception
165
     * @throws InvalidConfigException
166
     * @throws Throwable
167
     *
168
     * @return array Schema names in the database, except system schemas.
169 1
     *
170
     * @link https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-principals-transact-sql
171 1
     */
172
    protected function findSchemaNames(): array
173
    {
174
        $sql = <<<SQL
175
        SELECT [s].[name]
176
        FROM [sys].[schemas] AS [s]
177 1
        INNER JOIN [sys].[database_principals] AS [p] ON [p].[principal_id] = [s].[principal_id]
178
        WHERE [p].[is_fixed_role] = 0 AND [p].[sid] IS NOT NULL
179 1
        ORDER BY [s].[name] ASC
180
        SQL;
181
182
        return $this->db->createCommand($sql)->queryColumn();
183
    }
184
185
    /**
186
     * @throws Exception
187 506
     * @throws InvalidConfigException
188
     * @throws Throwable
189 506
     */
190 506
    protected function findTableComment(TableSchemaInterface $tableSchema): void
191 506
    {
192
        $schemaName = $tableSchema->getSchemaName()
193 506
            ? "N'" . (string) $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
194 506
        $tableName = 'N' . (string) $this->db->getQuoter()->quoteValue($tableSchema->getName());
195
196
        $sql = <<<SQL
197 506
        SELECT [value]
198 506
        FROM fn_listextendedproperty (
199
            N'MS_description',
200 506
            'SCHEMA', $schemaName,
201
            'TABLE', $tableName,
202 506
            DEFAULT, DEFAULT)
203
        SQL;
204 506
205
        $comment = $this->db->createCommand($sql)->queryScalar();
206
207
        $tableSchema->comment(is_string($comment) ? $comment : null);
208
    }
209
210
    /**
211
     * Returns all table names in the database.
212
     *
213
     * This method should be overridden by child classes to support this feature because the default implementation
214
     * simply throws an exception.
215
     *
216
     * @param string $schema The schema of the tables.
217
     * Defaults to empty string, meaning the current or default schema.
218
     *
219
     * @throws Exception
220
     * @throws InvalidConfigException
221
     * @throws Throwable
222 11
     *
223
     * @return array All tables name in the database. The names have NO schema name prefix.
224 11
     */
225 11
    protected function findTableNames(string $schema = ''): array
226
    {
227
        if ($schema === '') {
228 11
            $schema = $this->defaultSchema;
229
        }
230
231
        $sql = <<<SQL
232
        SELECT [t].[table_name]
233 11
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
234
        WHERE [t].[table_schema] = :schema AND [t].[table_type] IN ('BASE TABLE', 'VIEW')
235 11
        ORDER BY [t].[table_name]
236
        SQL;
237
238
        return $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
239
    }
240
241
    /**
242
     * Loads the metadata for the specified table.
243
     *
244
     * @param string $name The table name.
245
     *
246
     * @throws Exception
247
     * @throws InvalidConfigException
248
     * @throws Throwable
249 506
     *
250
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table doesn't exist.
251 506
     */
252 506
    protected function loadTableSchema(string $name): TableSchemaInterface|null
253 506
    {
254
        $table = $this->resolveTableName($name);
255 506
        $this->findPrimaryKeys($table);
256 486
        $this->findTableComment($table);
257 486
258
        if ($this->findColumns($table)) {
259
            $this->findForeignKeys($table);
260 166
            return $table;
261
        }
262
263
        return null;
264
    }
265
266
    /**
267
     * Loads a primary key for the given table.
268
     *
269
     * @param string $tableName The table name.
270
     *
271
     * @throws Exception
272
     * @throws InvalidConfigException
273
     * @throws Throwable
274 50
     *
275
     * @return Constraint|null The primary key for the given table, `null` if the table has no primary key.
276
     */
277 50
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
278 50
    {
279
        /** @psalm-var mixed $tablePrimaryKey */
280
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
281
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
282
    }
283
284
    /**
285
     * Loads all foreign keys for the given table.
286
     *
287
     * @param string $tableName The table name.
288
     *
289
     * @throws Exception
290
     * @throws InvalidConfigException
291
     * @throws Throwable
292 8
     *
293
     * @return array The foreign keys for the given table.
294
     */
295 8
    protected function loadTableForeignKeys(string $tableName): array
296 8
    {
297
        /** @psalm-var mixed $tableForeignKeys */
298
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
299
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
300
    }
301
302
    /**
303
     * Loads all indexes for the given table.
304
     *
305
     * @param string $tableName The table name.
306
     *
307
     * @throws Exception
308
     * @throws InvalidConfigException
309
     * @throws Throwable
310 39
     *
311
     * @return array Indexes for the given table.
312 39
     */
313
    protected function loadTableIndexes(string $tableName): array
314
    {
315
        $sql = <<<SQL
316
        SELECT
317
            [i].[name] AS [name],
318
            [iccol].[name] AS [column_name],
319
            [i].[is_unique] AS [index_is_unique],
320
            [i].[is_primary_key] AS [index_is_primary]
321
        FROM [sys].[indexes] AS [i]
322
        INNER JOIN [sys].[index_columns] AS [ic]
323
            ON [ic].[object_id] = [i].[object_id] AND [ic].[index_id] = [i].[index_id]
324
        INNER JOIN [sys].[columns] AS [iccol]
325 39
            ON [iccol].[object_id] = [ic].[object_id] AND [iccol].[column_id] = [ic].[column_id]
326
        WHERE [i].[object_id] = OBJECT_ID(:fullName)
327 39
        ORDER BY [ic].[key_ordinal] ASC
328 39
        SQL;
329
330
        $resolvedName = $this->resolveTableName($tableName);
331 39
        $indexes = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
332 39
333
        /** @psalm-var array[] $indexes */
334 39
        $indexes = $this->normalizeRowKeyCase($indexes, true);
335
        $indexes = DbArrayHelper::index($indexes, null, ['name']);
336
337
        $result = [];
338
339
        /**
340
         * @psalm-var array<
341
         *   string,
342
         *   array<
343
         *     array-key,
344
         *     array{name: string, column_name: string, index_is_unique: string, index_is_primary: string}
345 39
         *   >
346 36
         * > $indexes
347 36
         */
348 36
        foreach ($indexes as $name => $index) {
349 36
            $result[] = (new IndexConstraint())
350 36
                ->primary((bool) $index[0]['index_is_primary'])
351
                ->unique((bool) $index[0]['index_is_unique'])
352
                ->columnNames(DbArrayHelper::getColumn($index, 'column_name'))
353 39
                ->name($name);
354
        }
355
356
        return $result;
357
    }
358
359
    /**
360
     * Loads all unique constraints for the given table.
361
     *
362
     * @param string $tableName The table name.
363
     *
364
     * @throws Exception|InvalidConfigException|Throwable
365 17
     *
366
     * @return array The unique constraints for the given table.
367
     */
368 17
    protected function loadTableUniques(string $tableName): array
369 17
    {
370
        /** @psalm-var mixed $tableUniques */
371
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
372
        return is_array($tableUniques) ? $tableUniques : [];
373
    }
374
375
    /**
376
     * Loads all check constraints for the given table.
377
     *
378
     * @param string $tableName The table name.
379
     *
380
     * @throws Exception
381
     * @throws InvalidConfigException
382
     * @throws Throwable
383 17
     *
384
     * @return array The check constraints for the given table.
385
     */
386 17
    protected function loadTableChecks(string $tableName): array
387 17
    {
388
        /** @psalm-var mixed $tableCheck */
389
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
390
        return is_array($tableCheck) ? $tableCheck : [];
391
    }
392
393
    /**
394
     * Loads all default value constraints for the given table.
395
     *
396
     * @param string $tableName The table name.
397
     *
398
     * @throws Exception
399
     * @throws InvalidConfigException
400
     * @throws Throwable
401 16
     *
402
     * @return array The default value constraints for the given table.
403
     */
404 16
    protected function loadTableDefaultValues(string $tableName): array
405 16
    {
406
        /** @psalm-var mixed $tableDefault */
407
        $tableDefault = $this->loadTableConstraints($tableName, self::DEFAULTS);
408
        return is_array($tableDefault) ? $tableDefault : [];
409
    }
410
411
    /**
412
     * Loads the column information into a {@see ColumnSchemaInterface} object.
413
     *
414
     * @psalm-param ColumnArray $info The column information.
415 486
     */
416
    private function loadColumnSchema(array $info): ColumnSchemaInterface
417 486
    {
418
        $dbType = $info['data_type'];
419
        $type = $this->getColumnType($dbType, $info);
420
        $isUnsigned = stripos($dbType, 'unsigned') !== false;
421
        /** @psalm-var ColumnArray $info */
422
        $column = $this->createColumnSchema($type, $info['column_name'], unsigned: $isUnsigned);
0 ignored issues
show
Bug introduced by
The method createColumnSchema() does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe mean createColumn()? ( Ignorable by Annotation )

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

422
        /** @scrutinizer ignore-call */ 
423
        $column = $this->createColumnSchema($type, $info['column_name'], unsigned: $isUnsigned);

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...
423
        $column->size($info['size'] ?? null);
424
        $column->precision($info['precision'] ?? null);
425 486
        $column->scale($info['scale'] ?? null);
426
        $column->allowNull($info['is_nullable'] === 'YES');
427 486
        $column->dbType($dbType);
428
        $column->enumValues([]); // MSSQL has only vague equivalents to enum.
429 486
        $column->primaryKey(false); // The primary key will be determined in the `findColumns()` method.
430 486
        $column->autoIncrement($info['is_identity'] === '1');
431 486
        $column->computed($info['is_computed'] === '1');
432 486
        $column->comment($info['comment'] ?? '');
433 486
        $column->phpType($this->getColumnPhpType($type));
0 ignored issues
show
Bug introduced by
$type of type string is incompatible with the type Yiisoft\Db\Schema\ColumnSchemaInterface expected by parameter $column of Yiisoft\Db\Schema\Abstra...ema::getColumnPhpType(). ( Ignorable by Annotation )

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

433
        $column->phpType($this->getColumnPhpType(/** @scrutinizer ignore-type */ $type));
Loading history...
434 486
        $column->defaultValue($this->normalizeDefaultValue($info['column_default'], $column));
435 486
436 486
        return $column;
437 486
    }
438 486
439
    /**
440 486
     * Get the abstract data type for the database data type.
441 486
     *
442
     * @param string $dbType The database data type
443 486
     * @param array $info Column information.
444 486
     *
445
     * @return string The abstract data type.
446
     */
447 486
    private function getColumnType(string $dbType, array &$info): string
448 319
    {
449 319
        preg_match('/^(\w*)(?:\(([^)]+)\))?/', $dbType, $matches);
450 319
        $dbType = strtolower($matches[1]);
451
452 319
        if (!empty($matches[2])) {
453 92
            $values = explode(',', $matches[2], 2);
454
            $info['size'] = (int) $values[0];
455
            $info['precision'] = (int) $values[0];
456 319
457
            if (isset($values[1])) {
458 319
                $info['scale'] = (int) $values[1];
459
            }
460
461
            if (($dbType === 'tinyint' || $dbType === 'bit') && $info['size'] === 1) {
462
                return self::TYPE_BOOLEAN;
463
            }
464
465
            if ($dbType === 'bit') {
466
                return match (true) {
467
                    $info['size'] === 32 => self::TYPE_INTEGER,
468 486
                    $info['size'] > 32 => self::TYPE_BIGINT,
469 486
                    default => self::TYPE_SMALLINT,
470
                };
471 486
            }
472
        }
473
474
        return $this->typeMap[$dbType] ?? self::TYPE_STRING;
475
    }
476
477
    protected function createPhpTypeColumnSchema(string $phpType, string $name): ColumnSchemaInterface
478
    {
479
        if ($phpType === self::PHP_TYPE_RESOURCE) {
480
            return new BinaryColumnSchema($name);
481
        }
482 486
483
        return parent::createPhpTypeColumnSchema($phpType, $name);
484
    }
485 486
486 367
    /**
487 363
     * Converts column's default value according to {@see ColumnSchema::phpType} after retrieval from the database.
488 486
     *
489
     * @param string|null $defaultValue The default value retrieved from the database.
490 482
     * @param ColumnSchemaInterface $column The column schema object.
491
     *
492
     * @return mixed The normalized default value.
493 363
     */
494
    private function normalizeDefaultValue(string|null $defaultValue, ColumnSchemaInterface $column): mixed
495 363
    {
496 129
        if (
497 363
            $defaultValue === null
498
            || $defaultValue === '(NULL)'
499
            || $column->isPrimaryKey()
500
            || $column->isComputed()
501
        ) {
502
            return null;
503
        }
504
505
        $value = $this->parseDefaultValue($defaultValue);
506
507
        return is_numeric($value)
508
            ? $column->phpTypeCast($value)
509 506
            : $value;
510
    }
511 506
512
    /**
513 506
     * Collects the metadata of table columns.
514 506
     *
515
     * @param TableSchemaInterface $table The table metadata.
516 506
     *
517
     * @throws Throwable
518
     *
519
     * @return bool Whether the table exists in the database.
520
     */
521
    protected function findColumns(TableSchemaInterface $table): bool
522 506
    {
523 506
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
524
525
        $whereParams = [':table_name' => $table->getName()];
526 506
        $whereSql = '[t1].[table_name] = :table_name';
527
528 506
        if ($table->getCatalogName() !== null) {
529 506
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
530
            $whereSql .= ' AND [t1].[table_catalog] = :catalog';
531
            $whereParams[':catalog'] = $table->getCatalogName();
532
        }
533
534
        if ($table->getSchemaName() !== null) {
535
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
536
        }
537
538
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
539
540
        $sql = <<<SQL
541
        SELECT
542
            [t1].[column_name],
543
            [t1].[is_nullable],
544
        CASE WHEN [t1].[data_type] IN ('char','varchar','nchar','nvarchar','binary','varbinary') THEN
545
        CASE WHEN [t1].[character_maximum_length] = NULL OR [t1].[character_maximum_length] = -1 THEN
546
            [t1].[data_type]
547
        ELSE
548
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[character_maximum_length]))) + ')'
549
        END
550
        WHEN [t1].[data_type] IN ('decimal','numeric') THEN
551
        CASE WHEN [t1].[numeric_precision] = NULL OR [t1].[numeric_precision] = -1 THEN
552
            [t1].[data_type]
553
        ELSE
554
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[numeric_precision]))) + ',' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[numeric_scale]))) + ')'
555
        END
556
        ELSE
557
            [t1].[data_type]
558
        END AS 'data_type',
559
        [t1].[column_default],
560 506
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsIdentity') AS is_identity,
561 506
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsComputed') AS is_computed,
562 506
        (
563
        SELECT CONVERT(VARCHAR, [t2].[value])
564
        FROM [sys].[extended_properties] AS [t2]
565
        WHERE
566 506
        [t2].[class] = 1 AND
567
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
568 506
        [t2].[name] = 'MS_Description' AND
569 506
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
570
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
571
        ) as comment
572
        FROM $columnsTableName AS [t1]
573
        WHERE $whereSql
574
        SQL;
575 486
576 486
        try {
577 486
            /** @psalm-var ColumnArray[] $columns */
578 298
            $columns = $this->db->createCommand($sql, $whereParams)->queryAll();
579 298
580 298
            if (empty($columns)) {
581
                return false;
582
            }
583
        } catch (Exception) {
584 486
            return false;
585 286
        }
586
587
        foreach ($columns as $column) {
588 486
            $column = $this->loadColumnSchema($column);
589
            foreach ($table->getPrimaryKey() as $primaryKey) {
590
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
591 486
                    $column->primaryKey(true);
592
                    break;
593
                }
594
            }
595
596
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
597
                $table->sequenceName('');
598
            }
599
600
            $table->column($column->getName(), $column);
601
        }
602
603
        return true;
604
    }
605 506
606
    /**
607 506
     * Collects the constraint details for the given table and constraint type.
608 506
     *
609
     * @param string $type Either PRIMARY KEY or UNIQUE.
610 506
     *
611 506
     * @throws Exception
612
     * @throws InvalidConfigException
613
     * @throws Throwable
614
     *
615
     * @return array Each entry has index_name and field_name.
616 506
     */
617 506
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
618
    {
619 506
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
620 506
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
621
622
        $catalogName = $table->getCatalogName();
623 506
        if ($catalogName !== null) {
624 506
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
625
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
626
        }
627
628
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
629
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
630
631
        $sql = <<<SQL
632 506
        SELECT
633
            [kcu].[constraint_name] AS [index_name],
634 506
            [kcu].[column_name] AS [field_name]
635 506
        FROM $keyColumnUsageTableName AS [kcu]
636 506
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
637 506
            [kcu].[table_schema] = [tc].[table_schema] AND
638 506
            [kcu].[table_name] = [tc].[table_name] AND
639 506
            [kcu].[constraint_name] = [tc].[constraint_name]
640 506
        WHERE
641 506
            [tc].[constraint_type] = :type AND
642
            [kcu].[table_name] = :tableName AND
643
            [kcu].[table_schema] = :schemaName
644
        SQL;
645
646
        return $this->db->createCommand(
647
            $sql,
648
            [
649
                ':tableName' => $table->getName(),
650
                ':schemaName' => $table->getSchemaName(),
651
                ':type' => $type,
652
            ]
653 506
        )->queryAll();
654
    }
655
656 506
    /**
657
     * Collects the primary key column details for the given table.
658 506
     *
659 298
     * @param TableSchemaInterface $table The table metadata
660
     *
661
     * @throws Exception
662
     * @throws InvalidConfigException
663
     * @throws Throwable
664
     */
665
    protected function findPrimaryKeys(TableSchemaInterface $table): void
666
    {
667
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
668
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
669
670
        foreach ($primaryKeys as $row) {
671
            $table->primaryKey($row['field_name']);
672 486
        }
673
    }
674 486
675 486
    /**
676 486
     * Collects the foreign key column details for the given table.
677 486
     *
678
     * @param TableSchemaInterface $table The table metadata
679 486
     *
680 486
     * @throws Exception
681
     * @throws InvalidConfigException
682
     * @throws Throwable
683 486
     */
684
    protected function findForeignKeys(TableSchemaInterface $table): void
685
    {
686
        $catalogName = $table->getCatalogName();
687 486
        $fk = [];
688
        $object = $table->getName();
689
        $schemaName = $table->getSchemaName();
690
691
        if ($schemaName !== null) {
692
            $object = $schemaName . '.' . $object;
693
        }
694
695
        if ($catalogName !== null) {
696
            $object = $catalogName . '.' . $object;
697
        }
698
699
        $sql = <<<SQL
700
        SELECT
701 486
        [fk].[name] AS [fk_name],
702
        [cp].[name] AS [fk_column_name],
703
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
704
        [cr].[name] AS [uq_column_name]
705
        FROM [sys].[foreign_keys] AS [fk]
706
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
707
            ON [fk].[object_id] = [fkc].[constraint_object_id]
708
        INNER JOIN [sys].[columns] AS [cp]
709 486
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
710 486
        INNER JOIN [sys].[columns] AS [cr]
711
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
712 486
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
713 10
        SQL;
714 10
715 10
        /**
716
         * @psalm-var array<
717
         *   array-key,
718 10
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
719 10
         * > $rows
720
         */
721
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
722
        $table->foreignKeys([]);
723
724
        foreach ($rows as $row) {
725
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
726
                $fk[$row['fk_name']][] = $row['uq_table_name'];
727
                $table->foreignKeys($fk);
728 4
            }
729
730 4
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
731 1
            $table->foreignKeys($fk);
732
        }
733
    }
734 4
735
    /**
736
     * @throws Exception
737
     * @throws InvalidConfigException
738
     * @throws Throwable
739 4
     */
740
    protected function findViewNames(string $schema = ''): array
741 4
    {
742
        if ($schema === '') {
743
            $schema = $this->defaultSchema;
744
        }
745
746
        $sql = <<<SQL
747
        SELECT [t].[table_name]
748
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
749
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
750
        ORDER BY [t].[table_name]
751
        SQL;
752
753
        return $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
754
    }
755
756
    /**
757
     * Returns all unique indexes for the given table.
758
     *
759
     * Each array element is of the following structure:
760
     *
761
     * ```php
762
     * [
763
     *     'IndexName1' => ['col1' [, ...]],
764 1
     *     'IndexName2' => ['col2' [, ...]],
765
     * ]
766 1
     * ```
767
     *
768
     * @param TableSchemaInterface $table The table metadata.
769 1
     *
770
     * @throws Exception
771 1
     * @throws InvalidConfigException
772 1
     * @throws Throwable
773
     *
774
     * @return array All unique indexes for the given table.
775 1
     */
776
    public function findUniqueIndexes(TableSchemaInterface $table): array
777
    {
778
        $result = [];
779
780
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
781
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
782
783
        foreach ($tableUniqueConstraints as $row) {
784
            $result[$row['index_name']][] = $row['field_name'];
785
        }
786
787
        return $result;
788
    }
789
790
    /**
791
     * Loads multiple types of constraints and returns the specified ones.
792
     *
793
     * @param string $tableName table name.
794
     * @param string $returnType return type:
795 108
     * - primaryKey
796
     * - foreignKeys
797 108
     * - uniques
798
     * - checks
799
     * - defaults
800
     *
801
     * @throws Exception
802
     * @throws InvalidConfigException
803
     * @throws Throwable
804
     *
805
     * @return mixed Constraints of the specified type.
806
     */
807
    private function loadTableConstraints(string $tableName, string $returnType): mixed
808
    {
809
        $sql = <<<SQL
810
        SELECT
811
            [o].[name] AS [name],
812
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
813
            RTRIM([o].[type]) AS [type],
814
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
815
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
816
            [ffccol].[name] AS [foreign_column_name],
817
            [f].[update_referential_action_desc] AS [on_update],
818
            [f].[delete_referential_action_desc] AS [on_delete],
819
            [c].[definition] AS [check_expr],
820
            [d].[definition] AS [default_expr]
821
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
822
        INNER JOIN [sys].[objects] AS [o]
823
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
824
        LEFT JOIN [sys].[check_constraints] AS [c]
825
            ON [c].[object_id] = [o].[object_id]
826
        LEFT JOIN [sys].[columns] AS [ccol]
827
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
828
        LEFT JOIN [sys].[default_constraints] AS [d]
829
            ON [d].[object_id] = [o].[object_id]
830
        LEFT JOIN [sys].[columns] AS [dcol]
831
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
832
        LEFT JOIN [sys].[key_constraints] AS [k]
833
            ON [k].[object_id] = [o].[object_id]
834
        LEFT JOIN [sys].[index_columns] AS [kic]
835 108
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
836
        LEFT JOIN [sys].[columns] AS [kiccol]
837 108
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
838 108
        LEFT JOIN [sys].[foreign_keys] AS [f]
839
            ON [f].[object_id] = [o].[object_id]
840
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
841 108
            ON [fc].[constraint_object_id] = [o].[object_id]
842 108
        LEFT JOIN [sys].[columns] AS [fccol]
843
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
844 108
        LEFT JOIN [sys].[columns] AS [ffccol]
845 108
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
846 108
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
847 108
        SQL;
848 108
849 108
        $resolvedName = $this->resolveTableName($tableName);
850 108
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
851
852
        /** @psalm-var array[] $constraints */
853 108
        $constraints = $this->normalizeRowKeyCase($constraints, true);
854
        $constraints = DbArrayHelper::index($constraints, null, ['type', 'name']);
855
856
        $result = [
857
            self::PRIMARY_KEY => null,
858 99
            self::FOREIGN_KEYS => [],
859
            self::UNIQUES => [],
860 99
            self::CHECKS => [],
861
            self::DEFAULTS => [],
862 65
        ];
863 65
864 65
        /** @psalm-var array<array-key, array> $constraints */
865 65
        foreach ($constraints as $type => $names) {
866 93
            /**
867 23
             * @psalm-var object|string|null $name
868 23
             * @psalm-var ConstraintArray $constraint
869 23
             */
870 23
            foreach ($names as $name => $constraint) {
871 23
                switch ($type) {
872 23
                    case 'PK':
873 23
                        /** @psalm-var Constraint */
874 23
                        $result[self::PRIMARY_KEY] = (new Constraint())
875 23
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
876 77
                            ->name($name);
877 71
                        break;
878 71
                    case 'F':
879 71
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
880 71
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
881 41
                            ->foreignTableName($constraint[0]['foreign_table_name'])
882 19
                            ->foreignColumnNames(DbArrayHelper::getColumn($constraint, 'foreign_column_name'))
883 19
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
884 19
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
885 19
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
886 19
                            ->name($name);
887 38
                        break;
888 38
                    case 'UQ':
889 38
                        $result[self::UNIQUES][] = (new Constraint())
890 38
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
891 38
                            ->name($name);
892 38
                        break;
893
                    case 'C':
894
                        $result[self::CHECKS][] = (new CheckConstraint())
895
                            ->expression($constraint[0]['check_expr'])
896
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
897 108
                            ->name($name);
898 108
                        break;
899
                    case 'D':
900
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
901 108
                            ->value($constraint[0]['default_expr'])
902
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
903
                            ->name($name);
904
                        break;
905
                }
906
            }
907
        }
908
909
        foreach ($result as $type => $data) {
910
            $this->setTableMetadata($tableName, $type, $data);
911 609
        }
912
913 609
        return $result[$returnType];
914
    }
915
916
    /**
917
     * Returns the cache key for the specified table name.
918
     *
919
     * @param string $name The table name.
920
     *
921
     * @return array The cache key.
922
     */
923 581
    protected function getCacheKey(string $name): array
924
    {
925 581
        return array_merge([self::class], $this->generateCacheKey(), [$this->getRawTableName($name)]);
926
    }
927
928 363
    /**
929
     * Returns the cache tag name.
930 363
     *
931 84
     * This allows {@see refresh()} to invalidate all cached table schemas.
932
     *
933
     * @return string The cache tag name.
934 363
     */
935 363
    protected function getCacheTag(): string
936
    {
937
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
938 290
    }
939
940
    private function parseDefaultValue(string $value): string
941
    {
942
        if (preg_match('/^\'(.*)\'$/', $value, $matches)) {
943
            return $matches[1];
944
        }
945
946
        if (preg_match('/^\((.*)\)$/', $value, $matches)) {
947
            return $this->parseDefaultValue($matches[1]);
948
        }
949
950
        return $value;
951
    }
952
}
953