Test Failed
Pull Request — master (#111)
by Def
21:53 queued 19:07
created

Schema::getTableNameParts()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 5
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 12
ccs 6
cts 6
cp 1
crap 3
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Mssql;
6
7
use Throwable;
8
use Yiisoft\Arrays\ArrayHelper;
9
use Yiisoft\Db\Constraint\CheckConstraint;
10
use Yiisoft\Db\Constraint\Constraint;
11
use Yiisoft\Db\Constraint\DefaultValueConstraint;
12
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
13
use Yiisoft\Db\Constraint\IndexConstraint;
14
use Yiisoft\Db\Exception\Exception;
15
use Yiisoft\Db\Exception\InvalidConfigException;
16
use Yiisoft\Db\Schema\ColumnSchemaBuilder;
17
use Yiisoft\Db\Schema\ColumnSchemaInterface;
18
use Yiisoft\Db\Schema\Schema as AbstractSchema;
19
use Yiisoft\Db\Schema\TableSchemaInterface;
20
21
use function array_change_key_case;
22
use function array_map;
23
use function explode;
24
use function is_array;
25
use function md5;
26
use function preg_match;
27
use function serialize;
28
use function str_replace;
29
use function strcasecmp;
30
use function stripos;
31
32
/**
33
 * Schema is the class for retrieving metadata from MS SQL Server databases (version 2008 and above).
34
 *
35
 * @psalm-type ColumnArray = array{
36
 *   column_name: string,
37
 *   is_nullable: string,
38
 *   data_type: string,
39
 *   column_default: mixed,
40
 *   is_identity: string,
41
 *   comment: null|string
42
 * }
43
 *
44
 * @psalm-type ConstraintArray = array<
45
 *   array-key,
46
 *   array {
47
 *     name: string,
48
 *     column_name: string,
49
 *     type: string,
50
 *     foreign_table_schema: string|null,
51
 *     foreign_table_name: string|null,
52
 *     foreign_column_name: string|null,
53
 *     on_update: string,
54
 *     on_delete: string,
55
 *     check_expr: string,
56
 *     default_expr: string
57
 *   }
58
 * >
59
 */
60
final class Schema extends AbstractSchema
61
{
62
    public const DEFAULTS = 'defaults';
63
64
    /**
65
     * @var string|null the default schema used for the current session.
66
     */
67
    protected ?string $defaultSchema = 'dbo';
68
69
    /**
70
     * @var array mapping from physical column types (keys) to abstract column types (values)
71
     *
72
     * @psalm-var string[]
73
     */
74
    private array $typeMap = [
75
        /** exact numbers */
76
        'bigint' => self::TYPE_BIGINT,
77
        'numeric' => self::TYPE_DECIMAL,
78
        'bit' => self::TYPE_SMALLINT,
79
        'smallint' => self::TYPE_SMALLINT,
80
        'decimal' => self::TYPE_DECIMAL,
81
        'smallmoney' => self::TYPE_MONEY,
82
        'int' => self::TYPE_INTEGER,
83
        'tinyint' => self::TYPE_TINYINT,
84
        'money' => self::TYPE_MONEY,
85
86
        /** approximate numbers */
87
        'float' => self::TYPE_FLOAT,
88
        'double' => self::TYPE_DOUBLE,
89
        'real' => self::TYPE_FLOAT,
90
91
        /** date and time */
92
        'date' => self::TYPE_DATE,
93
        'datetimeoffset' => self::TYPE_DATETIME,
94
        'datetime2' => self::TYPE_DATETIME,
95
        'smalldatetime' => self::TYPE_DATETIME,
96
        'datetime' => self::TYPE_DATETIME,
97
        'time' => self::TYPE_TIME,
98
99
        /** character strings */
100
        'char' => self::TYPE_CHAR,
101
        'varchar' => self::TYPE_STRING,
102
        'text' => self::TYPE_TEXT,
103
104
        /** unicode character strings */
105
        'nchar' => self::TYPE_CHAR,
106
        'nvarchar' => self::TYPE_STRING,
107
        'ntext' => self::TYPE_TEXT,
108
109
        /** binary strings */
110
        'binary' => self::TYPE_BINARY,
111
        'varbinary' => self::TYPE_BINARY,
112
        'image' => self::TYPE_BINARY,
113
114
        /**
115
         * other data types 'cursor' type cannot be used with tables
116
         */
117
        'timestamp' => self::TYPE_TIMESTAMP,
118
        'hierarchyid' => self::TYPE_STRING,
119
        'uniqueidentifier' => self::TYPE_STRING,
120
        'sql_variant' => self::TYPE_STRING,
121
        'xml' => self::TYPE_STRING,
122
        'table' => self::TYPE_STRING,
123
    ];
124
125
    /**
126
     * Resolves the table name and schema name (if any).
127
     *
128
     * @param string $name the table name.
129
     *
130
     * @return TableSchemaInterface resolved table, schema, etc. names.
131 390
     *
132
     * also see case `wrongBehaviour` in \Yiisoft\Db\TestSupport\TestCommandTrait::batchInsertSqlProviderTrait
133 390
     */
134
    protected function resolveTableName(string $name): TableSchemaInterface
135
    {
136
        $resolvedName = new TableSchema();
137
138
        $parts = array_reverse(
139
            $this->db->getQuoter()->getTableNameParts($name)
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
140
        );
141
142
        $resolvedName->name($parts[0] ?? '');
143
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
144
        $resolvedName->catalogName($parts[2] ?? null);
145
        $resolvedName->serverName($parts[3] ?? null);
0 ignored issues
show
Bug introduced by
The method serverName() does not exist on Yiisoft\Db\Mssql\TableSchema. ( Ignorable by Annotation )

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

145
        $resolvedName->/** @scrutinizer ignore-call */ 
146
                       serverName($parts[3] ?? null);

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...
146 180
147
        if (empty($parts[2]) && $resolvedName->getSchemaName() === $this->defaultSchema) {
148 180
            $resolvedName->fullName($parts[0]);
149 180
        } else {
150 180
            $resolvedName->fullName(implode('.', array_reverse($parts)));
151
        }
152 180
153
        return $resolvedName;
154
    }
155
156
    /**
157
     * Returns all schema names in the database, including the default one but not system schemas.
158
     *
159
     * This method should be overridden by child classes in order to support this feature because the default
160
     * implementation simply throws an exception.
161
     *
162 180
     * @throws Exception|InvalidConfigException|Throwable
163
     *
164
     * @return array All schema names in the database, except system schemas.
165
     *
166
     * @link https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-principals-transact-sql
167
     */
168
    protected function findSchemaNames(): array
169
    {
170
        $sql = <<<SQL
171
        SELECT [s].[name]
172 180
        FROM [sys].[schemas] AS [s]
173
        INNER JOIN [sys].[database_principals] AS [p] ON [p].[principal_id] = [s].[principal_id]
174 3
        WHERE [p].[is_fixed_role] = 0 AND [p].[sid] IS NOT NULL
175 3
        ORDER BY [s].[name] ASC
176 3
        SQL;
177
178 3
        return $this->db->createCommand($sql)->queryColumn();
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
179 3
    }
180 3
181
    /**
182
     * Returns all table names in the database.
183
     *
184 177
     * This method should be overridden by child classes in order to support this feature because the default
185 177
     * implementation simply throws an exception.
186 177
     *
187
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
188
     *
189 180
     * @throws Exception|InvalidConfigException|Throwable
190
     *
191
     * @return array All table names in the database. The names have NO schema name prefix.
192
     */
193
    protected function findTableNames(string $schema = ''): array
194
    {
195
        if ($schema === '') {
196
            $schema = $this->defaultSchema;
197
        }
198
199
        $sql = <<<SQL
200
        SELECT [t].[table_name]
201 180
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
202
        WHERE [t].[table_schema] = :schema AND [t].[table_type] IN ('BASE TABLE', 'VIEW')
203 180
        ORDER BY [t].[table_name]
204
        SQL;
205 180
206
        $tables = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
207 180
208 180
        return array_map(static fn (string $item): string => '[' . $item . ']', $tables);
209
    }
210
211
    /**
212 180
     * Loads the metadata for the specified table.
213
     *
214
     * @param string $name table name.
215
     *
216
     * @throws Exception|InvalidConfigException|Throwable
217
     *
218
     * @return TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table does not exist.
219
     */
220
    protected function loadTableSchema(string $name): ?TableSchemaInterface
221
    {
222
        $table = $this->resolveTableName($name);
223
        $this->findPrimaryKeys($table);
224
225
        if ($this->findColumns($table)) {
226
            $this->findForeignKeys($table);
227 1
            return $table;
228
        }
229 1
230
        return null;
231
    }
232
233
    /**
234
     * Loads a primary key for the given table.
235
     *
236
     * @param string $tableName table name.
237 1
     *
238
     * @throws Exception|InvalidConfigException|Throwable
239
     *
240
     * @return Constraint|null The primary key for the given table, `null` if the table has no primary key.
241
     */
242
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
243
    {
244
        /** @var mixed */
245
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
246
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
247
    }
248
249
    /**
250
     * Loads all foreign keys for the given table.
251
     *
252 11
     * @param string $tableName table name.
253
     *
254 11
     * @throws Exception|InvalidConfigException|Throwable
255 11
     *
256
     * @return array The foreign keys for the given table.
257
     */
258 11
    protected function loadTableForeignKeys(string $tableName): array
259
    {
260
        /** @var mixed */
261
        $tableForeingKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
262
        return is_array($tableForeingKeys) ? $tableForeingKeys : [];
263
    }
264
265 11
    /**
266
     * Loads all indexes for the given table.
267 11
     *
268
     * @param string $tableName table name.
269
     *
270
     * @throws Exception|InvalidConfigException|Throwable
271
     *
272
     * @return array indexes for the given table.
273
     */
274
    protected function loadTableIndexes(string $tableName): array
275
    {
276
        $sql = <<<SQL
277
        SELECT
278
            [i].[name] AS [name],
279 114
            [iccol].[name] AS [column_name],
280
            [i].[is_unique] AS [index_is_unique],
281 114
            [i].[is_primary_key] AS [index_is_primary]
282 114
        FROM [sys].[indexes] AS [i]
283
        INNER JOIN [sys].[index_columns] AS [ic]
284 114
            ON [ic].[object_id] = [i].[object_id] AND [ic].[index_id] = [i].[index_id]
285 105
        INNER JOIN [sys].[columns] AS [iccol]
286 105
            ON [iccol].[object_id] = [ic].[object_id] AND [iccol].[column_id] = [ic].[column_id]
287
        WHERE [i].[object_id] = OBJECT_ID(:fullName)
288
        ORDER BY [ic].[key_ordinal] ASC
289 19
        SQL;
290
291
        $resolvedName = $this->resolveTableName($tableName);
292
        $indexes = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
293
294
        /** @psalm-var array[] $indexes */
295
        $indexes = $this->normalizeRowKeyCase($indexes, true);
296
        $indexes = ArrayHelper::index($indexes, null, 'name');
297
298
        $result = [];
299
300
        /**
301 32
         * @psalm-var array<
302
         *   string,
303
         *   array<
304 32
         *     array-key,
305 32
         *     array{array-key, name: string, column_name: string, index_is_unique: string, index_is_primary: string}
306
         *   >
307
         * > $indexes
308
         */
309
        foreach ($indexes as $name => $index) {
310
            $result[] = (new IndexConstraint())
311
                ->primary((bool) $index[0]['index_is_primary'])
312
                ->unique((bool) $index[0]['index_is_unique'])
313
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
314
                ->name($name);
315
        }
316
317 5
        return $result;
318
    }
319
320 5
    /**
321 5
     * Loads all unique constraints for the given table.
322
     *
323
     * @param string $tableName table name.
324
     *
325
     * @throws Exception|InvalidConfigException|Throwable
326
     *
327
     * @return array The unique constraints for the given table.
328
     */
329
    protected function loadTableUniques(string $tableName): array
330
    {
331
        /** @var mixed */
332
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
333 30
        return is_array($tableUniques) ? $tableUniques : [];
334
    }
335 30
336
    /**
337
     * Loads all check constraints for the given table.
338
     *
339
     * @param string $tableName table name.
340
     *
341
     * @throws Exception|InvalidConfigException|Throwable
342
     *
343
     * @return array The check constraints for the given table.
344
     */
345
    protected function loadTableChecks(string $tableName): array
346
    {
347
        /** @var mixed */
348
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
349
        return is_array($tableCheck) ? $tableCheck : [];
350 30
    }
351 30
352
    /**
353
     * Loads all default value constraints for the given table.
354 30
     *
355 30
     * @param string $tableName table name.
356
     *
357 30
     * @throws Exception|InvalidConfigException|Throwable
358
     *
359
     * @return array The default value constraints for the given table.
360
     */
361
    protected function loadTableDefaultValues(string $tableName): array
362
    {
363
        /** @var mixed */
364
        $tableDefault = $this->loadTableConstraints($tableName, self::DEFAULTS);
365
        return is_array($tableDefault) ? $tableDefault : [];
366
    }
367
368 30
    /**
369 27
     * Creates a column schema for the database.
370 27
     *
371 27
     * This method may be overridden by child classes to create a DBMS-specific column schema.
372 27
     *
373 27
     * @return ColumnSchema column schema instance.
374
     */
375
    protected function createColumnSchema(): ColumnSchema
376 30
    {
377
        return new ColumnSchema();
378
    }
379
380
    /**
381
     * Loads the column information into a {@see ColumnSchemaInterface} object.
382
     *
383
     * @psalm-param ColumnArray $info The column information.
384
     *
385
     * @return ColumnSchemaInterface the column schema object.
386
     */
387
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
388 14
    {
389
        $column = $this->createColumnSchema();
390
391 14
        $column->name($info['column_name']);
392 14
        $column->allowNull($info['is_nullable'] === 'YES');
393
        $column->dbType($info['data_type']);
394
        $column->enumValues([]); // mssql has only vague equivalents to enum
395
        $column->primaryKey(false); // primary key will be determined in findColumns() method
396
        $column->autoIncrement($info['is_identity'] === '1');
397
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
398
        $column->comment($info['comment'] ?? '');
399
        $column->type(self::TYPE_STRING);
400
401
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
402
            $type = $matches[1];
403
404 14
            if (isset($this->typeMap[$type])) {
405
                $column->type($this->typeMap[$type]);
406
            }
407 14
408 14
            if (!empty($matches[2])) {
409
                $values = explode(',', $matches[2]);
410
                $column->precision((int) $values[0]);
411
                $column->size((int) $values[0]);
412
413
                if (isset($values[1])) {
414
                    $column->scale((int) $values[1]);
415
                }
416
417
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
418
                    $column->type(self::TYPE_BOOLEAN);
419
                } elseif ($type === 'bit') {
420 14
                    if ($column->getSize() > 32) {
421
                        $column->type(self::TYPE_BIGINT);
422
                    } elseif ($column->getSize() === 32) {
423 14
                        $column->type(self::TYPE_INTEGER);
424 14
                    }
425
                }
426
            }
427
        }
428
429
        $column->phpType($this->getColumnPhpType($column));
430
431
        if ($info['column_default'] === '(NULL)') {
432
            $info['column_default'] = null;
433
        }
434 105
435
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
436 105
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
437
        }
