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