Passed
Push — dev ( 214347...f9bbea )
by Def
27:01 queued 23:58
created

Schema::findColumns()   B

Complexity

Conditions 10
Paths 36

Size

Total Lines 73
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 10.4096

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 10
eloc 52
c 1
b 0
f 0
nc 36
nop 1
dl 0
loc 73
ccs 21
cts 25
cp 0.84
crap 10.4096
rs 7.1806

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Mssql;
6
7
use Throwable;
8
use Yiisoft\Arrays\ArrayHelper;
9
use Yiisoft\Db\Cache\SchemaCache;
10
use Yiisoft\Db\Connection\ConnectionInterface;
11
use Yiisoft\Db\Constraint\CheckConstraint;
12
use Yiisoft\Db\Constraint\Constraint;
13
use Yiisoft\Db\Constraint\DefaultValueConstraint;
14
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
15
use Yiisoft\Db\Constraint\IndexConstraint;
16
use Yiisoft\Db\Exception\Exception;
17
use Yiisoft\Db\Exception\InvalidConfigException;
18
use Yiisoft\Db\Schema\ColumnSchemaBuilder;
19
use Yiisoft\Db\Schema\Schema as AbstractSchema;
20
use Yiisoft\Db\View\ViewInterface;
21
22
use function array_change_key_case;
23
use function array_map;
24
use function count;
25
use function explode;
26
use function is_array;
27
use function md5;
28
use function preg_match;
29
use function preg_match_all;
30
use function preg_replace;
31
use function serialize;
32
use function str_contains;
33
use function str_replace;
34
use function strcasecmp;
35
use function stripos;
36
37
/**
38
 * Schema is the class for retrieving metadata from MS SQL Server databases (version 2008 and above).
39
 *
40
 * @psalm-type ColumnArray = array{
41
 *   column_name: string,
42
 *   is_nullable: string,
43
 *   data_type: string,
44
 *   column_default: mixed,
45
 *   is_identity: string,
46
 *   comment: null|string
47
 * }
48
 *
49
 * @psalm-type ConstraintArray = array<
50
 *   array-key,
51
 *   array {
52
 *     name: string,
53
 *     column_name: string,
54
 *     type: string,
55
 *     foreign_table_schema: string|null,
56
 *     foreign_table_name: string|null,
57
 *     foreign_column_name: string|null,
58
 *     on_update: string,
59
 *     on_delete: string,
60
 *     check_expr: string,
61
 *     default_expr: string
62
 *   }
63
 * >
64
 */