438
439
        return $column;
440
    }
441
442
    /**
443
     * Collects the metadata of table columns.
444
     *
445
     * @param TableSchemaInterface $table the table metadata.
446 105
     *
447
     * @throws Throwable
448 105
     *
449
     * @return bool whether the table exists in the database.
450 105
     */
451 105
    protected function findColumns(TableSchemaInterface $table): bool
452 105
    {
453 105
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
454 105
        $whereSql = '[t1].[table_name] = ' . (string) $this->db->getQuoter()->quoteValue($table->getName());
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
455 105
456 105
        if ($table->getCatalogName() !== null) {
457 105
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
458 105
            $whereSql .= " AND [t1].[table_catalog] = '{$table->getCatalogName()}'";
459
        }
460 105
461 105
        if ($table->getSchemaName() !== null) {
462
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
463 105
        }
464 105
465
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
466
467 105
        $sql = <<<SQL
468 88
        SELECT
469 88
            [t1].[column_name],
470 88
            [t1].[is_nullable],
471
        CASE WHEN [t1].[data_type] IN ('char','varchar','nchar','nvarchar','binary','varbinary') THEN
472 88
        CASE WHEN [t1].[character_maximum_length] = NULL OR [t1].[character_maximum_length] = -1 THEN
473
            [t1].[data_type]
474
        ELSE
475
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[character_maximum_length]))) + ')'
476 88
        END
