Passed
Pull Request — master (#211)
by Wilmer
18:28 queued 14:01
created

Schema::loadColumnSchema()   C

Complexity

Conditions 14
Paths 138

Size

Total Lines 62
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 14.7716

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 14
eloc 37
c 5
b 1
f 0
nc 138
nop 1
dl 0
loc 62
ccs 32
cts 38
cp 0.8421
crap 14.7716
rs 5.95

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