65
final class Schema extends AbstractSchema implements ViewInterface
66
{
67
    public const DEFAULTS = 'defaults';
68
69
    /**
70
     * @var string|null the default schema used for the current session.
71
     */
72
    protected ?string $defaultSchema = 'dbo';
73
74
    /**
75
     * @var array mapping from physical column types (keys) to abstract column types (values)
76
     *
77
     * @psalm-var string[]
78
     */
79
    private array $typeMap = [
80
        /** exact numbers */
81
        'bigint' => self::TYPE_BIGINT,
82
        'numeric' => self::TYPE_DECIMAL,
83
        'bit' => self::TYPE_SMALLINT,
84
        'smallint' => self::TYPE_SMALLINT,
85
        'decimal' => self::TYPE_DECIMAL,
86
        'smallmoney' => self::TYPE_MONEY,
87
        'int' => self::TYPE_INTEGER,
88
        'tinyint' => self::TYPE_TINYINT,
89
        'money' => self::TYPE_MONEY,
90
91
        /** approximate numbers */
92
        'float' => self::TYPE_FLOAT,
93
        'double' => self::TYPE_DOUBLE,
94
        'real' => self::TYPE_FLOAT,
95
96
        /** date and time */
97
        'date' => self::TYPE_DATE,
98
        'datetimeoffset' => self::TYPE_DATETIME,
99
        'datetime2' => self::TYPE_DATETIME,
100
        'smalldatetime' => self::TYPE_DATETIME,
101
        'datetime' => self::TYPE_DATETIME,
102
        'time' => self::TYPE_TIME,
103
104
        /** character strings */
105
        'char' => self::TYPE_CHAR,
106
        'varchar' => self::TYPE_STRING,
107
        'text' => self::TYPE_TEXT,
108
109
        /** unicode character strings */
110
        'nchar' => self::TYPE_CHAR,
111
        'nvarchar' => self::TYPE_STRING,
112
        'ntext' => self::TYPE_TEXT,
113
114
        /** binary strings */
115
        'binary' => self::TYPE_BINARY,
116
        'varbinary' => self::TYPE_BINARY,
117
        'image' => self::TYPE_BINARY,
118
119
        /**
120
         * other data types 'cursor' type cannot be used with tables
121
         */
122
        'timestamp' => self::TYPE_TIMESTAMP,
123
        'hierarchyid' => self::TYPE_STRING,
124
        'uniqueidentifier' => self::TYPE_STRING,
125
        'sql_variant' => self::TYPE_STRING,
126
        'xml' => self::TYPE_STRING,
127
        'table' => self::TYPE_STRING,
128
    ];
129
130
    private array $viewNames = [];
131
132 382
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
133
    {
134 382
        parent::__construct($schemaCache);
135
    }
136
137
    /**
138
     * Resolves the table name and schema name (if any).
139
     *
140
     * @param string $name the table name.
141
     *
142
     * @return TableSchema resolved table, schema, etc. names.
143
     *
144
     * @todo Review this method and see if it can be simplified @darkdef.
145
     */
146 84
    protected function resolveTableName(string $name): TableSchema
147
    {
148 84
        $resolvedName = new TableSchema();
149 84
        $parts = $this->getTableNameParts($name);
150 84
        $partCount = count($parts);
151
152 84
        if ($partCount === 4) {
153
            /** server name, catalog name, schema name and table name passed - not coverage tests */
154
            $resolvedName->catalogName($parts[1]);
155
            $resolvedName->schemaName($parts[2]);
156
            $resolvedName->name($parts[3]);
157
            $resolvedName->fullName(
158
                (string) $resolvedName->getCatalogName() . '.' .
159
                (string) $resolvedName->getSchemaName() . '.' .
160
                $resolvedName->getName()
161
            );
162 84
        } elseif ($partCount === 3) {
163
            /** catalog name, schema name and table name passed - not coverage tests */
164
            $resolvedName->catalogName($parts[0]);
165
            $resolvedName->schemaName($parts[1]);
166
            $resolvedName->name($parts[2]);
167
            $resolvedName->fullName(
168
                (string) $resolvedName->getCatalogName() . '.' .
169
                (string) $resolvedName->getSchemaName() . '.' .
170
                $resolvedName->getName()
171
            );
172 84
        } elseif ($partCount === 2) {
173
            /** only schema name and table name passed - not coverage tests */
174
            $resolvedName->schemaName($parts[0]);
175
            $resolvedName->name($parts[1]);
176
            $resolvedName->fullName(
177
                (
178
                    $resolvedName->getSchemaName() !== $this->defaultSchema
179
                    ? (string) $resolvedName->getSchemaName() . '.' : ''
180
                ) . $resolvedName->getName()
181
            );
182
        } else {
183
            /** only table name passed */
184 84
            $resolvedName->schemaName($this->defaultSchema);
185 84
            $resolvedName->name($parts[0]);
186 84
            $resolvedName->fullName($resolvedName->getName());
187
        }
188
189 84
        return $resolvedName;
190
    }
191
192
    /**
193
     * Splits full table name into parts.
194
     *
195
     * @param string $name
196
     *
197
     * @return array
198
     *
199
     * @psalm-return string[]
200
     */
201 171
    protected function getTableNameParts(string $name): array
202
    {
203 171
        $parts = [$name];
204
205 171
        preg_match_all('/([^.\[\]]+)|\[([^\[\]]+)]/', $name, $matches);
206
207 171
        if (isset($matches[0]) && !empty($matches[0])) {
208 171
            $parts = $matches[0];
209
        }
210
211
        /** @psalm-var string[] */
212 171
        return str_replace(['[', ']'], '', $parts);
213
    }
214
215
    /**
216
     * Returns all schema names in the database, including the default one but not system schemas.
217
     *
218
     * This method should be overridden by child classes in order to support this feature because the default
219
     * implementation simply throws an exception.
220
     *
221
     * @throws Exception|InvalidConfigException|Throwable
222
     *
223
     * @return array all schema names in the database, except system schemas.
224
     *
225
     * {@see https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-principals-transact-sql}
226
     */
227 1
    protected function findSchemaNames(): array
228
    {
229 1
        $sql = <<<SQL
230
        SELECT [s].[name]
231
        FROM [sys].[schemas] AS [s]
232
        INNER JOIN [sys].[database_principals] AS [p] ON [p].[principal_id] = [s].[principal_id]
233
        WHERE [p].[is_fixed_role] = 0 AND [p].[sid] IS NOT NULL
234
        ORDER BY [s].[name] ASC
235
        SQL;
236
237 1
        $schemaNames = $this->db->createCommand($sql)->queryColumn();
238 1
        if (!$schemaNames) {
239
            return [];
240
        }
241
242 1
        return $schemaNames;
243
    }
244
245
    /**
246
     * Returns all table names in the database.
247
     *
248
     * This method should be overridden by child classes in order to support this feature because the default
249
     * implementation simply throws an exception.
250
     *
251
     * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema.
252
     *
253
     * @throws Exception|InvalidConfigException|Throwable
254
     *
255
     * @return array all table names in the database. The names have NO schema name prefix.
256
     */
257 5
    protected function findTableNames(string $schema = ''): array
258
    {
259 5
        if ($schema === '') {
260 5
            $schema = $this->defaultSchema;
261
        }
262
263 5
        $sql = <<<SQL
264
        SELECT [t].[table_name]
265
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
266
        WHERE [t].[table_schema] = :schema AND [t].[table_type] IN ('BASE TABLE', 'VIEW')
267
        ORDER BY [t].[table_name]
268
        SQL;
269
270 5
        $tables = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
271 5
        if (!$tables) {
272
            return [];
273
        }
274
275 5
        return array_map(static fn (string $item): string => '[' . $item . ']', $tables);
276
    }
277
278
    /**
279
     * Loads the metadata for the specified table.
280
     *
281
     * @param string $name table name.
282
     *
283
     * @throws Exception|InvalidConfigException|Throwable
284
     *
285
     * @return TableSchema|null DBMS-dependent table metadata, `null` if the table does not exist.
286
     */
287 111
    protected function loadTableSchema(string $name): ?TableSchema
288
    {
289 111
        $table = new TableSchema();
290
291 111
        $this->resolveTableNames($table, $name);
292 111
        $this->findPrimaryKeys($table);
293
294 111
        if ($this->findColumns($table)) {
295 102
            $this->findForeignKeys($table);
296 102
            return $table;
297
        }
298
299 19
        return null;
300
    }
301
302
    /**
303
     * Loads a primary key for the given table.
304
     *
305
     * @param string $tableName table name.
306
     *
307
     * @throws Exception|InvalidConfigException|Throwable
308
     *
309
     * @return Constraint|null The primary key for the given table, `null` if the table has no primary key.
310
     */
311 31
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
312
    {
313
        /** @var mixed */
314 31
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
315 31
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
316
    }
317
318
    /**
319
     * Loads all foreign keys for the given table.
320
     *
321
     * @param string $tableName table name.
322
     *
323
     * @throws Exception|InvalidConfigException|Throwable
324
     *
325
     * @return array The foreign keys for the given table.
326
     */
327 4
    protected function loadTableForeignKeys(string $tableName): array
328
    {
329
        /** @var mixed */
330 4
        $tableForeingKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
331 4
        return is_array($tableForeingKeys) ? $tableForeingKeys : [];
332
    }
333
334
    /**
335
     * Loads all indexes for the given table.
336
     *
337
     * @param string $tableName table name.
338
     *
339
     * @throws Exception|InvalidConfigException|Throwable
340
     *
341
     * @return array indexes for the given table.
342
     */
343 29
    protected function loadTableIndexes(string $tableName): array
344
    {
345 29
        $sql = <<<SQL
346
        SELECT
347
            [i].[name] AS [name],
348
            [iccol].[name] AS [column_name],
349
            [i].[is_unique] AS [index_is_unique],
350
            [i].[is_primary_key] AS [index_is_primary]
351
        FROM [sys].[indexes] AS [i]
352
        INNER JOIN [sys].[index_columns] AS [ic]
353
            ON [ic].[object_id] = [i].[object_id] AND [ic].[index_id] = [i].[index_id]
354
        INNER JOIN [sys].[columns] AS [iccol]
355
            ON [iccol].[object_id] = [ic].[object_id] AND [iccol].[column_id] = [ic].[column_id]
356
        WHERE [i].[object_id] = OBJECT_ID(:fullName)
357
        ORDER BY [ic].[key_ordinal] ASC
358
        SQL;
359
360 29
        $resolvedName = $this->resolveTableName($tableName);
361 29
        $indexes = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
362
363
        /** @psalm-var array[] $indexes */
364 29
        $indexes = $this->normalizeRowKeyCase($indexes, true);
365 29
        $indexes = ArrayHelper::index($indexes, null, 'name');
366
367 29
        $result = [];
368
369
        /**
370
         * @psalm-var array<
371
         *   string,
372
         *   array<
373
         *     array-key,
374
         *     array{array-key, name: string, column_name: string, index_is_unique: string, index_is_primary: string}
375
         *   >
376
         * > $indexes
377
         */
378 29
        foreach ($indexes as $name => $index) {
379 26
            $result[] = (new IndexConstraint())
380 26
                ->primary((bool) $index[0]['index_is_primary'])
381 26
                ->unique((bool) $index[0]['index_is_unique'])
382 26
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
383 26
                ->name($name);
384
        }
385
386 29
        return $result;
387
    }
388
389
    /**
390
     * Loads all unique constraints for the given table.
391
     *
392
     * @param string $tableName table name.
393
     *
394
     * @throws Exception|InvalidConfigException|Throwable
395
     *
396
     * @return array The unique constraints for the given table.
397
     */
398 13
    protected function loadTableUniques(string $tableName): array
399
    {
400
        /** @var mixed */
401 13
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
402 13
        return is_array($tableUniques) ? $tableUniques : [];
403
    }
404
405
    /**
406
     * Loads all check constraints for the given table.
407
     *
408
     * @param string $tableName table name.
409
     *
410
     * @throws Exception|InvalidConfigException|Throwable
411
     *
412
     * @return array The check constraints for the given table.
413
     */
414 13
    protected function loadTableChecks(string $tableName): array
415
    {
416
        /** @var mixed */
417 13
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
418 13
        return is_array($tableCheck) ? $tableCheck : [];
419
    }
420
421
    /**
422
     * Loads all default value constraints for the given table.
423
     *
424
     * @param string $tableName table name.
425
     *
426
     * @throws Exception|InvalidConfigException|Throwable
427
     *
428
     * @return array The default value constraints for the given table.
429
     */
430 13
    protected function loadTableDefaultValues(string $tableName): array
431
    {
432
        /** @var mixed */
433 13
        $tableDefault = $this->loadTableConstraints($tableName, self::DEFAULTS);
434 13
        return is_array($tableDefault) ? $tableDefault : [];
435
    }
436
437
    /**
438
     * Creates a column schema for the database.
439
     *
440
     * This method may be overridden by child classes to create a DBMS-specific column schema.
441
     *
442
     * @return ColumnSchema column schema instance.
443
     */
444 102
    protected function createColumnSchema(): ColumnSchema
445
    {
446 102
        return new ColumnSchema();
447
    }
448
449
    /**
450
     * Resolves the table name and schema name (if any).
451
     *
452
     * @param TableSchema $table the table metadata object.
453
     * @param string $name the table name
454
     *
455
     * @todo Review this method and see if it can be simplified @darkdef.
456
     */
457 111
    protected function resolveTableNames(TableSchema $table, string $name): void
458
    {
459 111
        $parts = $this->getTableNameParts($name);
460
461 111
        $partCount = count($parts);
462
463 111
        if ($partCount === 4) {
464
            /** server name, catalog name, schema name and table name passed - no coverage tests */
465
            $table->catalogName($parts[1]);
466
            $table->schemaName($parts[2]);
467
            $table->name($parts[3]);
468
            $table->fullName(
469
                (string) $table->getCatalogName() . '.' . (string) $table->getSchemaName() . '.' . $table->getName()
470
            );
471 111
        } elseif ($partCount === 3) {
472
            /** catalog name, schema name and table name passed - no coverage tests */
473
            $table->catalogName($parts[0]);
474
            $table->schemaName($parts[1]);
475
            $table->name($parts[2]);
476
            $table->fullName(
477
                (string) $table->getCatalogName() . '.' . (string) $table->getSchemaName() . '.' . $table->getName()
478
            );
479 111
        } elseif ($partCount === 2) {
480
            /** only schema name and table name passed - no coverage tests */
481 3
            $table->schemaName($parts[0]);
482 3
            $table->name($parts[1]);
483 3
            $table->fullName(
484 3
                $table->getSchemaName() !== $this->defaultSchema
485 3
                ? (string) $table->getSchemaName() . '.' . $table->getName() : $table->getName()
486
            );
487
        } else {
488
            /** only table name passed */
489 108
            $table->schemaName($this->defaultSchema);
490 108
            $table->name($parts[0]);
491 108
            $table->fullName($table->getName());
492
        }
493
    }
494
495
    /**
496
     * Loads the column information into a {@see ColumnSchema} object.
497
     *
498
     * @psalm-param ColumnArray $info The column information.
499
     *
500
     * @return ColumnSchema the column schema object.
501
     */
502 102
    protected function loadColumnSchema(array $info): ColumnSchema
503
    {
504 102
        $column = $this->createColumnSchema();
505
506 102
        $column->name($info['column_name']);
507 102
        $column->allowNull($info['is_nullable'] === 'YES');
508 102
        $column->dbType($info['data_type']);
509 102
        $column->enumValues([]); // mssql has only vague equivalents to enum
510 102
        $column->primaryKey(false); // primary key will be determined in findColumns() method
511 102
        $column->autoIncrement($info['is_identity'] === '1');
512 102
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
513 102
        $column->comment($info['comment'] ?? '');
514 102
        $column->type(self::TYPE_STRING);
515
516 102
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
517 102
            $type = $matches[1];
518
519 102
            if (isset($this->typeMap[$type])) {
520 102
                $column->type($this->typeMap[$type]);
521
            }
522
523 102
            if (!empty($matches[2])) {
524 86
                $values = explode(',', $matches[2]);
525 86
                $column->precision((int) $values[0]);
526 86
                $column->size((int) $values[0]);
527
528 86
                if (isset($values[1])) {
529
                    $column->scale((int) $values[1]);
530
                }
531
532 86
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
533
                    $column->type('boolean');
534 86
                } elseif ($type === 'bit') {
535
                    if ($column->getSize() > 32) {
536
                        $column->type('bigint');
537
                    } elseif ($column->getSize() === 32) {
538
                        $column->type('integer');
539
                    }
540
                }
541
            }
542
        }
