Test Failed
Pull Request — dev (#85)
by Def
02:43
created

Schema::findForeignKeys()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 52
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 5.005

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 31
c 1
b 0
f 0
nc 12
nop 1
dl 0
loc 52
ccs 16
cts 17
cp 0.9412
crap 5.005
rs 9.1128

How to fix   Long Method   

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
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
133 380
    {
134
        parent::__construct($schemaCache);
135 380
    }
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
    protected function resolveTableName(string $name): TableSchema
147 84
    {
148
        $resolvedName = new TableSchema();
149 84
        $parts = $this->getTableNameParts($name);
150 84
        $partCount = count($parts);
151 84
152
        if ($partCount === 4) {
153 84
            /** 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
        } elseif ($partCount === 3) {
163 84
            /** 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
        } elseif ($partCount === 2) {
173 84
            /** 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
            $resolvedName->schemaName($this->defaultSchema);
185 84
            $resolvedName->name($parts[0]);
186 84
            $resolvedName->fullName($resolvedName->getName());
187 84
        }
188
189
        return $resolvedName;
190 84
    }
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
    protected function getTableNameParts(string $name): array
202 168
    {
203
        $parts = [$name];
204 168
205
        preg_match_all('/([^.\[\]]+)|\[([^\[\]]+)]/', $name, $matches);
206 168
207
        if (isset($matches[0]) && !empty($matches[0])) {
208 168
            $parts = $matches[0];
209 168
        }
210
211
        /** @psalm-var string[] */
212
        return str_replace(['[', ']'], '', $parts);
213 168
    }
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
    protected function findSchemaNames(): array
228 1
    {
229
        $sql = <<<SQL
230 1
        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
        $schemaNames = $this->db->createCommand($sql)->queryColumn();
238 1
        if (!$schemaNames) {
239 1
            return [];
240
        }
241
242
        return $schemaNames;
243 1
    }
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
    protected function findTableNames(string $schema = ''): array
258 5
    {
259
        if ($schema === '') {
260 5
            $schema = $this->defaultSchema;
261 5
        }
262
263
        $sql = <<<SQL
264 5
        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
        $tables = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
271 5
        if (!$tables) {
272 5
            return [];
273
        }
274
275
        return array_map(static fn (string $item): string => '[' . $item . ']', $tables);
276 5
    }
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
    protected function loadTableSchema(string $name): ?TableSchema
288 108
    {
289
        $table = new TableSchema();
290 108
291
        $this->resolveTableNames($table, $name);
292 108
        $this->findPrimaryKeys($table);
293 108
294
        if ($this->findColumns($table)) {
295 108
            $this->findForeignKeys($table);
296 99
            return $table;
297 99
        }
298
299
        return null;
300 19
    }
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
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
312 31
    {
313
        /** @var mixed */
314
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
315 31
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
316 31
    }
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
    protected function loadTableForeignKeys(string $tableName): array
328 4
    {
329
        /** @var mixed */
330
        $tableForeingKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
331 4
        return is_array($tableForeingKeys) ? $tableForeingKeys : [];
332 4
    }
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
    protected function loadTableIndexes(string $tableName): array
344 29
    {
345
        $sql = <<<SQL
346 29
        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
        $resolvedName = $this->resolveTableName($tableName);
361 29
        $indexes = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
362 29
363
        /** @psalm-var array[] $indexes */
364
        $indexes = $this->normalizeRowKeyCase($indexes, true);
365 29
        $indexes = ArrayHelper::index($indexes, null, 'name');
366 29
367
        $result = [];
368 29
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
        foreach ($indexes as $name => $index) {
379 29
            $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 26
        }
385
386
        return $result;
387 29
    }
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
    protected function loadTableUniques(string $tableName): array
399 13
    {
400
        /** @var mixed */
401
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
402 13
        return is_array($tableUniques) ? $tableUniques : [];
403 13
    }
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
    protected function loadTableChecks(string $tableName): array
415 13
    {
416
        /** @var mixed */
417
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
418 13
        return is_array($tableCheck) ? $tableCheck : [];
419 13
    }
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
    protected function loadTableDefaultValues(string $tableName): array
431 13
    {
432
        /** @var mixed */
433
        $tableDefault = $this->loadTableConstraints($tableName, self::DEFAULTS);
434 13
        return is_array($tableDefault) ? $tableDefault : [];
435 13
    }
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
    protected function createColumnSchema(): ColumnSchema
445 1
    {
446
        return new ColumnSchema();
447 1
    }
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 1
    protected function resolveTableNames(TableSchema $table, string $name): void
458
    {
459 1
        $parts = $this->getTableNameParts($name);
460
461
        $partCount = count($parts);
462
463
        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 1
                (string) $table->getCatalogName() . '.' . (string) $table->getSchemaName() . '.' . $table->getName()
470
            );
471 1
        } 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
        } elseif ($partCount === 2) {
480
            /** only schema name and table name passed - no coverage tests */
481 99
            $table->schemaName($parts[0]);
482
            $table->name($parts[1]);
483 99
            $table->fullName(
484
                $table->getSchemaName() !== $this->defaultSchema
485
                ? (string) $table->getSchemaName() . '.' . $table->getName() : $table->getName()
486
            );
487
        } else {
488
            /** only table name passed */
489
            $table->schemaName($this->defaultSchema);
490
            $table->name($parts[0]);
491
            $table->fullName($table->getName());
492
        }
