Test Failed
Pull Request — master (#277)
by Sergei
18:58 queued 15:25
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.1953

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