Passed
Branch dev (7c494b)
by Wilmer
04:53
created

SchemaPDOMssql::findColumns()   B

Complexity

Conditions 10
Paths 36

Size

Total Lines 73
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 10.2918

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 10
eloc 52
c 1
b 0
f 0
nc 36
nop 1
dl 0
loc 73
ccs 24
cts 28
cp 0.8571
crap 10.2918
rs 7.1806

How to fix   Long Method    Complexity   

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 366
    public function __construct(private ConnectionPDOInterface $db, SchemaCache $schemaCache)
132
    {
133 366
        parent::__construct($schemaCache);
134 366
    }
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 158
    protected function getTableNameParts(string $name): array
201
    {
202 158
        $parts = [$name];
203
204 158
        preg_match_all('/([^.\[\]]+)|\[([^\[\]]+)]/', $name, $matches);
205
206 158
        if (isset($matches[0]) && !empty($matches[0])) {
207 158
            $parts = $matches[0];
208
        }
209
210
        /** @psalm-var string[] */
211 158
        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 98
    protected function loadTableSchema(string $name): ?TableSchema
279
    {
280 98
        $table = new TableSchema();
281
282 98
        $this->resolveTableNames($table, $name);
283 98
        $this->findPrimaryKeys($table);
284
285 98
        if ($this->findColumns($table)) {
286 92
            $this->findForeignKeys($table);
287 92
            return $table;
288
        }
289
290 16
        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 1
    }
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 1
    }
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 92
    protected function createColumnSchema(): ColumnSchema
470
    {
471 92
        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 98
    protected function resolveTableNames(TableSchema $table, string $name): void
483
    {
484 98
        $parts = $this->getTableNameParts($name);
485
486 98
        $partCount = count($parts);
487
488 98
        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 98
        } 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 98
        } 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 95
            $table->schemaName($this->defaultSchema);
515 95
            $table->name($parts[0]);
516 95
            $table->fullName($table->getName());
517
        }
518 98
    }
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 92
    protected function loadColumnSchema(array $info): ColumnSchema