543
544 102
        $column->phpType($this->getColumnPhpType($column));
545
546 102
        if ($info['column_default'] === '(NULL)') {
547 4
            $info['column_default'] = null;
548
        }
549
550 102
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
551 102
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
552
        }
553
554 102
        return $column;
555
    }
556
557
    /**
558
     * Collects the metadata of table columns.
559
     *
560
     * @param TableSchema $table the table metadata.
561
     *
562
     * @throws Throwable
563
     *
564
     * @return bool whether the table exists in the database.
565
     */
566 111
    protected function findColumns(TableSchema $table): bool
567
    {
568 111
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
569 111
        $whereSql = '[t1].[table_name] = ' . (string) $this->db->getQuoter()->quoteValue($table->getName());
570
571 111
        if ($table->getCatalogName() !== null) {
572
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
573
            $whereSql .= " AND [t1].[table_catalog] = '{$table->getCatalogName()}'";
574
        }
575
576 111
        if ($table->getSchemaName() !== null) {
577 111
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
578
        }
579
580 111
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
581
582 111
        $sql = <<<SQL
583
        SELECT
584
            [t1].[column_name],
585
            [t1].[is_nullable],
586
        CASE WHEN [t1].[data_type] IN ('char','varchar','nchar','nvarchar','binary','varbinary') THEN
587
        CASE WHEN [t1].[character_maximum_length] = NULL OR [t1].[character_maximum_length] = -1 THEN
588
            [t1].[data_type]
589
        ELSE
590
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[character_maximum_length]))) + ')'
591
        END