477
        ELSE
478 88
            [t1].[data_type]
479
        END AS 'data_type',
480
        [t1].[column_default],
481
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsIdentity') AS is_identity,
482
        (
483
        SELECT CONVERT(VARCHAR, [t2].[value])
484
        FROM [sys].[extended_properties] AS [t2]
485
        WHERE
486
        [t2].[class] = 1 AND
487
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
488 105
        [t2].[name] = 'MS_Description' AND
489
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
490 105
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
491 4
        ) as comment
492
        FROM $columnsTableName AS [t1]
493
        WHERE $whereSql
494 105
        SQL;
495 105
496
        try {
497
            /** @psalm-var ColumnArray[] */
498 105
            $columns = $this->db->createCommand($sql)->queryAll();
499
500
            if (empty($columns)) {
501
                return false;
502
            }
503
        } catch (Exception) {
504
            return false;
505
        }
506
507
        foreach ($columns as $column) {
508
            $column = $this->loadColumnSchema($column);
509
            foreach ($table->getPrimaryKey() as $primaryKey) {
510 114
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
511
                    $column->primaryKey(true);
512 114
                    break;
513 114
                }
514
            }
515 114
516
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
517
                $table->sequenceName('');
518
            }
519
520 114
            $table->columns($column->getName(), $column);
