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