592
        ELSE
593
            [t1].[data_type]
594
        END AS 'data_type',
595
        [t1].[column_default],
596
        COLUMNPROPERTY(OBJECT_ID([t1].[table_schema] + '.' + [t1].[table_name]), [t1].[column_name], 'IsIdentity') AS is_identity,
597
        (
598
        SELECT CONVERT(VARCHAR, [t2].[value])
599
        FROM [sys].[extended_properties] AS [t2]
600
        WHERE
601
        [t2].[class] = 1 AND
602
        [t2].[class_desc] = 'OBJECT_OR_COLUMN' AND
603
        [t2].[name] = 'MS_Description' AND
604
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
605
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
606
        ) as comment
607
        FROM $columnsTableName AS [t1]
608
        WHERE $whereSql
609
        SQL;
610
611
        try {
612
            /** @psalm-var ColumnArray[] */
613 111
            $columns = $this->db->createCommand($sql)->queryAll();
614
615 111
            if (empty($columns)) {
616 111
                return false;
617
            }
618
        } catch (Exception) {
619
            return false;
620
        }
621
622 102
        foreach ($columns as $column) {
623 102
            $column = $this->loadColumnSchema($column);
624 102
            foreach ($table->getPrimaryKey() as $primaryKey) {
625 68
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
626 68
                    $column->primaryKey(true);
627 68
                    break;
628
                }
629
            }