528
    {
529 92
        $column = $this->createColumnSchema();
530
531 92
        $column->name($info['column_name']);
532 92
        $column->allowNull($info['is_nullable'] === 'YES');
533 92
        $column->dbType($info['data_type']);
534 92
        $column->enumValues([]); // mssql has only vague equivalents to enum
535 92
        $column->primaryKey(false); // primary key will be determined in findColumns() method
536 92
        $column->autoIncrement($info['is_identity'] === '1');
537 92
        $column->unsigned(stripos($column->getDbType(), 'unsigned') !== false);
538 92
        $column->comment($info['comment'] ?? '');
539 92
        $column->type(self::TYPE_STRING);
540
541 92
        if (preg_match('/^(\w+)(?:\(([^)]+)\))?/', $column->getDbType(), $matches)) {
542 92
            $type = $matches[1];
543
544 92
            if (isset($this->typeMap[$type])) {
545 92
                $column->type($this->typeMap[$type]);
546
            }
547
548 92
            if (!empty($matches[2])) {
549 76
                $values = explode(',', $matches[2]);
550 76
                $column->precision((int) $values[0]);
551 76
                $column->size((int) $values[0]);
552
553 76
                if (isset($values[1])) {
554
                    $column->scale((int) $values[1]);
555
                }
556
557 76
                if ($column->getSize() === 1 && ($type === 'tinyint' || $type === 'bit')) {
558
                    $column->type('boolean');
559 76
                } 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 92
        $column->phpType($this->getColumnPhpType($column));
570
571 92
        if ($info['column_default'] === '(NULL)') {
572 4
            $info['column_default'] = null;
573
        }
574
575 92
        if (!$column->isPrimaryKey() && ($column->getType() !== 'timestamp' || $info['column_default'] !== 'CURRENT_TIMESTAMP')) {
576 92
            $column->defaultValue($column->defaultPhpTypecast($info['column_default']));
577
        }
578
579 92
        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 98
    protected function findColumns(TableSchema $table): bool
592
    {
593 98
        $columnsTableName = 'INFORMATION_SCHEMA.COLUMNS';
594 98
        $whereSql = '[t1].[table_name] = ' . (string) $this->db->getQuoter()->quoteValue($table->getName());
595
596 98
        if ($table->getCatalogName() !== null) {
597
            $columnsTableName = "{$table->getCatalogName()}.$columnsTableName";
598
            $whereSql .= " AND [t1].[table_catalog] = '{$table->getCatalogName()}'";
599
        }
600
601 98
        if ($table->getSchemaName() !== null) {
602 98
            $whereSql .= " AND [t1].[table_schema] = '{$table->getSchemaName()}'";
603
        }
604
605 98
        $columnsTableName = $this->db->getQuoter()->quoteTableName($columnsTableName);
606
607 98
        $sql = <<<SQL
608 98
        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 98
        FROM $columnsTableName AS [t1]
633 98
        WHERE $whereSql
634
        SQL;
635
636
        try {
637
            /** @psalm-var ColumnArray[] */
638 98
            $columns = $this->db->createCommand($sql)->queryAll();
639
640 98
            if (empty($columns)) {
641 98
                return false;
642
            }
643
        } catch (Exception $e) {
644
            return false;
645
        }
646
647 92
        foreach ($columns as $column) {
648 92
            $column = $this->loadColumnSchema($column);
649 92
            foreach ($table->getPrimaryKey() as $primaryKey) {
650 59
                if (strcasecmp($column->getName(), $primaryKey) === 0) {
651 59
                    $column->primaryKey(true);
652 59
                    break;
653
                }
654
            }
655
656 92
            if ($column->isPrimaryKey() && $column->isAutoIncrement()) {
657 58
                $table->sequenceName('');
658
            }
659
660 92
            $table->columns($column->getName(), $column);
661
        }
662
663 92
        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 98
    protected function findTableConstraints(TableSchema $table, string $type): array
677
    {
678 98
        $keyColumnUsageTableName = 'INFORMATION_SCHEMA.KEY_COLUMN_USAGE';
679 98
        $tableConstraintsTableName = 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS';
680
681 98
        if ($table->getCatalogName() !== null) {
682
            $keyColumnUsageTableName = $table->getCatalogName() . '.' . $keyColumnUsageTableName;
683
            $tableConstraintsTableName = $table->getCatalogName() . '.' . $tableConstraintsTableName;
684
        }
685
686 98
        $keyColumnUsageTableName = $this->db->getQuoter()->quoteTableName($keyColumnUsageTableName);
687 98
        $tableConstraintsTableName = $this->db->getQuoter()->quoteTableName($tableConstraintsTableName);
688
689 98
        $sql = <<<SQL
690 98
        SELECT
691
            [kcu].[constraint_name] AS [index_name],
692
            [kcu].[column_name] AS [field_name]
693 98
        FROM $keyColumnUsageTableName AS [kcu]
694 98
        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 98
        return $this->db->createCommand(
705 98
            $sql,
706
            [
707 98
                ':tableName' => $table->getName(),
708 98
                ':schemaName' => $table->getSchemaName(),
709 98
                ':type' => $type,
710
            ]
711 98
        )->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 98
    protected function findPrimaryKeys(TableSchema $table): void
722
    {
723
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $primaryKeys */
724 98
        $primaryKeys = $this->findTableConstraints($table, 'PRIMARY KEY');
725
726 98
        foreach ($primaryKeys as $row) {
727 59
            $table->primaryKey($row['field_name']);
728
        }
729 98
    }
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 92
    protected function findForeignKeys(TableSchema $table): void
739
    {
740 92
        $catalogName = $table->getCatalogName();
741 92
        $fk = [];
742 92
        $object = $table->getName();
743 92
        $schemaName = $table->getSchemaName();
744
745 92
        if ($schemaName !== null) {
746 92
            $object = $schemaName . '.' . $object;
747
        }
748
749 92
        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 92
        $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 92
        $rows = $this->db->createCommand($sql, [':object' => $object])->queryAll();
780 92
        $table->foreignKeys([]);
781
782 92
        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 92
    }
792
793 2
    public function findViewNames(string $schema = ''): array
794
    {
795 2
        if ($schema === '') {
796 1
            $schema = $this->defaultSchema;
797
        }
798
799 2
        $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 2
        $views = $this->db->createCommand($sql, [':schema' => $schema])->queryColumn();
807
808 2
        return array_map(static fn (string $item): string => '[' . $item . ']', $views);
809
    }
810
811
    /**
812
     * Returns all unique indexes for the given table.
813
     *
814
     * Each array element is of the following structure:
815
     *
816
     * ```php
817
     * [
818
     *     'IndexName1' => ['col1' [, ...]],
819
     *     'IndexName2' => ['col2' [, ...]],
820
     * ]
821
     * ```
822
     *
823
     * @param TableSchema $table the table metadata.
824
     *
825
     * @throws Exception|InvalidConfigException|Throwable
826
     *
827
     * @return array all unique indexes for the given table.
828
     */
829 1
    public function findUniqueIndexes(TableSchema $table): array
830
    {
831 1
        $result = [];
832
833
        /** @psalm-var array<array-key, array{index_name: string, field_name: string}> $tableUniqueConstraints */
834 1
        $tableUniqueConstraints = $this->findTableConstraints($table, 'UNIQUE');
835
836 1
        foreach ($tableUniqueConstraints as $row) {
837 1
            $result[$row['index_name']][] = $row['field_name'];
838
        }
839
840 1
        return $result;
841
    }
842
843
    /**
844
     * Loads multiple types of constraints and returns the specified ones.
845
     *
846
     * @param string $tableName table name.
847
     * @param string $returnType return type:
848
     * - primaryKey
849
     * - foreignKeys
850
     * - uniques
851
     * - checks
852
     * - defaults
853
     *
854
     * @throws Exception|InvalidConfigException|Throwable
855
     *
856
     * @return mixed constraints.
857
     */
858 74
    private function loadTableConstraints(string $tableName, string $returnType): mixed
859
    {
860 74
        $sql = <<<SQL
861
        SELECT
862
            [o].[name] AS [name],
863
            COALESCE([ccol].[name], [dcol].[name], [fccol].[name], [kiccol].[name]) AS [column_name],
864
            RTRIM([o].[type]) AS [type],
865
            OBJECT_SCHEMA_NAME([f].[referenced_object_id]) AS [foreign_table_schema],
866
            OBJECT_NAME([f].[referenced_object_id]) AS [foreign_table_name],
867
            [ffccol].[name] AS [foreign_column_name],
868
            [f].[update_referential_action_desc] AS [on_update],
869
            [f].[delete_referential_action_desc] AS [on_delete],
870
            [c].[definition] AS [check_expr],
871
            [d].[definition] AS [default_expr]
872
        FROM (SELECT OBJECT_ID(:fullName) AS [object_id]) AS [t]
873
        INNER JOIN [sys].[objects] AS [o]
874
            ON [o].[parent_object_id] = [t].[object_id] AND [o].[type] IN ('PK', 'UQ', 'C', 'D', 'F')
875
        LEFT JOIN [sys].[check_constraints] AS [c]
876
            ON [c].[object_id] = [o].[object_id]
877
        LEFT JOIN [sys].[columns] AS [ccol]
878
            ON [ccol].[object_id] = [c].[parent_object_id] AND [ccol].[column_id] = [c].[parent_column_id]
879
        LEFT JOIN [sys].[default_constraints] AS [d]
880
            ON [d].[object_id] = [o].[object_id]
881
        LEFT JOIN [sys].[columns] AS [dcol]
882
            ON [dcol].[object_id] = [d].[parent_object_id] AND [dcol].[column_id] = [d].[parent_column_id]
883
        LEFT JOIN [sys].[key_constraints] AS [k]
884
            ON [k].[object_id] = [o].[object_id]
885
        LEFT JOIN [sys].[index_columns] AS [kic]
886
            ON [kic].[object_id] = [k].[parent_object_id] AND [kic].[index_id] = [k].[unique_index_id]
887
        LEFT JOIN [sys].[columns] AS [kiccol]
888
            ON [kiccol].[object_id] = [kic].[object_id] AND [kiccol].[column_id] = [kic].[column_id]
889
        LEFT JOIN [sys].[foreign_keys] AS [f]
890
            ON [f].[object_id] = [o].[object_id]
891
        LEFT JOIN [sys].[foreign_key_columns] AS [fc]
892
            ON [fc].[constraint_object_id] = [o].[object_id]
893
        LEFT JOIN [sys].[columns] AS [fccol]
894
            ON [fccol].[object_id] = [fc].[parent_object_id] AND [fccol].[column_id] = [fc].[parent_column_id]
895
        LEFT JOIN [sys].[columns] AS [ffccol]
896
            ON [ffccol].[object_id] = [fc].[referenced_object_id] AND [ffccol].[column_id] = [fc].[referenced_column_id]
897
        ORDER BY [kic].[key_ordinal] ASC, [fc].[constraint_column_id] ASC
898
        SQL;
899
900 74
        $resolvedName = $this->resolveTableName($tableName);
901 74
        $constraints = $this->db->createCommand($sql, [':fullName' => $resolvedName->getFullName()])->queryAll();
902
903
        /** @psalm-var array[] $constraints */
904 74
        $constraints = $this->normalizePdoRowKeyCase($constraints, true);
905 74
        $constraints = ArrayHelper::index($constraints, null, ['type', 'name']);
906
907 74
        $result = [
908
            'primaryKey' => null,
909
            'foreignKeys' => [],
910
            'uniques' => [],
911
            'checks' => [],
912
            'defaults' => [],
913
        ];
914
915
        /** @psalm-var array<array-key, array> $constraints */
916 74
        foreach ($constraints as $type => $names) {
917
            /**
918
             * @psalm-var object|string|null $name
919
             * @psalm-var ConstraintArray $constraint
920
             */
921 74
            foreach ($names as $name => $constraint) {
922 74
                switch ($type) {
923 74
                    case 'PK':
924
                        /** @var Constraint */
925 53
                        $result['primaryKey'] = (new Constraint())
926 53
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
927 53
                            ->name($name);
928 53
                        break;
929 74
                    case 'F':
930 16
                        $result['foreignKeys'][] = (new ForeignKeyConstraint())
931 16
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
932 16
                            ->foreignTableName($constraint[0]['foreign_table_name'])
933 16
                            ->foreignColumnNames(ArrayHelper::getColumn($constraint, 'foreign_column_name'))
934 16
                            ->onDelete(str_replace('_', '', $constraint[0]['on_delete']))
935 16
                            ->onUpdate(str_replace('_', '', $constraint[0]['on_update']))
936 16
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
937 16
                            ->name($name);
938 16
                        break;
939 59
                    case 'UQ':
940 57
                        $result['uniques'][] = (new Constraint())
941 57
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
942 57
                            ->name($name);
943 57
                        break;
944 31
                    case 'C':
945 13
                        $result['checks'][] = (new CheckConstraint())
946 13
                            ->expression($constraint[0]['check_expr'])
947 13
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
948 13
                            ->name($name);
949 13
                        break;
950 30
                    case 'D':
951 30
                        $result['defaults'][] = (new DefaultValueConstraint())
952 30
                            ->value($constraint[0]['default_expr'])
953 30
                            ->columnNames(ArrayHelper::getColumn($constraint, 'column_name'))
954 30
                            ->name($name);
955 30
                        break;
956
                }
957
            }
958
        }
959
960 74
        foreach ($result as $type => $data) {
961 74
            $this->setTableMetadata($tableName, $type, $data);
962
        }
963
964 74
        return $result[$returnType];
965
    }
966
967
    /**
968
     * Executes the INSERT command, returning primary key values.
969
     *
970
     * @param string $table the table that new rows will be inserted into.
971
     * @param array $columns the column data (name => value) to be inserted into the table.
972
     *
973
     * @throws Exception|InvalidCallException|InvalidConfigException|Throwable
974
     *
975
     * @return array|false primary key values or false if the command fails.
976
     *
977
     * @todo Remove old version support @darkdef.
978
     */
979 1
    public function insert(string $table, array $columns): bool|array
980
    {
981 1
        $command = $this->db->createCommand()->insert($table, $columns);
982
983 1
        if (!$command->execute()) {
984
            return false;
985
        }
986
987 1
        $isVersion2005orLater = version_compare($this->db->getServerVersion(), '9', '>=');
988
        /** @var array */
989 1
        $inserted = $isVersion2005orLater ? $command->getPdoStatement()?->fetch() : [];
990 1
        $tableSchema = $this->getTableSchema($table);
991
992 1
        $result = [];
993
994 1
        if ($tableSchema !== null) {
995 1
            $pks = $tableSchema->getPrimaryKey();
996 1
            $columnsTable = $tableSchema->getColumns();
997
998 1
            foreach ($pks as $name) {
999
                /**
1000
                * @link https://github.com/yiisoft/yii2/issues/13828
1001
                * @link https://github.com/yiisoft/yii2/issues/17474
1002
                */
1003 1
                if (isset($inserted[$name])) {
1004
                    /** @var string */
1005 1
                    $result[$name] = $inserted[$name];
1006
                } elseif ($tableSchema->getColumns()[$name]->isAutoIncrement()) {
1007
                    // for a version earlier than 2005
1008
                    $result[$name] = $this->getLastInsertID((string) $tableSchema->getSequenceName());
1009
                } elseif (isset($columns[$name])) {
1010
                    /** @var string */
1011
                    $result[$name] = $columns[$name];
1012
                } else {
1013
                    /** @var mixed */
1014
                    $result[$name] = !array_key_exists($name, $columnsTable) ?: $columnsTable[$name]->getDefaultValue();
1015
                }
1016
            }
1017
        }
1018
1019 1
        return $result;
1020
    }
1021
1022
    /**
1023
     * Create a column schema builder instance giving the type and value precision.
1024
     *
1025
     * This method may be overridden by child classes to create a DBMS-specific column schema builder.
1026
     *
1027
     * @param string $type type of the column. See {@see ColumnSchemaBuilder::$type}.
1028
     * @param array|int|string|null $length length or precision of the column. See {@see ColumnSchemaBuilder::$length}.
1029
     *
1030
     * @return ColumnSchemaBuilder column schema builder instance
1031
     */
1032 3
    public function createColumnSchemaBuilder(string $type, array|int|string $length = null): ColumnSchemaBuilder
1033
    {
1034 3
        return new ColumnSchemaBuilder($type, $length);
1035
    }
1036
1037 2
    public function setTransactionIsolationLevel(string $level): void
1038
    {
1039 2
        $this->db->createCommand("SET TRANSACTION ISOLATION LEVEL $level")->execute();
1040 2
    }
1041
1042
    /**
1043
     * Returns the actual name of a given table name.
1044
     *
1045
     * This method will strip off curly brackets from the given table name and replace the percentage character '%' with
1046
     * {@see ConnectionInterface::tablePrefix}.
1047
     *
1048
     * @param string $name the table name to be converted.
1049
     *
1050
     * @return string the real name of the given table name.
1051
     */
1052 158
    public function getRawTableName(string $name): string
1053
    {
1054 158
        if (str_contains($name, '{{')) {
1055 23
            $name = preg_replace('/{{(.*?)}}/', '\1', $name);
1056
1057 23
            return str_replace('%', $this->db->getTablePrefix(), $name);
1058
        }
1059
1060 158
        return $name;
1061
    }
1062
1063
    /**
1064
     * Returns the cache key for the specified table name.
1065
     *
1066
     * @param string $name the table name.
1067
     *
1068
     * @return array the cache key.
1069
     */
1070 158
    protected function getCacheKey(string $name): array
1071
    {
1072
        return [
1073 158
            __CLASS__,
1074 158
            $this->db->getDriver()->getDsn(),
1075 158
            $this->db->getDriver()->getUsername(),
1076 158
            $this->getRawTableName($name),
1077
        ];
1078
    }
1079
1080
    /**
1081
     * Returns the cache tag name.
1082
     *
1083
     * This allows {@see refresh()} to invalidate all cached table schemas.
1084
     *
1085
     * @return string the cache tag name.
1086
     */
1087 158
    protected function getCacheTag(): string
1088
    {
1089 158
        return md5(serialize([
1090 158
            __CLASS__,
1091 158
            $this->db->getDriver()->getDsn(),
1092 158
            $this->db->getDriver()->getUsername(),
1093
        ]));
1094
    }
1095
1096
    /**
1097
     * @return bool whether this DBMS supports [savepoint](http://en.wikipedia.org/wiki/Savepoint).
1098
     */
1099 2
    public function supportsSavepoint(): bool
1100
    {
1101 2
        return $this->db->isSavepointEnabled();
1102
    }
1103
1104
    /**
1105
     * Changes row's array key case to lower if PDO one is set to uppercase.
1106
     *
1107
     * @param array $row row's array or an array of row's arrays.
1108
     * @param bool $multiple whether multiple rows or a single row passed.
1109
     *
1110
     * @throws Exception
1111
     *
1112
     * @return array normalized row or rows.
1113
     */
1114 84
    protected function normalizePdoRowKeyCase(array $row, bool $multiple): array
1115
    {
1116 84
        if ($this->db->getSlavePdo()?->getAttribute(PDO::ATTR_CASE) !== PDO::CASE_UPPER) {
1117 64
            return $row;
1118
        }
1119
1120 20
        if ($multiple) {
1121 20
            return array_map(static function (array $row) {
1122 19
                return array_change_key_case($row, CASE_LOWER);
1123 20
            }, $row);
1124
        }
1125
1126
        return array_change_key_case($row, CASE_LOWER);
1127
    }
1128
1129 3
    public function getLastInsertID(string $sequenceName = ''): string
1130
    {
1131 3
        $pdo = $this->db->getPDO();
1132
1133 3
        if ($pdo !== null && $this->db->isActive()) {
1134 3
            return $pdo->lastInsertId(
1135 3
                $sequenceName === '' ? null : $this->db->getQuoter()->quoteTableName($sequenceName)
1136
            );
1137
        }
1138
1139
        throw new InvalidCallException('DB Connection is not active.');
1140
    }
1141
1142 1
    public function getViewNames(string $schema = '', bool $refresh = false): array
1143
    {
1144
        /** @var array */
1145 1
        $viewNames = $this->viewNames[$schema] ?? [];
1146
1147 1
        if ($viewNames === [] || $refresh) {
1148 1
            $viewNames[$schema] = $this->findViewNames($schema);
1149
        }
1150
1151 1
        return $this->viewNames[$schema] = $viewNames;
1152
    }
1153
}
1154