Passed
Push — master ( 9b125d...f5b0c0 )
by Alexander
11:25 queued 07:35
created

Schema::findForeignKeys()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 52
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 5.0042

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 17
cts 18
cp 0.9444
crap 5.0042
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\ColumnSchemaInterface;
20
use Yiisoft\Db\Schema\Schema as AbstractSchema;
21
use Yiisoft\Db\Schema\TableSchemaInterface;
22
23
use function array_change_key_case;
24
use function array_map;
25
use function count;
26
use function explode;
27
use function is_array;
28
use function md5;
29
use function preg_match;
30
use function preg_match_all;
31
use function preg_replace;
32
use function serialize;
33
use function str_contains;
34
use function str_replace;
35
use function strcasecmp;
36
use function stripos;
37
38
/**
39
 * Schema is the class for retrieving metadata from MS SQL Server databases (version 2008 and above).
40
 *
41
 * @psalm-type ColumnArray = array{
42
 *   column_name: string,
43
 *   is_nullable: string,
44
 *   data_type: string,
45
 *   column_default: mixed,
46
 *   is_identity: string,
47
 *   comment: null|string
48
 * }
49
 *
50
 * @psalm-type ConstraintArray = array<
51
 *   array-key,
52
 *   array {
53
 *     name: string,
54
 *     column_name: string,
55
 *     type: string,
56
 *     foreign_table_schema: string|null,
57
 *     foreign_table_name: string|null,
58
 *     foreign_column_name: string|null,
59
 *     on_update: string,
60
 *     on_delete: string,
61
 *     check_expr: string,
62
 *     default_expr: string
63
 *   }
64
 * >
65
 */
66
final class Schema extends AbstractSchema
67
{
68
    public const DEFAULTS = 'defaults';
69
70
    /**
71
     * @var string|null the default schema used for the current session.
72
     */
73
    protected ?string $defaultSchema = 'dbo';
74
75
    /**
76
     * @var array mapping from physical column types (keys) to abstract column types (values)
77
     *
78
     * @psalm-var string[]
79
     */
80
    private array $typeMap = [
81
        /** exact numbers */
82
        'bigint' => self::TYPE_BIGINT,
83
        'numeric' => self::TYPE_DECIMAL,
84
        'bit' => self::TYPE_SMALLINT,
85
        'smallint' => self::TYPE_SMALLINT,
86
        'decimal' => self::TYPE_DECIMAL,
87
        'smallmoney' => self::TYPE_MONEY,
88
        'int' => self::TYPE_INTEGER,
89
        'tinyint' => self::TYPE_TINYINT,
90
        'money' => self::TYPE_MONEY,
91
92
        /** approximate numbers */
93
        'float' => self::TYPE_FLOAT,
94
        'double' => self::TYPE_DOUBLE,
95
        'real' => self::TYPE_FLOAT,
96
97
        /** date and time */
98
        'date' => self::TYPE_DATE,
99
        'datetimeoffset' => self::TYPE_DATETIME,
100
        'datetime2' => self::TYPE_DATETIME,
101
        'smalldatetime' => self::TYPE_DATETIME,
102
        'datetime' => self::TYPE_DATETIME,
103
        'time' => self::TYPE_TIME,
104
105
        /** character strings */
106
        'char' => self::TYPE_CHAR,
107
        'varchar' => self::TYPE_STRING,
108
        'text' => self::TYPE_TEXT,
109
110
        /** unicode character strings */
111
        'nchar' => self::TYPE_CHAR,
112
        'nvarchar' => self::TYPE_STRING,
113
        'ntext' => self::TYPE_TEXT,
114
115
        /** binary strings */
116
        'binary' => self::TYPE_BINARY,
117
        'varbinary' => self::TYPE_BINARY,
118
        'image' => self::TYPE_BINARY,
119
120
        /**
121
         * other data types 'cursor' type cannot be used with tables
122
         */
123
        'timestamp' => self::TYPE_TIMESTAMP,
124
        'hierarchyid' => self::TYPE_STRING,
125
        'uniqueidentifier' => self::TYPE_STRING,
126
        'sql_variant' => self::TYPE_STRING,
127
        'xml' => self::TYPE_STRING,
128
        'table' => self::TYPE_STRING,
129
    ];
130
131 389
    public function __construct(private ConnectionInterface $db, SchemaCache $schemaCache)
132
    {
133 389
        parent::__construct($schemaCache);
134
    }
135
136
    /**
137
     * Resolves the table name and schema name (if any).
138
     *
139
     * @param string $name the table name.
140
     *
141
     * @return TableSchemaInterface resolved table, schema, etc. names.
142
     *
143
     * @todo Review this method and see if it can be simplified @darkdef.
144
     * also see case `wrongBehaviour` in \Yiisoft\Db\TestSupport\TestCommandTrait::batchInsertSqlProviderTrait
145
     */
146 90
    protected function resolveTableName(string $name): TableSchemaInterface
147
    {
148 90
        $resolvedName = new TableSchema();
149 90
        $parts = $this->getTableNameParts($name);
150 90
        $partCount = count($parts);
151
152 90
        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 90
        } 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 90
        } 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 90
            $resolvedName->schemaName($this->defaultSchema);
