Test Failed
Pull Request — master (#277)
by Sergei
03:24
created

Schema::createColumnSchema()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
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