493
    }
494 108
495
    /**
496 108
     * Loads the column information into a {@see ColumnSchema} object.
497
     *
498 108
     * @psalm-param ColumnArray $info The column information.
499
     *
500 108
     * @return ColumnSchema the column schema object.
501
     */
502
    protected function loadColumnSchema(array $info): ColumnSchema
503
    {
504
        $column = $this->createColumnSchema();
505
506
        $column->name($info['column_name']);
507
        $column->allowNull($info['is_nullable'] === 'YES');
508 108
        $column->dbType($info['data_type']);
509
        $column->enumValues([]); // mssql has only vague equivalents to enum
510
        $column->primaryKey(false); // primary key will be determined in findColumns() method
511
        $column->autoIncrement($info['is_identity'] === '1');
512
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
513
        $column->comment($info['comment'] ?? '');
514
        $column->type(self::TYPE_STRING);
515
516 108
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
517
            $type = $matches[1];
518 3
519 3
            if (isset($this->typeMap[$type])) {
520 3
                $column->type($this->typeMap[$type]);
521 3
            }
522 3
523
            if (!empty($matches[2])) {
524
                $values = explode(',', $matches[2]);
525
                $column->precision((int) $values[0]);
526 105
                $column->size((int) $values[0]);
527 105
528 105
                if (isset($values[1])) {
529
                    $column->scale((int) $values[1]);
530
                }
531
532
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
533
                    $column->type('boolean');
534
                } elseif ($type === 'bit') {
535
                    if ($column->getSize() > 32) {
536
                        $column->type('bigint');
537
                    } elseif ($column->getSize() === 32) {
538
                        $column->type('integer');
539 99
                    }
540
                }
541 99
            }
542
        }
543 99
544 99
        $column->phpType($this->getColumnPhpType($column));
545 99
546 99
        if ($info['column_default'] === '(NULL)') {
547 99
            $info['column_default'] = null;
548 99
        }
549 99
550 99
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
551 99
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
552
        }
553 99
554 99
        return $column;
555
    }
556 99
557 99
    /**
558
     * Collects the metadata of table columns.
559
     *
560 99
     * @param TableSchema $table the table metadata.
561 83
     *
562 83
     * @throws Throwable
563 83
     *
564
     * @return bool whether the table exists in the database.
565 83
     */
566
    protected function findColumns(TableSchema $table): bool
