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