630
631 102
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
632 67
                $table->sequenceName('');
633
            }
634
635 102
            $table->columns($column->getName(), $column);
636
        }
637
638 102
        return true;
639
    }
640
641
    /**
642
     * Collects the constraint details for the given table and constraint type.
643
     *
644
     * @param TableSchema $table
645
     * @param string $type either PRIMARY KEY or UNIQUE.
646
     *
647
     * @throws Exception|InvalidConfigException|Throwable
648
     *
649
     * @return array each entry contains index_name and field_name.
650
     */
651 111
    protected function findTableConstraints(TableSchema $table, string $type): array
652
    {
653 111
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
654 111
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
655
656 111
        if ($table->getCatalogName() !== null) {
657
            $keyColumnUsageTableName = $table->getCatalogName() . '.' . $keyColumnUsageTableName;
658
            $tableConstraintsTableName = $table->getCatalogName() . '.' . $tableConstraintsTableName;
659
        }
660
661 111
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
662 111
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
663
664 111
        $sql = <<<SQL
665
        SELECT
666
            [kcu].[constraint_name] AS [index_name],
667
            [kcu].[column_name] AS [field_name]
668
        FROM $keyColumnUsageTableName AS [kcu]
669
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
670
            [kcu].[table_schema] = [tc].[table_schema] AND
671
            [kcu].[table_name] = [tc].[table_name] AND
672
            [kcu].[constraint_name] = [tc].[constraint_name]
673
        WHERE
674
            [tc].[constraint_type] = :type AND
675
            [kcu].[table_name] = :tableName AND
676
            [kcu].[table_schema] = :schemaName
677
        SQL;
678
679 111
        return $this->db->createCommand(
680
            $sql,
681
            [
682 111
                ':tableName' => $table->getName(),
683 111
                ':schemaName' => $table->getSchemaName(),
684
                ':type' => $type,
685
            ]
686 111
        )->queryAll();
687
    }
