Test Setup Failed
Pull Request — master (#225)
by Def
02:43
created

Schema::createColumnSchema()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 15
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 3
eloc 12
c 5
b 1
f 0
nc 1
nop 1
dl 0
loc 15
ccs 4
cts 4
cp 1
crap 3
rs 9.8666
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Oracle;
6
7
use Throwable;
8
use Yiisoft\Db\Cache\SchemaCache;
9
use Yiisoft\Db\Connection\ConnectionInterface;
10
use Yiisoft\Db\Constraint\CheckConstraint;
11
use Yiisoft\Db\Constraint\Constraint;
12
use Yiisoft\Db\Constraint\ForeignKeyConstraint;
13
use Yiisoft\Db\Constraint\IndexConstraint;
14
use Yiisoft\Db\Driver\Pdo\AbstractPdoSchema;
15
use Yiisoft\Db\Exception\Exception;
16
use Yiisoft\Db\Exception\InvalidConfigException;
17
use Yiisoft\Db\Exception\NotSupportedException;
18
use Yiisoft\Db\Expression\Expression;
19
use Yiisoft\Db\Helper\DbArrayHelper;
20
use Yiisoft\Db\Schema\Builder\ColumnInterface;
21
use Yiisoft\Db\Schema\ColumnSchemaInterface;
22
use Yiisoft\Db\Schema\TableSchemaInterface;
23
24
use function array_merge;
25
use function array_reverse;
26
use function implode;
27
use function is_array;
28
use function md5;
29
use function serialize;
30
use function str_contains;
31
use function strlen;
32
use function substr;
33
use function trim;
34
35
/**
36
 * Implements the Oracle Server specific schema, supporting Oracle Server 11C and above.
37
 *
38
 * @psalm-type ColumnInfoArray = array{
39
 *   column_name: string,
40
 *   data_type: string,
41
 *   data_precision: string|null,
42
 *   data_scale: string|null,
43
 *   data_length: string,
44
 *   nullable: string,
45
 *   data_default: string|null,
46
 *   is_pk: string|null,
47
 *   column_comment: string|null
48
 * }
49
 *
50
 * @psalm-type ConstraintArray = array<
51
 *   array-key,
52
 *   array {
53
 *     name: string,
54
 *     column_name: string,
55 543
 *     type: string,
56
 *     foreign_table_schema: string|null,
57 543
 *     foreign_table_name: string|null,
58 543
 *     foreign_column_name: string|null,
59
 *     on_update: string,
60
 *     on_delete: string,
61 15
 *     check_expr: string
62
 *   }
63 15
 * >
64
 */
65
final class Schema extends AbstractPdoSchema
66 205
{
67
    public function __construct(protected ConnectionInterface $db, SchemaCache $schemaCache, string $defaultSchema)
68 205
    {
69
        $this->defaultSchema = $defaultSchema;
70 205
        parent::__construct($db, $schemaCache);
71 205
    }
72 205
73
    public function createColumn(string $type, array|int|string $length = null): ColumnInterface
74 205
    {
75 205
        return new Column($type, $length);
76
    }
77 205
78 205
    protected function resolveTableName(string $name): TableSchemaInterface
79 205
    {
80 205
        $resolvedName = new TableSchema();
81
82 205
        $parts = array_reverse(
83
            $this->db->getQuoter()->getTableNameParts($name)
84
        );
85
86
        $resolvedName->name($parts[0] ?? '');
87
        $resolvedName->schemaName($parts[1] ?? $this->defaultSchema);
88
89
        $resolvedName->fullName(
90
            $resolvedName->getSchemaName() !== $this->defaultSchema ?
91
            implode('.', array_reverse($parts)) : $resolvedName->getName()
92
        );
93 1
94
        return $resolvedName;
95 1
    }
96
97
    /**
98
     * @link https://docs.oracle.com/cd/B28359_01/server.111/b28337/tdpsg_user_accounts.htm
99
     *
100 1
     * @throws Exception
101
     * @throws InvalidConfigException
102 1
     * @throws NotSupportedException
103
     * @throws Throwable
104
     */
105
    protected function findSchemaNames(): array
106
    {
107
        $sql = <<<SQL
108
        SELECT "u"."USERNAME"
109
        FROM "DBA_USERS" "u"
110 146
        WHERE "u"."DEFAULT_TABLESPACE" NOT IN ('SYSTEM', 'SYSAUX')
111
        ORDER BY "u"."USERNAME" ASC
112 146
        SQL;
113
114
        return $this->db->createCommand($sql)->queryColumn();
115
    }
116
117
    /**
118 146
     * @throws Exception
119
     * @throws InvalidConfigException
120 146
     * @throws Throwable
121 146
     */
122 146
    protected function findTableComment(TableSchemaInterface $tableSchema): void
123 146
    {
124
        $sql = <<<SQL
125 146
        SELECT "COMMENTS"
126
        FROM ALL_TAB_COMMENTS
127
        WHERE
128
              "OWNER" = :schemaName AND
129
              "TABLE_NAME" = :tableName
130
        SQL;
131
132
        $comment = $this->db->createCommand($sql, [
133 12
            ':schemaName' => $tableSchema->getSchemaName(),
134
            ':tableName' => $tableSchema->getName(),
135 12
        ])->queryScalar();
136 11
137
        $tableSchema->comment(is_string($comment) ? $comment : null);
138
    }
139
140
    /**
141
     * @throws Exception
142
     * @throws InvalidConfigException
143
     * @throws Throwable
144
     */
145
    protected function findTableNames(string $schema = ''): array
146 11
    {
147
        if ($schema === '') {
148 11
            $sql = <<<SQL
149
            SELECT TABLE_NAME
150 1
            FROM USER_TABLES
151
            UNION ALL
152
            SELECT VIEW_NAME AS TABLE_NAME
153
            FROM USER_VIEWS
154
            UNION ALL
155 1
            SELECT MVIEW_NAME AS TABLE_NAME
156 1
            FROM USER_MVIEWS
157
            ORDER BY TABLE_NAME
158
            SQL;
159 12
160 12
            $command = $this->db->createCommand($sql);
161
        } else {
162
            $sql = <<<SQL
163 12
            SELECT OBJECT_NAME AS TABLE_NAME
164
            FROM ALL_OBJECTS
165 12
            WHERE OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW') AND OWNER = :schema
166 12
            ORDER BY OBJECT_NAME
167
            SQL;
168
            $command = $this->db->createCommand($sql, [':schema' => $schema]);
169 12
        }
170
171
        $rows = $command->queryAll();
172
        $names = [];
173
174
        /** @psalm-var string[][] $rows */
175
        foreach ($rows as $row) {
176
            /** @psalm-var string[] $row */
177 146
            $row = $this->normalizeRowKeyCase($row, false);
178
            $names[] = $row['table_name'];
179 146
        }
180 146
181
        return $names;
182 146
    }
183 129
184 129
    /**
185
     * @throws Exception
186
     * @throws InvalidConfigException
187 33
     * @throws Throwable
188
     */
189
    protected function loadTableSchema(string $name): TableSchemaInterface|null
190
    {
191
        $table = $this->resolveTableName($name);
192
        $this->findTableComment($table);
193
194
        if ($this->findColumns($table)) {
195
            $this->findConstraints($table);
196 47
            return $table;
197
        }
198
199 47
        return null;
200 47
    }
201
202
    /**
203
     * @throws Exception
204
     * @throws InvalidConfigException
205
     * @throws NotSupportedException
206
     * @throws Throwable
207
     */
208
    protected function loadTablePrimaryKey(string $tableName): Constraint|null
209 8
    {
210
        /** @psalm-var mixed $tablePrimaryKey */
211
        $tablePrimaryKey = $this->loadTableConstraints($tableName, self::PRIMARY_KEY);
212 8
        return $tablePrimaryKey instanceof Constraint ? $tablePrimaryKey : null;
213 8
    }
214
215
    /**
216
     * @throws Exception
217
     * @throws InvalidConfigException
218
     * @throws NotSupportedException
219
     * @throws Throwable
220
     */
221
    protected function loadTableForeignKeys(string $tableName): array
222 38
    {
223
        /** @psalm-var mixed $tableForeignKeys */
224 38
        $tableForeignKeys = $this->loadTableConstraints($tableName, self::FOREIGN_KEYS);
225
        return is_array($tableForeignKeys) ? $tableForeignKeys : [];
226
    }
227
228
    /**
229
     * @throws Exception
230
     * @throws InvalidConfigException
231
     * @throws NotSupportedException
232
     * @throws Throwable
233
     */
234
    protected function loadTableIndexes(string $tableName): array
235 38
    {
236
        $sql = <<<SQL
237 38
        SELECT "ui"."INDEX_NAME" AS "name", "uicol"."COLUMN_NAME" AS "column_name",
238 38
        CASE "ui"."UNIQUENESS" WHEN 'UNIQUE' THEN 1 ELSE 0 END AS "index_is_unique",
239 38
        CASE WHEN "uc"."CONSTRAINT_NAME" IS NOT NULL THEN 1 ELSE 0 END AS "index_is_primary"
240 38
        FROM "SYS"."USER_INDEXES" "ui"
241 38
        LEFT JOIN "SYS"."USER_IND_COLUMNS" "uicol"
242
        ON "uicol"."INDEX_NAME" = "ui"."INDEX_NAME"
243
        LEFT JOIN "SYS"."USER_CONSTRAINTS" "uc"
244 38
        ON "uc"."OWNER" = "ui"."TABLE_OWNER" AND "uc"."CONSTRAINT_NAME" = "ui"."INDEX_NAME" AND "uc"."CONSTRAINT_TYPE" = 'P'
245 38
        WHERE "ui"."TABLE_OWNER" = :schemaName AND "ui"."TABLE_NAME" = :tableName
246
        ORDER BY "uicol"."COLUMN_POSITION" ASC
247 38
        SQL;
248
249
        $resolvedName = $this->resolveTableName($tableName);
250
        $indexes = $this->db->createCommand($sql, [
251
            ':schemaName' => $resolvedName->getSchemaName(),
252
            ':tableName' => $resolvedName->getName(),
253 38
        ])->queryAll();
254 35
255
        /** @psalm-var array[] $indexes */
256 35
        $indexes = $this->normalizeRowKeyCase($indexes, true);
257 20
        $indexes = DbArrayHelper::index($indexes, null, ['name']);
258
259
        $result = [];
260 35
261 35
        /**
262 35
         * @psalm-var object|string|null $name
263 35
         * @psalm-var array[] $index
264 35
         */
265
        foreach ($indexes as $name => $index) {
266
            $columnNames = DbArrayHelper::getColumn($index, 'column_name');
267 38
268
            if ($columnNames[0] === null) {
269
                $columnNames[0] = '';
270
            }
271
272
            $result[] = (new IndexConstraint())
273
                ->primary((bool) $index[0]['index_is_primary'])
274
                ->unique((bool) $index[0]['index_is_unique'])
275
                ->name($name)
276 17
                ->columnNames($columnNames);
277
        }
278
279 17
        return $result;
280 17
    }
281
282
    /**
283
     * @throws Exception
284
     * @throws InvalidConfigException
285
     * @throws NotSupportedException
286
     * @throws Throwable
287
     */
288
    protected function loadTableUniques(string $tableName): array
289 17
    {
290
        /** @psalm-var mixed $tableUniques */
291
        $tableUniques = $this->loadTableConstraints($tableName, self::UNIQUES);
292 17
        return is_array($tableUniques) ? $tableUniques : [];
293 17
    }
294
295
    /**
296
     * @throws Exception
297
     * @throws InvalidConfigException
298
     * @throws NotSupportedException
299 13
     * @throws Throwable
300
     */
301 13
    protected function loadTableChecks(string $tableName): array
302
    {
303
        /** @psalm-var mixed $tableCheck */
304
        $tableCheck = $this->loadTableConstraints($tableName, self::CHECKS);
305
        return is_array($tableCheck) ? $tableCheck : [];
306
    }
307
308
    /**
309
     * @throws NotSupportedException If this method is called.
310
     */
311
    protected function loadTableDefaultValues(string $tableName): array
312
    {
313
        throw new NotSupportedException(__METHOD__ . ' is not supported by Oracle.');
314 146
    }
315
316 146
    /**
317
     * Collects the table column metadata.
318
     *
319
     * @param TableSchemaInterface $table The table schema.
320
     *
321
     * @throws Exception
322
     * @throws Throwable
323
     *
324
     * @return bool Whether the table exists.
325
     */
326
    protected function findColumns(TableSchemaInterface $table): bool
327
    {
328
        $sql = <<<SQL
329
        SELECT
330
            A.COLUMN_NAME,
331
            A.DATA_TYPE,
332
            A.DATA_PRECISION,
333
            A.DATA_SCALE,
334
            (
335
            CASE A.CHAR_USED WHEN 'C' THEN A.CHAR_LENGTH
336
                ELSE A.DATA_LENGTH
337
            END
338
            ) AS DATA_LENGTH,
339
            A.NULLABLE,
340
            A.DATA_DEFAULT,
341
            (
342
                SELECT COUNT(*)
343
                FROM ALL_CONSTRAINTS AC
344
                INNER JOIN ALL_CONS_COLUMNS ACC ON ACC.CONSTRAINT_NAME=AC.CONSTRAINT_NAME
345
                WHERE
346
                     AC.OWNER = A.OWNER
347
                   AND AC.TABLE_NAME = B.OBJECT_NAME
348 146
                   AND ACC.COLUMN_NAME = A.COLUMN_NAME
349
                   AND AC.CONSTRAINT_TYPE = 'P'
350 146
            ) AS IS_PK,
351 146
            COM.COMMENTS AS COLUMN_COMMENT
352 146
        FROM ALL_TAB_COLUMNS A
353 146
            INNER JOIN ALL_OBJECTS B ON B.OWNER = A.OWNER AND LTRIM(B.OBJECT_NAME) = LTRIM(A.TABLE_NAME)
354
            LEFT JOIN ALL_COL_COMMENTS COM ON (A.OWNER = COM.OWNER AND A.TABLE_NAME = COM.TABLE_NAME AND A.COLUMN_NAME = COM.COLUMN_NAME)
355 146
        WHERE
356 33
            A.OWNER = :schemaName
357
            AND B.OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW')
358
            AND B.OBJECT_NAME = :tableName
359
        ORDER BY A.COLUMN_ID
360 129
        SQL;
361 129
362
        $columns = $this->db->createCommand($sql, [
363
            ':tableName' => $table->getName(),
364
            ':schemaName' => $table->getSchemaName(),
365
        ])->queryAll();
366
367
        if ($columns === []) {
368
            return false;
369
        }
370
371
        /** @psalm-var string[][] $columns */
372
        foreach ($columns as $column) {
373
            /** @psalm-var ColumnInfoArray $column */
374
            $column = $this->normalizeRowKeyCase($column, false);
375
376 129
            $c = $this->createColumnSchema($column);
377
378 129
            $table->column($c->getName(), $c);
379
        }
380
381 129
        return true;
382
    }
383
384
    /**
385
     * Sequence name of table.
386
     *
387
     * @throws Exception
388
     * @throws InvalidConfigException
389
     * @throws Throwable
390
     *
391
     * @return bool|float|int|string|null Whether the sequence exists.
392
     *
393
     * @internal TableSchemaInterface `$table->getName()` The table schema.
394
     */
395 79
    protected function getTableSequenceName(string $tableName): bool|float|int|string|null
396
    {
397 79
        $sequenceNameSql = <<<SQL
398
        SELECT
399
            UD.REFERENCED_NAME AS SEQUENCE_NAME
400
        FROM USER_DEPENDENCIES UD
401
            JOIN USER_TRIGGERS UT ON (UT.TRIGGER_NAME = UD.NAME)
402
        WHERE
403
            UT.TABLE_NAME = :tableName
404
            AND UD.TYPE = 'TRIGGER'
405
            AND UD.REFERENCED_TYPE = 'SEQUENCE'
406 79
        SQL;
407 79
        $sequenceName = $this->db->createCommand($sequenceNameSql, [':tableName' => $tableName])->queryScalar();
408
409 79
        return $sequenceName === false ? null : $sequenceName;
410
    }
411
412
    /**
413
     * Creates ColumnSchema instance.
414
     *
415
     * @psalm-param ColumnInfoArray $info
416
     */
417
    protected function createColumnSchema(array $info): ColumnSchemaInterface
418
    {
419
        $column = new ColumnSchema($info['column_name']);
420
        $column->allowNull($info['nullable'] === 'Y');
421
        $column->comment($info['column_comment']);
422
        $column->primaryKey((bool) $info['is_pk']);
423
        $column->size((int) $info['data_length']);
424
        $column->precision($info['data_precision'] !== null ? (int) $info['data_precision'] : null);
425
        $column->scale($info['data_scale'] !== null ? (int) $info['data_scale'] : null);
426
        $column->dbType($info['data_type']);
427 129
        $column->type($this->extractColumnType($column));
428
        $column->phpType($this->getColumnPhpType($column));
429 129
        $column->defaultValue($this->normalizeDefaultValue($info['data_default'], $column));
430 129
431 129
        return $column;
432 129
    }
433
434 129
    /**
435 129
     * Converts column's default value according to {@see ColumnSchema::phpType} after retrieval from the database.
436 129
     *
437 129
     * @param string|null $defaultValue The default value retrieved from the database.
438 129
     * @param ColumnSchemaInterface $column The column schema object.
439 129
     *
440 129
     * @return mixed The normalized default value.
441
     *
442 129
     * @psalm-suppress PossiblyNullArgument
443 129
     */
444 129
    private function normalizeDefaultValue(?string $defaultValue, ColumnSchemaInterface $column): mixed
445 129
    {
446 129
        return match (true) {
447 129
            $defaultValue === null,
448 129
            $column->isPrimaryKey()
449
                => null,
450 129
            $defaultValue === 'CURRENT_TIMESTAMP'
451
                && $column->getType() === self::TYPE_TIMESTAMP
452 129
                    => new Expression($defaultValue),
0 ignored issues
show
Bug introduced by
It seems like $defaultValue can also be of type null; however, parameter $expression of Yiisoft\Db\Expression\Expression::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

452
                    => new Expression(/** @scrutinizer ignore-type */ $defaultValue),
Loading history...
453 125
            /** @psalm-var string $defaultValue */
454 121
            strlen($defaultValue) > 2
0 ignored issues
show
Bug introduced by
It seems like $defaultValue can also be of type null; however, parameter $string of strlen() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

454
            strlen(/** @scrutinizer ignore-type */ $defaultValue) > 2
Loading history...
455
                && str_starts_with($defaultValue, "'")
0 ignored issues
show
Bug introduced by
It seems like $defaultValue can also be of type null; however, parameter $haystack of str_starts_with() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

455
                && str_starts_with(/** @scrutinizer ignore-type */ $defaultValue, "'")
Loading history...
456 71
                && str_ends_with($defaultValue, "'")
0 ignored issues
show
Bug introduced by
It seems like $defaultValue can also be of type null; however, parameter $haystack of str_ends_with() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

456
                && str_ends_with(/** @scrutinizer ignore-type */ $defaultValue, "'")
Loading history...
457
                    => $column->phpTypecast(substr($defaultValue, 1, -1)),
0 ignored issues
show
Bug introduced by
It seems like $defaultValue can also be of type null; however, parameter $string of substr() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

457
                    => $column->phpTypecast(substr(/** @scrutinizer ignore-type */ $defaultValue, 1, -1)),
Loading history...
458 71
            default
459
            => $column->phpTypecast(trim($defaultValue)),
0 ignored issues
show
Bug introduced by
It seems like $defaultValue can also be of type null; however, parameter $string of trim() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

459
            => $column->phpTypecast(trim(/** @scrutinizer ignore-type */ $defaultValue)),
Loading history...
460
        };
461
    }
462 71
463 71
    /**
464 71
     * Finds constraints and fills them into TableSchemaInterface object passed.
465
     *
466 25
     * @throws Exception
467
     * @throws InvalidConfigException
468 71
     * @throws Throwable
469
     *
470
     * @psalm-suppress PossiblyNullArrayOffset
471 71
     */
472
    protected function findConstraints(TableSchemaInterface $table): void
473
    {
474
        $sql = <<<SQL
475
        SELECT
476 129
            /*+ PUSH_PRED(C) PUSH_PRED(D) PUSH_PRED(E) */
477
            D.CONSTRAINT_NAME,
478
            D.CONSTRAINT_TYPE,
479
            C.COLUMN_NAME,
480
            C.POSITION,
481
            D.R_CONSTRAINT_NAME,
482
            E.TABLE_NAME AS TABLE_REF,
483
            F.COLUMN_NAME AS COLUMN_REF,
484
            C.TABLE_NAME
485
        FROM ALL_CONS_COLUMNS C
486
            INNER JOIN ALL_CONSTRAINTS D ON D.OWNER = C.OWNER AND D.CONSTRAINT_NAME = C.CONSTRAINT_NAME
487
            LEFT JOIN ALL_CONSTRAINTS E ON E.OWNER = D.R_OWNER AND E.CONSTRAINT_NAME = D.R_CONSTRAINT_NAME
488 129
            LEFT JOIN ALL_CONS_COLUMNS F ON F.OWNER = E.OWNER AND F.CONSTRAINT_NAME = E.CONSTRAINT_NAME AND F.POSITION = C.POSITION
489
        WHERE
490 129
            C.OWNER = :schemaName
491
            AND C.TABLE_NAME = :tableName
492
            ORDER BY D.CONSTRAINT_NAME, C.POSITION
493
        SQL;
494
495
        /**
496
         * @psalm-var array{
497
         *   array{
498
         *     constraint_name: string,
499
         *     constraint_type: string,
500
         *     column_name: string,
501
         *     position: string|null,
502
         *     r_constraint_name: string|null,
503
         *     table_ref: string|null,
504
         *     column_ref: string|null,
505
         *     table_name: string
506
         *   }
507
         * } $rows
508
         */
509 129
        $rows = $this->db->createCommand(
510
            $sql,
511
            [':tableName' => $table->getName(), ':schemaName' => $table->getSchemaName()]
512
        )->queryAll();
513
514
        $constraints = [];
515
516
        foreach ($rows as $row) {
517
            /** @psalm-var string[] $row */
518
            $row = $this->normalizeRowKeyCase($row, false);
519
520
            if ($row['constraint_type'] === 'P') {
521
                $table->getColumns()[$row['column_name']]->primaryKey(true);
522
                $table->primaryKey($row['column_name']);
523
524
                if (empty($table->getSequenceName())) {
525 129
                    $table->sequenceName((string) $this->getTableSequenceName($table->getName()));
526 129
                }
527 129
            }
528 129
529
            if ($row['constraint_type'] !== 'R') {
530 129
                /**
531
                 * This condition isn't checked in `WHERE` because of an Oracle Bug:
532 129
                 *
533
                 * @link https://github.com/yiisoft/yii2/pull/8844
534 118
                 */
535
                continue;
536 118
            }
537 79
538 79
            $name = $row['constraint_name'];
539
540 79
            if (!isset($constraints[$name])) {
541 79
                $constraints[$name] = [
542
                    'tableName' => $row['table_ref'],
543
                    'columns' => [],
544
                ];
545 118
            }
546
547
            $constraints[$name]['columns'][$row['column_name']] = $row['column_ref'];
548
        }
549
550
        foreach ($constraints as $index => $constraint) {
551 118
            $table->foreignKey($index, array_merge([$constraint['tableName']], $constraint['columns']));
552
        }
553
    }
554 14
555
    /**
556 14
     * Returns all unique indexes for the given table.
557 14
     *
558 14
     * Each array element is of the following structure:.
559 14
     *
560 14
     * ```php
561
     * [
562
     *     'IndexName1' => ['col1' [, ...]],
563 14
     *     'IndexName2' => ['col2' [, ...]],
564
     * ]
565
     * ```
566 129
     *
567 14
     * @param TableSchemaInterface $table The table metadata.
568
     *
569
     * @throws Exception
570
     * @throws InvalidConfigException
571
     * @throws Throwable
572
     *
573
     * @return array All unique indexes for the given table.
574
     */
575
    public function findUniqueIndexes(TableSchemaInterface $table): array
576
    {
577
        $query = <<<SQL
578
        SELECT
579
            DIC.INDEX_NAME,
580
            DIC.COLUMN_NAME
581
        FROM ALL_INDEXES DI
582
            INNER JOIN ALL_IND_COLUMNS DIC ON DI.TABLE_NAME = DIC.TABLE_NAME AND DI.INDEX_NAME = DIC.INDEX_NAME
583
        WHERE
584
            DI.UNIQUENESS = 'UNIQUE'
585
            AND DIC.TABLE_OWNER = :schemaName
586
            AND DIC.TABLE_NAME = :tableName
587
        ORDER BY DIC.TABLE_NAME, DIC.INDEX_NAME, DIC.COLUMN_POSITION
588
        SQL;
589
        $result = [];
590
591 1
        $rows = $this->db->createCommand(
592
            $query,
593 1
            [':tableName' => $table->getName(), ':schemaName' => $table->getschemaName()]
594
        )->queryAll();
595
596
        /** @psalm-var array<array{INDEX_NAME: string, COLUMN_NAME: string}> $rows */
597
        foreach ($rows as $row) {
598
            $result[$row['INDEX_NAME']][] = $row['COLUMN_NAME'];
599
        }
600
601
        return $result;
602
    }
603
604 1
    /**
605 1
     * Extracts the data type for the given column.
606
     *
607 1
     * @param ColumnSchemaInterface $column The column schema object.
608 1
     *
609 1
     * @return string The abstract column type.
610 1
     */
611
    private function extractColumnType(ColumnSchemaInterface $column): string
612
    {
613 1
        $dbType = (string) $column->getDbType();
614 1
615
        return match (true) {
616
            str_contains($dbType, 'FLOAT'),
617 1
            str_contains($dbType, 'DOUBLE')
618
                => self::TYPE_DOUBLE,
619
            str_contains($dbType, 'NUMBER')
620
                => $column->getScale() === null || $column->getScale() > 0
621
                    ? self::TYPE_DECIMAL
622
                    : self::TYPE_INTEGER,
623
            str_contains($dbType, 'BLOB')
624
                => self::TYPE_BINARY,
625
            str_contains($dbType, 'CLOB')
626
                => self::TYPE_TEXT,
627
            str_contains($dbType, 'TIMESTAMP')
628 129
                => self::TYPE_TIMESTAMP,
629
            default
630
            => self::TYPE_STRING,
631
        };
632
    }
633
634
    /**
635 129
     * Loads multiple types of constraints and returns the specified ones.
636
     *
637 129
     * @param string $tableName The table name.
638 28
     * @param string $returnType The return type:
639 129
     * - primaryKey
640 121
     * - foreignKeys
641 31
     * - uniques
642
     * - checks
643 121
     *
644
     * @throws Exception
645 97
     * @throws InvalidConfigException
646 30
     * @throws NotSupportedException
647 93
     * @throws Throwable
648 24
     *
649 92
     * @return mixed Constraints.
650 26
     */
651
    private function loadTableConstraints(string $tableName, string $returnType): mixed
652 92
    {
653
        $sql = <<<SQL
654
        SELECT
655
            "uc"."CONSTRAINT_NAME" AS "name",
656
            "uccol"."COLUMN_NAME" AS "column_name",
657
            "uc"."CONSTRAINT_TYPE" AS "type",
658
            "fuc"."OWNER" AS "foreign_table_schema",
659
            "fuc"."TABLE_NAME" AS "foreign_table_name",
660
            "fuccol"."COLUMN_NAME" AS "foreign_column_name",
661
            "uc"."DELETE_RULE" AS "on_delete",
662
            "uc"."SEARCH_CONDITION" AS "check_expr"
663
        FROM "USER_CONSTRAINTS" "uc"
664 129
        INNER JOIN "USER_CONS_COLUMNS" "uccol"
665
        ON "uccol"."OWNER" = "uc"."OWNER" AND "uccol"."CONSTRAINT_NAME" = "uc"."CONSTRAINT_NAME"
666
        LEFT JOIN "USER_CONSTRAINTS" "fuc"
667
        ON "fuc"."OWNER" = "uc"."R_OWNER" AND "fuc"."CONSTRAINT_NAME" = "uc"."R_CONSTRAINT_NAME"
668
        LEFT JOIN "USER_CONS_COLUMNS" "fuccol"
669
        ON "fuccol"."OWNER" = "fuc"."OWNER" AND "fuccol"."CONSTRAINT_NAME" = "fuc"."CONSTRAINT_NAME" AND "fuccol"."POSITION" = "uccol"."POSITION"
670
        WHERE "uc"."OWNER" = :schemaName AND "uc"."TABLE_NAME" = :tableName
671 129
        ORDER BY "uccol"."POSITION" ASC
672 129
        SQL;
673 129
674
        $resolvedName = $this->resolveTableName($tableName);
675
        $constraints = $this->db->createCommand($sql, [
676
            ':schemaName' => $resolvedName->getSchemaName(),
677
            ':tableName' => $resolvedName->getName(),
678
        ])->queryAll();
679
680
        /** @psalm-var array[] $constraints */
681
        $constraints = $this->normalizeRowKeyCase($constraints, true);
682
        $constraints = DbArrayHelper::index($constraints, null, ['type', 'name']);
683
684
        $result = [
685
            self::PRIMARY_KEY => null,
686
            self::FOREIGN_KEYS => [],
687
            self::UNIQUES => [],
688
            self::CHECKS => [],
689
        ];
690
691
        /**
692
         * @psalm-var string $type
693 89
         * @psalm-var array $names
694
         */
695 89
        foreach ($constraints as $type => $names) {
696
            /**
697
             * @psalm-var object|string|null $name
698
             * @psalm-var ConstraintArray $constraint
699
             */
700
            foreach ($names as $name => $constraint) {
701
                switch ($type) {
702
                    case 'P':
703
                        $result[self::PRIMARY_KEY] = (new Constraint())
704
                            ->name($name)
705
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
706
                        break;
707
                    case 'R':
708
                        $result[self::FOREIGN_KEYS][] = (new ForeignKeyConstraint())
709
                            ->name($name)
710
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
711
                            ->foreignSchemaName($constraint[0]['foreign_table_schema'])
712
                            ->foreignTableName($constraint[0]['foreign_table_name'])
713
                            ->foreignColumnNames(DbArrayHelper::getColumn($constraint, 'foreign_column_name'))
714 89
                            ->onDelete($constraint[0]['on_delete'])
715
                            ->onUpdate(null);
716 89
                        break;
717 89
                    case 'U':
718 89
                        $result[self::UNIQUES][] = (new Constraint())
719 89
                            ->name($name)
720 89
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'));
721
                        break;
722
                    case 'C':
723 89
                        $result[self::CHECKS][] = (new CheckConstraint())
724 89
                            ->name($name)
725
                            ->columnNames(DbArrayHelper::getColumn($constraint, 'column_name'))
726 89
                            ->expression($constraint[0]['check_expr']);
727 89
                        break;
728 89
                }
729 89
            }
730 89
        }
731 89
732
        foreach ($result as $type => $data) {
733
            $this->setTableMetadata($tableName, $type, $data);
734
        }
735
736
        return $result[$returnType];
737 89
    }
738
739
    /**
740
     * @throws Exception
741
     * @throws InvalidConfigException
742 82
     * @throws Throwable
743
     */
744 82
    protected function findViewNames(string $schema = ''): array
745 57
    {
746 57
        $sql = match ($schema) {
747 57
            '' => <<<SQL
748 57
            SELECT VIEW_NAME FROM USER_VIEWS
749 82
            SQL,
750 19
            default => <<<SQL
751 19
            SELECT VIEW_NAME FROM ALL_VIEWS WHERE OWNER = '$schema'
752 19
            SQL,
753 19
        };
754 19
755 19
        /** @psalm-var string[][] $views */
756 19
        $views = $this->db->createCommand($sql)->queryAll();
757 19
758 19
        foreach ($views as $key => $view) {
759 82
            $views[$key] = $view['VIEW_NAME'];
760 58
        }
761 58
762 58
        return $views;
763 58
    }
764 82
765 82
    /**
766 82
     * Returns the cache key for the specified table name.
767 82
     *
768 82
     * @param string $name The table name.
769 82
     *
770
     * @return array The cache key.
771
     */
772
    protected function getCacheKey(string $name): array
773
    {
774 89
        return array_merge([self::class], $this->generateCacheKey(), [$this->getRawTableName($name)]);
775 89
    }
776
777
    /**
778 89
     * Returns the cache tag name.
779
     *
780
     * This allows {@see refresh()} to invalidate all cached table schemas.
781
     *
782
     * @return string The cache tag name.
783
     */
784
    protected function getCacheTag(): string
785
    {
786 2
        return md5(serialize(array_merge([self::class], $this->generateCacheKey())));
787
    }
788
}
789