567
    {
568
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
569 83
        $whereSql = '[t1].[table_name] = ' . (string) $this->db->getQuoter()->quoteValue($table->getName());
570
571 83
        if ($table->getCatalogName() !== null) {
572
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
573
            $whereSql .= " AND [t1].[table_catalog] = '{$table->getCatalogName()}'";
574
        }
575
576
        if ($table->getSchemaName() !== null) {
577
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
578
        }
579
580
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
581 99
582
        $sql = <<<SQL
583 99
        SELECT
584 4
            [t1].[column_name],
585
            [t1].[is_nullable],
586
        CASE WHEN [t1].[data_type] IN ('char','varchar','nchar','nvarchar','binary','varbinary') THEN
587 99
        CASE WHEN [t1].[character_maximum_length] = NULL OR [t1].[character_maximum_length] = -1 THEN
588 99
            [t1].[data_type]
589
        ELSE
590
            [t1].[data_type] + '(' + LTRIM(RTRIM(CONVERT(CHAR,[t1].[character_maximum_length]))) + ')'
591 99
        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 108
        [t2].[name] = 'MS_Description' AND
604
        [t2].[major_id] = OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[table_name]) AND
605 108
        [t2].[minor_id] = COLUMNPROPERTY(OBJECT_ID([t1].[TABLE_SCHEMA] + '.' + [t1].[TABLE_NAME]), [t1].[COLUMN_NAME], 'ColumnID')
606 108
        ) as comment
607
        FROM $columnsTableName AS [t1]
608 108
        WHERE $whereSql
609
        SQL;
610
611
        try {
612
            /** @psalm-var ColumnArray[] */
613 108
            $columns = $this->db->createCommand($sql)->queryAll();
614 108
615
            if (empty($columns)) {
616
                return false;
617 108
            }
618
        } catch (Exception) {
619 108
            return false;
620
        }
621
622
        foreach ($columns as $column) {
623
            $column = $this->loadColumnSchema($column);
624
            foreach ($table->getPrimaryKey() as $primaryKey) {
625
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
626
                    $column->primaryKey(true);
627
                    break;
628
                }
629
            }
630
631
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
632
                $table->sequenceName('');
633
            }
634
635
            $table->columns($column->getName(), $column);
636
        }
637
638
        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 108
     */
651
    protected function findTableConstraints(TableSchema $table, string $type): array
652 108
    {
653 108
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
654
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
655
656
        if ($table->getCatalogName() !== null) {
657
            $keyColumnUsageTableName = $table->getCatalogName() . '.' . $keyColumnUsageTableName;
658
            $tableConstraintsTableName = $table->getCatalogName() . '.' . $tableConstraintsTableName;
659 99
        }
660 99
661 99
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
662 65
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
663 65
664 65
        $sql = <<<SQL
665
        SELECT
666
            [kcu].[constraint_name] AS [index_name],
667
            [kcu].[column_name] AS [field_name]
668 99
        FROM $keyColumnUsageTableName AS [kcu]
669 64
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
670
            [kcu].[table_schema] = [tc].[table_schema] AND
671
            [kcu].[table_name] = [tc].[table_name] AND
672 99
            [kcu].[constraint_name] = [tc].[constraint_name]
673
        WHERE
674
            [tc].[constraint_type] = :type AND
675 99
            [kcu].[table_name] = :tableName AND
676
            [kcu].[table_schema] = :schemaName
677
        SQL;
678
679
        return $this->db->createCommand(
680
            $sql,
681
            [
682
                ':tableName' => $table->getName(),
683
                ':schemaName' => $table->getSchemaName(),
684
                ':type' => $type,
685
            ]
686
        )->queryAll();
687
    }
688 108
689
    /**
690 108
     * Collects the primary key column details for the given table.
691 108
     *
692
     * @param TableSchema $table the table metadata
693 108
     *
694
     * @throws Exception|InvalidConfigException|Throwable
695
     */
696
    protected function findPrimaryKeys(TableSchema $table): void
