Passed
Pull Request — master (#211)
by Wilmer
04:31
created

Schema::loadColumnSchema()   C

Complexity

Conditions 14
Paths 138

Size

Total Lines 61
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 14.7716

Importance

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