688
689
    /**
690
     * Collects the primary key column details for the given table.
691
     *
692
     * @param TableSchema $table the table metadata
693
     *
694
     * @throws Exception|InvalidConfigException|Throwable
695
     */
696 111
    protected function findPrimaryKeys(TableSchema $table): void
697
    {
698
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
699 111
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
700
701 111
        foreach ($primaryKeys as $row) {
702 68
            $table->primaryKey($row['field_name']);
703
        }
704
    }
705
706
    /**
707
     * Collects the foreign key column details for the given table.
708
     *
709
     * @param TableSchema $table the table metadata
710
     *
711
     * @throws Exception|InvalidConfigException|Throwable
712
     */
713 102
    protected function findForeignKeys(TableSchema $table): void
714
    {
715 102
        $catalogName = $table->getCatalogName();
716 102
        $fk = [];
717 102
        $object = $table->getName();
718 102
        $schemaName = $table->getSchemaName();
719
720 102
        if ($schemaName !== null) {
721 102
            $object = $schemaName . '.' . $object;
722
        }
723
724 102
        if ($catalogName !== null) {
725
            $object = $catalogName . '.' . $object;
726
        }
727
728
        /**
729
         * Please refer to the following page for more details:
730
         * {@see http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx}
731
         */
732 102
        $sql = <<<SQL
733
        SELECT
734
        [fk].[name] AS [fk_name],
735
        [cp].[name] AS [fk_column_name],
736
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
737
        [cr].[name] AS [uq_column_name]
738
        FROM [sys].[foreign_keys] AS [fk]
739
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
740
            ON [fk].[object_id] = [fkc].[constraint_object_id]
741
        INNER JOIN [sys].[columns] AS [cp]
742
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
743
        INNER JOIN [sys].[columns] AS [cr]
744
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
745
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
746
        SQL;
747
748
        /**
749
         * @psalm-var array<
750
         *   array-key,
751
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
752
         * > $rows
753
         */
754 102
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
755 102
        $table->foreignKeys([]);
756
757 102
        foreach ($rows as $row) {
758 5
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
759 5
                $fk[$row['fk_name']][] = $row['uq_table_name'];
760 5
                $table->foreignKeys($fk);
761
            }
762
763 5
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
764 5
            $table->foreignKeys($fk);
765
        }
766
    }
767
768
    /**
769
     * @throws Exception|InvalidConfigException|Throwable
770
     */