521 114
        }
522
523
        return true;
524 114
    }
525
526 114
    /**
527
     * Collects the constraint details for the given table and constraint type.
528
     *
529
     * @param TableSchemaInterface $table
530
     * @param string $type either PRIMARY KEY or UNIQUE.
531
     *
532
     * @throws Exception|InvalidConfigException|Throwable
533
     *
534
     * @return array each entry contains index_name and field_name.
535
     */
536
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
537
    {
538
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
539
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
540
541
        $catalogName = $table->getCatalogName();
542
        if ($catalogName !== null) {
543
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
544
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
545
        }
546
547
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
548
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
549
550
        $sql = <<<SQL
551
        SELECT
552
            [kcu].[constraint_name] AS [index_name],
553
            [kcu].[column_name] AS [field_name]
554
        FROM $keyColumnUsageTableName AS [kcu]
555
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
556
            [kcu].[table_schema] = [tc].[table_schema] AND
557 114
            [kcu].[table_name] = [tc].[table_name] AND
558
            [kcu].[constraint_name] = [tc].[constraint_name]
559 114
        WHERE
560 114
            [tc].[constraint_type] = :type AND
561
            [kcu].[table_name] = :tableName AND
562
            [kcu].[table_schema] = :schemaName
563
        SQL;
564
565
        return $this->db->createCommand(
566 105
            $sql,
567 105
            [
568 105
                ':tableName' => $table->getName(),
569 69
                ':schemaName' => $table->getSchemaName(),
570 69
                ':type' => $type,
571 69
            ]
572
        )->queryAll();
573
    }