697
    {
698 108
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
699 108
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
700
701 108
        foreach ($primaryKeys as $row) {
702
            $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
    protected function findForeignKeys(TableSchema $table): void
714
    {
715
        $catalogName = $table->getCatalogName();
716 108
        $fk = [];
717
        $object = $table->getName();
718
        $schemaName = $table->getSchemaName();
719 108
720 108
        if ($schemaName !== null) {
721
            $object = $schemaName . '.' . $object;
722
        }
723 108
724
        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
        $sql = <<<SQL
733 108
        SELECT
734
        [fk].[name] AS [fk_name],
735
        [cp].[name] AS [fk_column_name],
736 108
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
737
        [cr].[name] AS [uq_column_name]
738 108
        FROM [sys].[foreign_keys] AS [fk]
739 65
        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 99
         *   array-key,
751
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
752 99
         * > $rows
753 99
         */
754 99
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
755 99
        $table->foreignKeys([]);
756
757 99
        foreach ($rows as $row) {
758 99
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
759
                $fk[$row['fk_name']][] = $row['uq_table_name'];
760
                $table->foreignKeys($fk);
761 99
            }
762
763
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
764
            $table->foreignKeys($fk);
765
        }
766
    }
767
768
    /**
769 99
     * @throws Exception|InvalidConfigException|Throwable
770
     */
771
    public function findViewNames(string $schema = ''): array
772
    {
773
        if ($schema === '') {
774
            $schema = $this->defaultSchema;
775
        }
776
777
        $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
        $views = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
785
        if (!$views) {
786
            return [];
787
        }
788
789
        return array_map(static fn (string $item): string => '[' . $item . ']', $views);
790
    }
791 99
792 99
    /**
793
     * Returns all unique indexes for the given table.
794 99
     *
795 5
     * Each array element is of the following structure:
796 5
     *
797 5
     * ```php
798
     * [
799
     *     'IndexName1' => ['col1' [, ...]],
800 5
     *     'IndexName2' => ['col2' [, ...]],
801 5
     * ]
802
     * ```
803
     *
804
     * @param TableSchema $table the table metadata.
805
     *
806
     * @throws Exception|InvalidConfigException|Throwable
807
     *
808 3
     * @return array all unique indexes for the given table.
809
     */
810 3
    public function findUniqueIndexes(TableSchema $table): array
811 1
    {
812
        $result = [];
813
814 3
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
815
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
816
817
        foreach ($tableUniqueConstraints as $row) {
818
            $result[$row['index_name']][] = $row['field_name'];
819
        }
820
821 3
        return $result;
822 3
    }
823
824
    /**
825
     * Loads multiple types of constraints and returns the specified ones.
826 3
     *
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
    private function loadTableConstraints(string $tableName, string $returnType): mixed
840
    {
841
        $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 1
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
848
            [ffccol].[name] AS [foreign_column_name],
849 1
            [f].[update_referential_action_desc] AS [on_update],
850
            [f].[delete_referential_action_desc] AS [on_delete],
851
            [c].[definition] AS [check_expr],
852 1
            [d].[definition] AS [default_expr]
853
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
854 1
        INNER JOIN [sys].[objects] AS [o]
855 1
            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 1
        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 74
        LEFT JOIN [sys].[columns] AS [ffccol]
877
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
878 74
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
879
        SQL;
880
881
        $resolvedName = $this->resolveTableName($tableName);
882
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
883
884
        /** @psalm-var array[] $constraints */
885
        $constraints = $this->normalizeRowKeyCase($constraints, true);
886
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
887
888
        $result = [
889
            'primaryKey' => null,
890
            'foreignKeys' => [],
891
            'uniques' => [],
892
            'checks' => [],
893
            'defaults' => [],
894
        ];
895
896
        /** @psalm-var array<array-key, array> $constraints */
897
        foreach ($constraints as $type => $names) {
898
            /**
899
             * @psalm-var object|string|null $name
900
             * @psalm-var ConstraintArray $constraint
901
             */
902
            foreach ($names as $name => $constraint) {
903
                switch ($type) {
904
                    case 'PK':
905
                        /** @var Constraint */
906
                        $result['primaryKey'] = (new Constraint())
907
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
908
                            ->name($name);
909
                        break;
910
                    case 'F':
911
                        $result['foreignKeys'][] = (new ForeignKeyConstraint())
912
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
913
                            ->foreignTableName($constraint[0]['foreign_table_name'])
914
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
915
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
916
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
917
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
918 74
                            ->name($name);
919 74
                        break;
920
                    case 'UQ':
921
                        $result['uniques'][] = (new Constraint())
922 74
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
923 74
                            ->name($name);
924
                        break;
925 74
                    case 'C':
926
                        $result['checks'][] = (new CheckConstraint())
927
                            ->expression($constraint[0]['check_expr'])
928
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
929
                            ->name($name);
930
                        break;
931
                    case 'D':
932
                        $result['defaults'][] = (new DefaultValueConstraint())
933
                            ->value($constraint[0]['default_expr'])
934 74
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
935
                            ->name($name);
936
                        break;
937
                }
938
            }
939 74
        }
940 74
941 74
        foreach ($result as $type => $data) {
942
            $this->setTableMetadata($tableName, $type, $data);
943 53
        }
944 53
945 53
        return $result[$returnType];
946 53
    }