771 3
    public function findViewNames(string $schema = ''): array
772
    {
773 3
        if ($schema === '') {
774 1
            $schema = $this->defaultSchema;
775
        }
776
777 3
        $sql = <<<SQL
778
        SELECT [t].[table_name]
779
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
780
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
781
        ORDER BY [t].[table_name]
782
        SQL;
783
784 3
        $views = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
785 3
        if (!$views) {
786
            return [];
787
        }
788
789 3
        return array_map(static fn (string $item): string => '[' . $item . ']', $views);
790
    }
791
792
    /**
793
     * Returns all unique indexes for the given table.
794
     *
795
     * Each array element is of the following structure:
796
     *
797
     * ```php
798
     * [
799
     *     'IndexName1' => ['col1' [, ...]],
800
     *     'IndexName2' => ['col2' [, ...]],
801
     * ]
802
     * ```
803
     *
804
     * @param TableSchema $table the table metadata.
805
     *
806
     * @throws Exception|InvalidConfigException|Throwable
807
     *
808
     * @return array all unique indexes for the given table.
809
     */
810 1
    public function findUniqueIndexes(TableSchema $table): array
811
    {
812 1
        $result = [];
813
814
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
815 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
816
817 1
        foreach ($tableUniqueConstraints as $row) {
818 1
            $result[$row['index_name']][] = $row['field_name'];
819
        }
820
821 1
        return $result;
822
    }
823
824
    /**
825
     * Loads multiple types of constraints and returns the specified ones.
826
     *
827
     * @param string $tableName table name.
828
     * @param string $returnType return type:
829
     * - primaryKey
830
     * - foreignKeys
831
     * - uniques
832
     * - checks
833
     * - defaults
834
     *
835
     * @throws Exception|InvalidConfigException|Throwable
836
     *
837
     * @return mixed constraints.
838
     */
839 74
    private function loadTableConstraints(string $tableName, string $returnType): mixed
840
    {
841 74
        $sql = <<<SQL
842
        SELECT
843
            [o].[name] AS [name],
844
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
845
            RTRIM([o].[type]) AS [type],
846
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
847
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
848
            [ffccol].[name] AS [foreign_column_name],
849
            [f].[update_referential_action_desc] AS [on_update],
850
            [f].[delete_referential_action_desc] AS [on_delete],
851
            [c].[definition] AS [check_expr],
852
            [d].[definition] AS [default_expr]
853
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
854
        INNER JOIN [sys].[objects] AS [o]
855
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
856
        LEFT JOIN [sys].[check_constraints] AS [c]
857
            ON [c].[object_id] = [o].[object_id]
858
        LEFT JOIN [sys].[columns] AS [ccol]
859
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
860
        LEFT JOIN [sys].[default_constraints] AS [d]
861
            ON [d].[object_id] = [o].[object_id]
862
        LEFT JOIN [sys].[columns] AS [dcol]
863
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
864
        LEFT JOIN [sys].[key_constraints] AS [k]
865
            ON [k].[object_id] = [o].[object_id]
866
        LEFT JOIN [sys].[index_columns] AS [kic]
867
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
868
        LEFT JOIN [sys].[columns] AS [kiccol]
869
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
870
        LEFT JOIN [sys].[foreign_keys] AS [f]
871
            ON [f].[object_id] = [o].[object_id]
872
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
873
            ON [fc].[constraint_object_id] = [o].[object_id]
874
        LEFT JOIN [sys].[columns] AS [fccol]
875
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
876
        LEFT JOIN [sys].[columns] AS [ffccol]
877
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
878
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
879
        SQL;
880
881 74
        $resolvedName = $this->resolveTableName($tableName);
882 74
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
883
884
        /** @psalm-var array[] $constraints */
885 74
        $constraints = $this->normalizeRowKeyCase($constraints, true);
886 74
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
887
888 74
        $result = [
889
            'primaryKey' => null,
890
            'foreignKeys' => [],
891
            'uniques' => [],
892
            'checks' => [],
893
            'defaults' => [],
894
        ];
895
896
        /** @psalm-var array<array-key, array> $constraints */
897 74
        foreach ($constraints as $type => $names) {
898
            /**
899
             * @psalm-var object|string|null $name
900
             * @psalm-var ConstraintArray $constraint
901
             */
902 74
            foreach ($names as $name => $constraint) {
903 74
                switch ($type) {
904 74
                    case 'PK':
905
                        /** @var Constraint */
906 53
                        $result['primaryKey'] = (new Constraint())
907 53
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
908 53
                            ->name($name);
909 53
                        break;
910 74
                    case 'F':
911 16
                        $result['foreignKeys'][] = (new ForeignKeyConstraint())
912 16
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
913 16
                            ->foreignTableName($constraint[0]['foreign_table_name'])
914 16
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
915 16
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
916 16
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
917 16
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
918 16
                            ->name($name);
919 16
                        break;
920 59
                    case 'UQ':
921 57
                        $result['uniques'][] = (new Constraint())
922 57
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
923 57
                            ->name($name);
924 57
                        break;
925 31
                    case 'C':
926 13
                        $result['checks'][] = (new CheckConstraint())
927 13
                            ->expression($constraint[0]['check_expr'])
928 13
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
929 13
                            ->name($name);
930 13
                        break;
931 30
                    case 'D':
932 30
                        $result['defaults'][] = (new DefaultValueConstraint())
933 30
                            ->value($constraint[0]['default_expr'])
934 30
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
935 30
                            ->name($name);
936 30
                        break;
937
                }
938
            }
939
        }
940
941 74
        foreach ($result as $type => $data) {
942 74
            $this->setTableMetadata($tableName, $type, $data);
943
        }
944
945 74
        return $result[$returnType];
946
    }
