Passed
Branch dev (72918d)
by Wilmer
03:30
created

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