947 74
948 16
    /**
949 16
     * Create a column schema builder instance giving the type and value precision.
950 16
     *
951 16
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
952 16
     *
953 16
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
954 16
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
955 16
     *
956 16
     * @return ColumnSchemaBuilder column schema builder instance
957 59
     *
958 57
     * @psalm-param array<array-key, string>|int|null|string $length
959 57
     */
960 57
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
961 57
    {
962 31
        return new ColumnSchemaBuilder($type, $length);
963 13
    }
964 13
965 13
    /**
966 13
     * Returns the actual name of a given table name.
967 13
     *
968 30
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
969 30
     * {@see ConnectionInterface::tablePrefix}.
970 30
     *
971 30
     * @param string $name the table name to be converted.
972 30
     *
973 30
     * @return string the real name of the given table name.
974
     */
975
    public function getRawTableName(string $name): string
976
    {
977
        if (str_contains($name, '{{')) {
978 74
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
979 74
980
            return str_replace('%', $this->db->getTablePrefix(), $name);
981
        }
982 74
983
        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
    protected function getCacheKey(string $name): array
994
    {
995
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
996
    }
997 3
998
    /**
999 3
     * Returns the cache tag name.
1000
     *
1001
     * This allows {@see refresh()} to invalidate all cached table schemas.
1002 2
     *
1003
     * @return string the cache tag name.
1004 2
     */
1005
    protected function getCacheTag(): string
1006
    {
1007
        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
    public function supportsSavepoint(): bool
1014
    {
1015
        return $this->db->isSavepointEnabled();
1016
    }
1017 168
1018
    /**
1019 168
     * Changes row's array key case to lower.
1020 24
     *
1021
     * @param array $row row's array or an array of row's arrays.
1022 24
     * @param bool $multiple whether multiple rows or a single row passed.
1023
     *
1024
     * @return array normalized row or rows.
1025 168
     */
1026
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
1027
    {
1028
        if ($multiple) {
1029
            return array_map(static function (array $row) {
1030
                return array_change_key_case($row, CASE_LOWER);
1031
            }, $row);
1032
        }
1033
1034
        return array_change_key_case($row, CASE_LOWER);
1035 168
    }
1036
1037 168
    /**
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 168
     */
1048
    public function getViewNames(string $schema = '', bool $refresh = false): array
1049 168
    {
1050
        if ($this->viewNames === [] || $refresh) {
1051
            $this->viewNames[$schema] = $this->findViewNames($schema);
1052
        }
1053
1054
        return is_array($this->viewNames[$schema]) ? $this->viewNames[$schema] : [];
1055 2
    }
1056
}
1057