574
575 105
    /**
576 68
     * Collects the primary key column details for the given table.
577
     *
578
     * @param TableSchemaInterface $table the table metadata
579 105
     *
580
     * @throws Exception|InvalidConfigException|Throwable
581
     */
582 105
    protected function findPrimaryKeys(TableSchemaInterface $table): void
583
    {
584
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
585
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
586
587
        foreach ($primaryKeys as $row) {
588
            $table->primaryKey($row['field_name']);
589
        }
590
    }
591
592
    /**
593
     * Collects the foreign key column details for the given table.
594
     *
595 114
     * @param TableSchemaInterface $table the table metadata
596
     *
597 114
     * @throws Exception|InvalidConfigException|Throwable
598 114
     */
599
    protected function findForeignKeys(TableSchemaInterface $table): void
600 114
    {
601 114
        $catalogName = $table->getCatalogName();
602
        $fk = [];
603
        $object = $table->getName();
604
        $schemaName = $table->getSchemaName();
605
606 114
        if ($schemaName !== null) {
607 114
            $object = $schemaName . '.' . $object;
608
        }
609 114
610
        if ($catalogName !== null) {
611
            $object = $catalogName . '.' . $object;
612
        }
613
614
        /**
615
         * Please refer to the following page for more details:
616
         * {@see http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx}
617
         */
618
        $sql = <<<SQL
619
        SELECT
620
        [fk].[name] AS [fk_name],
621
        [cp].[name] AS [fk_column_name],
622
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
623
        [cr].[name] AS [uq_column_name]
624 114
        FROM [sys].[foreign_keys] AS [fk]
625
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
626
            ON [fk].[object_id] = [fkc].[constraint_object_id]
627 114
        INNER JOIN [sys].[columns] AS [cp]
628 114
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
629
        INNER JOIN [sys].[columns] AS [cr]
630
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
631 114
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
632
        SQL;
633
634
        /**
635
         * @psalm-var array<
636
         *   array-key,
637
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
638
         * > $rows
639
         */
640
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
641 114
        $table->foreignKeys([]);
642
643
        foreach ($rows as $row) {
644 114
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
645
                $fk[$row['fk_name']][] = $row['uq_table_name'];
646 114
                $table->foreignKeys($fk);
647 69
            }
648
649
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
650
            $table->foreignKeys($fk);
651
        }
