Test Failed
Pull Request — master (#211)
by Wilmer
05:53 queued 01:29
created

Schema::loadColumnSchema()   C

Complexity

Conditions 14
Paths 138

Size

Total Lines 62
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 14.4345

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