Passed
Pull Request — master (#190)
by Def
04:08 queued 15s
created

Schema::findForeignKeys()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 52
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 5.0035

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 31
c 1
b 0
f 0
nc 12
nop 1
dl 0
loc 52
ccs 18
cts 19
cp 0.9474
crap 5.0035
rs 9.1128

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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