652
    }
653
654
    /**
655
     * @throws Exception|InvalidConfigException|Throwable
656
     */
657
    protected function findViewNames(string $schema = ''): array
658 105
    {
659
        if ($schema === '') {
660 105
            $schema = $this->defaultSchema;
661 105
        }
662 105
663 105
        $sql = <<<SQL
664
        SELECT [t].[table_name]
665 105
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
666 105
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
667
        ORDER BY [t].[table_name]
668
        SQL;
669 105
670
        $views = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
671
672
        return array_map(static fn (string $item): string => '[' . $item . ']', $views);
673
    }
674
675
    /**
676
     * Returns all unique indexes for the given table.
677 105
     *
678
     * Each array element is of the following structure:
679
     *
680
     * ```php
681
     * [
682
     *     'IndexName1' => ['col1' [, ...]],
683
     *     'IndexName2' => ['col2' [, ...]],
684
     * ]
685
     * ```
686
     *
687
     * @param TableSchemaInterface $table the table metadata.
688
     *
689
     * @throws Exception|InvalidConfigException|Throwable
690
     *
691
     * @return array all unique indexes for the given table.
692
     */
693
    public function findUniqueIndexes(TableSchemaInterface $table): array
694
    {
695
        $result = [];
696
697
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
698
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
699 105
700 105
        foreach ($tableUniqueConstraints as $row) {
701
            $result[$row['index_name']][] = $row['field_name'];
702 105
        }
703 6
704 6
        return $result;
705 6
    }
706
707
    /**
708 6
     * Loads multiple types of constraints and returns the specified ones.
709 6
     *
710
     * @param string $tableName table name.
711
     * @param string $returnType return type:
712
     * - primaryKey
713
     * - foreignKeys
714
     * - uniques
715
     * - checks
716 3
     * - defaults
717
     *
718 3
     * @throws Exception|InvalidConfigException|Throwable
719 1
     *
720
     * @return mixed constraints.
721
     */
722 3
    private function loadTableConstraints(string $tableName, string $returnType): mixed