947
948
    /**
949
     * Create a column schema builder instance giving the type and value precision.
950
     *
951
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
952
     *
953
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
954
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
955
     *
956
     * @return ColumnSchemaBuilder column schema builder instance
957
     *
958
     * @psalm-param array<array-key, string>|int|null|string $length
959
     */
960 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
961
    {
962 3
        return new ColumnSchemaBuilder($type, $length);
963
    }
964
965
    /**
966
     * Returns the actual name of a given table name.
967
     *
968
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
969
     * {@see ConnectionInterface::tablePrefix}.
970
     *
971
     * @param string $name the table name to be converted.
972
     *
973
     * @return string the real name of the given table name.
974
     */
975 171
    public function getRawTableName(string $name): string
976
    {
977 171
        if (str_contains($name, '{{')) {
978 24
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
979
980 24
            return str_replace('%', $this->db->getTablePrefix(), $name);
981
        }
982
983 171
        return $name;
984
    }
985
986
    /**
987
     * Returns the cache key for the specified table name.
988
     *
989
     * @param string $name the table name.
990
     *
991
     * @return array the cache key.
992
     */
993 171
    protected function getCacheKey(string $name): array
994
    {
995 171
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
996
    }
997
998
    /**
999
     * Returns the cache tag name.
1000
     *
1001
     * This allows {@see refresh()} to invalidate all cached table schemas.
1002
     *
1003
     * @return string the cache tag name.
1004
     */
1005 171
    protected function getCacheTag(): string
1006
    {
1007 171
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
1008
    }
1009
1010
    /**
1011
     * @return bool whether this DBMS supports [savepoint](http://en.wikipedia.org/wiki/Savepoint).
1012
     */
1013 5
    public function supportsSavepoint(): bool
1014
    {
1015 5
        return $this->db->isSavepointEnabled();
1016
    }
1017
1018
    /**
1019
     * Changes row's array key case to lower.
1020
     *
1021
     * @param array $row row's array or an array of row's arrays.
1022
     * @param bool $multiple whether multiple rows or a single row passed.
1023
     *
1024
     * @return array normalized row or rows.
1025
     */
1026 84
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
1027
    {
1028 84
        if ($multiple) {
1029 84
            return array_map(static function (array $row) {
1030 81
                return array_change_key_case($row, CASE_LOWER);
1031
            }, $row);
1032
        }
1033
1034
        return array_change_key_case($row, CASE_LOWER);
1035
    }
1036
1037
    /**
1038
     * @inheritDoc
1039
     */
1040
    public function getLastInsertID(?string $sequenceName = null): string
1041
    {
1042
        return $this->db->getLastInsertID($sequenceName);
1043
    }
1044
1045
    /**
1046
     * @throws Exception|InvalidConfigException|Throwable
1047
     */
1048 2
    public function getViewNames(string $schema = '', bool $refresh = false): array
1049
    {
1050 2
        if ($this->viewNames === [] || $refresh) {
1051 2
            $this->viewNames[$schema] = $this->findViewNames($schema);
1052
        }
1053
1054 2
        return is_array($this->viewNames[$schema]) ? $this->viewNames[$schema] : [];
1055
    }
1056
}
1057