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

140
        $resolvedName->/** @scrutinizer ignore-call */ 
141
                       serverName($name->getServerName());

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