723
    {
724
        $sql = <<<SQL
725
        SELECT
726
            [o].[name] AS [name],
727
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
728
            RTRIM([o].[type]) AS [type],
729 3
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
730
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
731 3
            [ffccol].[name] AS [foreign_column_name],
732
            [f].[update_referential_action_desc] AS [on_update],
733
            [f].[delete_referential_action_desc] AS [on_delete],
734
            [c].[definition] AS [check_expr],
735
            [d].[definition] AS [default_expr]
736
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
737
        INNER JOIN [sys].[objects] AS [o]
738
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
739
        LEFT JOIN [sys].[check_constraints] AS [c]
740
            ON [c].[object_id] = [o].[object_id]
741
        LEFT JOIN [sys].[columns] AS [ccol]
742
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
743
        LEFT JOIN [sys].[default_constraints] AS [d]
744
            ON [d].[object_id] = [o].[object_id]
745
        LEFT JOIN [sys].[columns] AS [dcol]
746
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
747
        LEFT JOIN [sys].[key_constraints] AS [k]
748
            ON [k].[object_id] = [o].[object_id]
749
        LEFT JOIN [sys].[index_columns] AS [kic]
750
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
751
        LEFT JOIN [sys].[columns] AS [kiccol]
752 1
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
753
        LEFT JOIN [sys].[foreign_keys] AS [f]
754 1
            ON [f].[object_id] = [o].[object_id]
755
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
756
            ON [fc].[constraint_object_id] = [o].[object_id]
757 1
        LEFT JOIN [sys].[columns] AS [fccol]
758
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
759 1
        LEFT JOIN [sys].[columns] AS [ffccol]
760 1
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
761
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
762
        SQL;
763 1
764
        $resolvedName = $this->resolveTableName($tableName);
765
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
766
767
        /** @psalm-var array[] $constraints */
768
        $constraints = $this->normalizeRowKeyCase($constraints, true);
769
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
770
771
        $result = [
772
            self::PRIMARY_KEY => null,
773
            self::FOREIGN_KEYS => [],
774
            self::UNIQUES => [],
775
            self::CHECKS => [],
776
            self::DEFAULTS => [],
777
        ];
778
779
        /** @psalm-var array<array-key, array> $constraints */
780
        foreach ($constraints as $type => $names) {
781 79
            /**
782
             * @psalm-var object|string|null $name
783 79
             * @psalm-var ConstraintArray $constraint
784
             */
785
            foreach ($names as $name => $constraint) {
786
                switch ($type) {
787
                    case 'PK':
788
                        /** @var Constraint */
789
                        $result[self::PRIMARY_KEY] = (new Constraint())
790
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
791
                            ->name($name);
792
                        break;
793
                    case 'F':
794
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
795
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
796
                            ->foreignTableName($constraint[0]['foreign_table_name'])
797
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
798
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
799
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
800
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
801
                            ->name($name);
802
                        break;
803
                    case 'UQ':
804
                        $result[self::UNIQUES][] = (new Constraint())
805
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
806
                            ->name($name);
807
                        break;
808
                    case 'C':
809
                        $result[self::CHECKS][] = (new CheckConstraint())
810
                            ->expression($constraint[0]['check_expr'])
811
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
812
                            ->name($name);
813
                        break;
814
                    case 'D':
815
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
816
                            ->value($constraint[0]['default_expr'])
817
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
818
                            ->name($name);
819
                        break;
820
                }
821
            }
822
        }
823 79
824 79
        foreach ($result as $type => $data) {
825
            $this->setTableMetadata($tableName, $type, $data);
826
        }
827 79
828 79
        return $result[$returnType];
829
    }
830 79
831
    /**
832 79
     * Create a column schema builder instance giving the type and value precision.
833 79
     *
834 79
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
835 79
     *
836
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
837
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
838
     *
839 79
     * @return ColumnSchemaBuilder column schema builder instance
840
     *
841
     * @psalm-param array<array-key, string>|int|null|string $length
842
     */
843
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
844 79
    {
845 79
        return new ColumnSchemaBuilder($type, $length);
846 79
    }
847
848 58
    /**
849 58
     * Returns the cache key for the specified table name.
850 58
     *
851 58
     * @param string $name the table name.
852 79
     *
853 21
     * @return array the cache key.
854 21
     */
855 21
    protected function getCacheKey(string $name): array
856 21
    {
857 21
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
858 21
    }
859 21
860 21
    /**
861 21
     * Returns the cache tag name.
862 64
     *
863 62
     * This allows {@see refresh()} to invalidate all cached table schemas.
864 62
     *
865 62
     * @return string the cache tag name.
866 62
     */
867 36
    protected function getCacheTag(): string
868 18
    {
869 18
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
870 18
    }
871 18
872 18
    /**
873 35
     * @return bool whether this DBMS supports [savepoint](http://en.wikipedia.org/wiki/Savepoint).
874 35
     */
875 35
    public function supportsSavepoint(): bool
876 35
    {
877 35
        return $this->db->isSavepointEnabled();
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
878 35
    }
879
880
    /**
881
     * Changes row's array key case to lower.
882
     *
883 79
     * @param array $row row's array or an array of row's arrays.
884 79
     * @param bool $multiple whether multiple rows or a single row passed.
885
     *
886
     * @return array normalized row or rows.
887 79
     */
888
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
889
    {
890
        if ($multiple) {
891
            return array_map(static function (array $row) {
892
                return array_change_key_case($row, CASE_LOWER);
893
            }, $row);
894
        }
895
896
        return array_change_key_case($row, CASE_LOWER);
897
    }
898
899
    /**
900
     * @inheritDoc
901
     */
902 3
    public function getLastInsertID(?string $sequenceName = null): string
903
    {
904 3
        return $this->db->getLastInsertID($sequenceName);
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist on Yiisoft\Db\Mssql\Schema. Did you maybe forget to declare it?
Loading history...
905
    }
906
}
907