185 90
            $resolvedName->name($parts[0]);
186 90
            $resolvedName->fullName($resolvedName->getName());
187
        }
188
189 90
        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 179
    protected function getTableNameParts(string $name): array
202
    {
203 179
        $parts = [$name];
204
205 179
        preg_match_all('/([^.\[\]]+)|\[([^\[\]]+)]/', $name, $matches);
206
207 179
        if (isset($matches[0]) && !empty($matches[0])) {
208 179
            $parts = $matches[0];
209
        }
210
211
        /** @psalm-var string[] */
212 179
        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 11
    protected function findTableNames(string $schema = ''): array
258
    {
259 11
        if ($schema === '') {
260 11
            $schema = $this->defaultSchema;
261
        }
262
263 11
        $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 11
        $tables = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
271 11
        if (!$tables) {
272
            return [];
273
        }
274
275 11
        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 TableSchemaInterface|null DBMS-dependent table metadata, `null` if the table does not exist.
286
     */
287 113
    protected function loadTableSchema(string $name): ?TableSchemaInterface
288
    {
289 113
        $table = new TableSchema();
290
291 113
        $this->resolveTableNames($table, $name);
292 113
        $this->findPrimaryKeys($table);
293
294 113
        if ($this->findColumns($table)) {
295 104
            $this->findForeignKeys($table);
296 104
            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 32
    protected function loadTablePrimaryKey(string $tableName): ?Constraint
312
    {
313
        /** @var mixed */
314 32
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
315 32
        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 5
    protected function loadTableForeignKeys(string $tableName): array
328
    {
329
        /** @var mixed */
330 5
        $tableForeingKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
331 5
        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 30
    protected function loadTableIndexes(string $tableName): array
344
    {
345 30
        $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 30
        $resolvedName = $this->resolveTableName($tableName);
361 30
        $indexes = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
362
363
        /** @psalm-var array[] $indexes */
364 30
        $indexes = $this->normalizeRowKeyCase($indexes, true);
365 30
        $indexes = ArrayHelper::index($indexes, null, 'name');
366
367 30
        $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 30
        foreach ($indexes as $name => $index) {
379 27
            $result[] = (new IndexConstraint())
380 27
                ->primary((bool) $index[0]['index_is_primary'])
381 27
                ->unique((bool) $index[0]['index_is_unique'])
382 27
                ->columnNames(ArrayHelper::getColumn($index, 'column_name'))
383 27
                ->name($name);
384
        }
385
386 30
        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 14
    protected function loadTableUniques(string $tableName): array
399
    {
400
        /** @var mixed */
401 14
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
402 14
        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 14
    protected function loadTableChecks(string $tableName): array
415
    {
416
        /** @var mixed */
417 14
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
418 14
        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 14
    protected function loadTableDefaultValues(string $tableName): array
431
    {
432
        /** @var mixed */
433 14
        $tableDefault = $this->loadTableConstraints($tableName, self::DEFAULTS);
434 14
        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 104
    protected function createColumnSchema(): ColumnSchema
445
    {
446 104
        return new ColumnSchema();
447
    }
448
449
    /**
450
     * Resolves the table name and schema name (if any).
451
     *
452
     * @param TableSchemaInterface $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 113
    protected function resolveTableNames(TableSchemaInterface $table, string $name): void
458
    {
459 113
        $parts = $this->getTableNameParts($name);
460
461 113
        $partCount = count($parts);
462
463 113
        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 113
        } 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 113
        } 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 110
            $table->schemaName($this->defaultSchema);
490 110
            $table->name($parts[0]);
491 110
            $table->fullName($table->getName());
492
        }
493
    }
494
495
    /**
496
     * Loads the column information into a {@see ColumnSchemaInterface} object.
497
     *
498
     * @psalm-param ColumnArray $info The column information.
499
     *
500
     * @return ColumnSchemaInterface the column schema object.
501
     */
502 104
    protected function loadColumnSchema(array $info): ColumnSchemaInterface
503
    {
504 104
        $column = $this->createColumnSchema();
505
506 104
        $column->name($info['column_name']);
507 104
        $column->allowNull($info['is_nullable'] === 'YES');
508 104
        $column->dbType($info['data_type']);
509 104
        $column->enumValues([]); // mssql has only vague equivalents to enum
510 104
        $column->primaryKey(false); // primary key will be determined in findColumns() method
511 104
        $column->autoIncrement($info['is_identity'] === '1');
512 104
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
513 104
        $column->comment($info['comment'] ?? '');
514 104
        $column->type(self::TYPE_STRING);
515
516 104
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
517 104
            $type = $matches[1];
518
519 104
            if (isset($this->typeMap[$type])) {
520 104
                $column->type($this->typeMap[$type]);
521
            }
522
523 104
            if (!empty($matches[2])) {
524 87
                $values = explode(',', $matches[2]);
525 87
                $column->precision((int) $values[0]);
526 87
                $column->size((int) $values[0]);
527
528 87
                if (isset($values[1])) {
529
                    $column->scale((int) $values[1]);
530
                }
531
532 87
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
533
                    $column->type(self::TYPE_BOOLEAN);
534 87
                } elseif ($type === 'bit') {
535
                    if ($column->getSize() > 32) {
536
                        $column->type(self::TYPE_BIGINT);
537
                    } elseif ($column->getSize() === 32) {
538
                        $column->type(self::TYPE_INTEGER);
539
                    }
540
                }
541
            }
542
        }
543
544 104
        $column->phpType($this->getColumnPhpType($column));
545
546 104
        if ($info['column_default'] === '(NULL)') {
547 4
            $info['column_default'] = null;
548
        }
549
550 104
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
551 104
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
552
        }
553
554 104
        return $column;
555
    }
556
557
    /**
558
     * Collects the metadata of table columns.
559
     *
560
     * @param TableSchemaInterface $table the table metadata.
561
     *
562
     * @throws Throwable
563
     *
564
     * @return bool whether the table exists in the database.
565
     */
566 113
    protected function findColumns(TableSchemaInterface $table): bool
567
    {
568 113
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
569 113
        $whereSql = '[t1].[table_name] = ' . (string) $this->db->getQuoter()->quoteValue($table->getName());
570
571 113
        if ($table->getCatalogName() !== null) {
572
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
573
            $whereSql .= " AND [t1].[table_catalog] = '{$table->getCatalogName()}'";
574
        }
575
576 113
        if ($table->getSchemaName() !== null) {
577 113
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
578
        }
579
580 113
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
581
582 113
        $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 113
            $columns = $this->db->createCommand($sql)->queryAll();
614
615 113
            if (empty($columns)) {
616 113
                return false;
617
            }
618
        } catch (Exception) {
619
            return false;
620
        }
621
622 104
        foreach ($columns as $column) {
623 104
            $column = $this->loadColumnSchema($column);
624 104
            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 104
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
632 67
                $table->sequenceName('');
633
            }
634
635 104
            $table->columns($column->getName(), $column);
636
        }
637
638 104
        return true;
639
    }
640
641
    /**
642
     * Collects the constraint details for the given table and constraint type.
643
     *
644
     * @param TableSchemaInterface $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 113
    protected function findTableConstraints(TableSchemaInterface $table, string $type): array
652
    {
653 113
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
654 113
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
655
656 113
        $catalogName = $table->getCatalogName();
657 113
        if ($catalogName !== null) {
658
            $keyColumnUsageTableName = $catalogName . '.' . $keyColumnUsageTableName;
659
            $tableConstraintsTableName = $catalogName . '.' . $tableConstraintsTableName;
660
        }
661
662 113
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
663 113
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
664
665 113
        $sql = <<<SQL
666
        SELECT
667
            [kcu].[constraint_name] AS [index_name],
668
            [kcu].[column_name] AS [field_name]
669
        FROM $keyColumnUsageTableName AS [kcu]
670
        LEFT JOIN $tableConstraintsTableName AS [tc] ON
671
            [kcu].[table_schema] = [tc].[table_schema] AND
672
            [kcu].[table_name] = [tc].[table_name] AND
673
            [kcu].[constraint_name] = [tc].[constraint_name]
674
        WHERE
675
            [tc].[constraint_type] = :type AND
676
            [kcu].[table_name] = :tableName AND
677
            [kcu].[table_schema] = :schemaName
678
        SQL;
679
680 113
        return $this->db->createCommand(
681
            $sql,
682
            [
683 113
                ':tableName' => $table->getName(),
684 113
                ':schemaName' => $table->getSchemaName(),
685
                ':type' => $type,
686
            ]
687 113
        )->queryAll();
688
    }
689
690
    /**
691
     * Collects the primary key column details for the given table.
692
     *
693
     * @param TableSchemaInterface $table the table metadata
694
     *
695
     * @throws Exception|InvalidConfigException|Throwable
696
     */
697 113
    protected function findPrimaryKeys(TableSchemaInterface $table): void
698
    {
699
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
700 113
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
701
702 113
        foreach ($primaryKeys as $row) {
703 68
            $table->primaryKey($row['field_name']);
704
        }
705
    }
706
707
    /**
708
     * Collects the foreign key column details for the given table.
709
     *
710
     * @param TableSchemaInterface $table the table metadata
711
     *
712
     * @throws Exception|InvalidConfigException|Throwable
713
     */
714 104
    protected function findForeignKeys(TableSchemaInterface $table): void
715
    {
716 104
        $catalogName = $table->getCatalogName();
717 104
        $fk = [];
718 104
        $object = $table->getName();
719 104
        $schemaName = $table->getSchemaName();
720
721 104
        if ($schemaName !== null) {
722 104
            $object = $schemaName . '.' . $object;
723
        }
724
725 104
        if ($catalogName !== null) {
726
            $object = $catalogName . '.' . $object;
727
        }
728
729
        /**
730
         * Please refer to the following page for more details:
731
         * {@see http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx}
732
         */
733 104
        $sql = <<<SQL
734
        SELECT
735
        [fk].[name] AS [fk_name],
736
        [cp].[name] AS [fk_column_name],
737
        OBJECT_NAME([fk].[referenced_object_id]) AS [uq_table_name],
738
        [cr].[name] AS [uq_column_name]
739
        FROM [sys].[foreign_keys] AS [fk]
740
        INNER JOIN [sys].[foreign_key_columns] AS [fkc]
741
            ON [fk].[object_id] = [fkc].[constraint_object_id]
742
        INNER JOIN [sys].[columns] AS [cp]
743
            ON [fk].[parent_object_id] = [cp].[object_id] AND [fkc].[parent_column_id] = [cp].[column_id]
744
        INNER JOIN [sys].[columns] AS [cr]
745
            ON [fk].[referenced_object_id] = [cr].[object_id] AND [fkc].[referenced_column_id] = [cr].[column_id]
746
        WHERE [fk].[parent_object_id] = OBJECT_ID(:object)
747
        SQL;
748
749
        /**
750
         * @psalm-var array<
751
         *   array-key,
752
         *   array{fk_name: string, fk_column_name: string, uq_table_name: string, uq_column_name: string}
753
         * > $rows
754
         */
755 104
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
756 104
        $table->foreignKeys([]);
757
758 104
        foreach ($rows as $row) {
759 6
            if (!isset($table->getForeignKeys()[$row['fk_name']])) {
760 6
                $fk[$row['fk_name']][] = $row['uq_table_name'];
761 6
                $table->foreignKeys($fk);
762
            }
763
764 6
            $fk[$row['fk_name']][$row['fk_column_name']] = $row['uq_column_name'];
765 6
            $table->foreignKeys($fk);
766
        }
767
    }
768
769
    /**
770
     * @throws Exception|InvalidConfigException|Throwable
771
     */
772 3
    protected function findViewNames(string $schema = ''): array
773
    {
774 3
        if ($schema === '') {
775 1
            $schema = $this->defaultSchema;
776
        }
777
778 3
        $sql = <<<SQL
779
        SELECT [t].[table_name]
780
        FROM [INFORMATION_SCHEMA].[TABLES] AS [t]
781
        WHERE [t].[table_schema] = :schema AND [t].[table_type] = 'VIEW'
782
        ORDER BY [t].[table_name]
783
        SQL;
784
785 3
        $views = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
786 3
        if (!$views) {
787
            return [];
788
        }
789
790 3
        return array_map(static fn (string $item): string => '[' . $item . ']', $views);
791
    }
792
793
    /**
794
     * Returns all unique indexes for the given table.
795
     *
796
     * Each array element is of the following structure:
797
     *
798
     * ```php
799
     * [
800
     *     'IndexName1' => ['col1' [, ...]],
801
     *     'IndexName2' => ['col2' [, ...]],
802
     * ]
803
     * ```
804
     *
805
     * @param TableSchemaInterface $table the table metadata.
806
     *
807
     * @throws Exception|InvalidConfigException|Throwable
808
     *
809
     * @return array all unique indexes for the given table.
810
     */
811 1
    public function findUniqueIndexes(TableSchemaInterface $table): array
812
    {
813 1
        $result = [];
814
815
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
816 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
817
818 1
        foreach ($tableUniqueConstraints as $row) {
819 1
            $result[$row['index_name']][] = $row['field_name'];
820
        }
821
822 1
        return $result;
823
    }
824
825
    /**
826
     * Loads multiple types of constraints and returns the specified ones.
827
     *
828
     * @param string $tableName table name.
829
     * @param string $returnType return type:
830
     * - primaryKey
831
     * - foreignKeys
832
     * - uniques
833
     * - checks
834
     * - defaults
835
     *
836
     * @throws Exception|InvalidConfigException|Throwable
837
     *
838
     * @return mixed constraints.
839
     */
840 79
    private function loadTableConstraints(string $tableName, string $returnType): mixed
841
    {
842 79
        $sql = <<<SQL
843
        SELECT
844
            [o].[name] AS [name],
845
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
846
            RTRIM([o].[type]) AS [type],
847
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
848
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
849
            [ffccol].[name] AS [foreign_column_name],
850
            [f].[update_referential_action_desc] AS [on_update],
851
            [f].[delete_referential_action_desc] AS [on_delete],
852
            [c].[definition] AS [check_expr],
853
            [d].[definition] AS [default_expr]
854
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
855
        INNER JOIN [sys].[objects] AS [o]
856
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
857
        LEFT JOIN [sys].[check_constraints] AS [c]
858
            ON [c].[object_id] = [o].[object_id]
859
        LEFT JOIN [sys].[columns] AS [ccol]
860
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
861
        LEFT JOIN [sys].[default_constraints] AS [d]
862
            ON [d].[object_id] = [o].[object_id]
863
        LEFT JOIN [sys].[columns] AS [dcol]
864
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
865
        LEFT JOIN [sys].[key_constraints] AS [k]
866
            ON [k].[object_id] = [o].[object_id]
867
        LEFT JOIN [sys].[index_columns] AS [kic]
868
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
869
        LEFT JOIN [sys].[columns] AS [kiccol]
870
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
871
        LEFT JOIN [sys].[foreign_keys] AS [f]
872
            ON [f].[object_id] = [o].[object_id]
873
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
874
            ON [fc].[constraint_object_id] = [o].[object_id]
875
        LEFT JOIN [sys].[columns] AS [fccol]
876
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
877
        LEFT JOIN [sys].[columns] AS [ffccol]
878
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
879
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
880
        SQL;
881
882 79
        $resolvedName = $this->resolveTableName($tableName);
883 79
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
884
885
        /** @psalm-var array[] $constraints */
886 79
        $constraints = $this->normalizeRowKeyCase($constraints, true);
887 79
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
888
889 79
        $result = [
890
            self::PRIMARY_KEY => null,
891 79
            self::FOREIGN_KEYS => [],
892 79
            self::UNIQUES => [],
893 79
            self::CHECKS => [],
894 79
            self::DEFAULTS => [],
895
        ];
896
897
        /** @psalm-var array<array-key, array> $constraints */
898 79
        foreach ($constraints as $type => $names) {
899
            /**
900
             * @psalm-var object|string|null $name
901
             * @psalm-var ConstraintArray $constraint
902
             */
903 79
            foreach ($names as $name => $constraint) {
904 79
                switch ($type) {
905 79
                    case 'PK':
906
                        /** @var Constraint */
907 58
                        $result[self::PRIMARY_KEY] = (new Constraint())
908 58
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
909 58
                            ->name($name);
910 58
                        break;
911 79
                    case 'F':
912 21
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
913 21
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
914 21
                            ->foreignTableName($constraint[0]['foreign_table_name'])
915 21
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
916 21
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
917 21
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
918 21
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
919 21
                            ->name($name);
920 21
                        break;
921 64
                    case 'UQ':
922 62
                        $result[self::UNIQUES][] = (new Constraint())
923 62
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
924 62
                            ->name($name);
925 62
                        break;
926 36
                    case 'C':
927 18
                        $result[self::CHECKS][] = (new CheckConstraint())
928 18
                            ->expression($constraint[0]['check_expr'])
929 18
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
930 18
                            ->name($name);
931 18
                        break;
932 35
                    case 'D':
933 35
                        $result[self::DEFAULTS][] = (new DefaultValueConstraint())
934 35
                            ->value($constraint[0]['default_expr'])
935 35
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
936 35
                            ->name($name);
937 35
                        break;
938
                }
939
            }
940
        }
941
942 79
        foreach ($result as $type => $data) {
943 79
            $this->setTableMetadata($tableName, $type, $data);
944
        }
945
946 79
        return $result[$returnType];
947
    }
948
949
    /**
950
     * Create a column schema builder instance giving the type and value precision.
951
     *
952
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
953
     *
954
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
955
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
956
     *
957
     * @return ColumnSchemaBuilder column schema builder instance
958
     *
959
     * @psalm-param array<array-key, string>|int|null|string $length
960
     */
961 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
962
    {
963 3
        return new ColumnSchemaBuilder($type, $length);
964
    }
965
966
    /**
967
     * Returns the actual name of a given table name.
968
     *
969
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
970
     * {@see ConnectionInterface::tablePrefix}.
971
     *
972
     * @param string $name the table name to be converted.
973
     *
974
     * @return string the real name of the given table name.
975
     */
976 179
    public function getRawTableName(string $name): string
977
    {
978 179
        if (str_contains($name, '{{')) {
979 24
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
980
981 24
            return str_replace('%', $this->db->getTablePrefix(), $name);
982
        }
983
984 179
        return $name;
985
    }
986
987
    /**
988
     * Returns the cache key for the specified table name.
989
     *
990
     * @param string $name the table name.
991
     *
992
     * @return array the cache key.
993
     */
994 179
    protected function getCacheKey(string $name): array
995
    {
996 179
        return array_merge([__CLASS__], $this->db->getCacheKey(), [$this->getRawTableName($name)]);
997
    }
998
999
    /**
1000
     * Returns the cache tag name.
1001
     *
1002
     * This allows {@see refresh()} to invalidate all cached table schemas.
1003
     *
1004
     * @return string the cache tag name.
1005
     */
1006 179
    protected function getCacheTag(): string
1007
    {
1008 179
        return md5(serialize(array_merge([__CLASS__], $this->db->getCacheKey())));
1009
    }
1010
1011
    /**
1012
     * @return bool whether this DBMS supports [savepoint](http://en.wikipedia.org/wiki/Savepoint).
1013
     */
1014 5
    public function supportsSavepoint(): bool
1015
    {
1016 5
        return $this->db->isSavepointEnabled();
1017
    }
1018
1019
    /**
1020
     * Changes row's array key case to lower.
1021
     *
1022
     * @param array $row row's array or an array of row's arrays.
1023
     * @param bool $multiple whether multiple rows or a single row passed.
1024
     *
1025
     * @return array normalized row or rows.
1026
     */
1027 90
    protected function normalizeRowKeyCase(array $row, bool $multiple): array
1028
    {
1029 90
        if ($multiple) {
1030 90
            return array_map(static function (array $row) {
1031 87
                return array_change_key_case($row, CASE_LOWER);
1032
            }, $row);
1033
        }
1034
1035
        return array_change_key_case($row, CASE_LOWER);
1036
    }
1037
1038
    /**
1039
     * @inheritDoc
1040
     */
1041
    public function getLastInsertID(?string $sequenceName = null): string
1042
    {
1043
        return $this->db->getLastInsertID($sequenceName);
1044
    }
1045
}
1046