Passed
Push — master ( 647f42...eee75a )
by Wilmer
04:02
created

QueryBuilder::addForeignKey()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 24
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3.0067

Importance

Changes 0
Metric Value
cc 3
eloc 10
nc 4
nop 7
dl 0
loc 24
ccs 10
cts 11
cp 0.9091
crap 3.0067
rs 9.9332
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Oracle;
6
7
use Generator;
8
use JsonException;
9
use Throwable;
10
use Yiisoft\Db\Constraint\Constraint;
11
use Yiisoft\Db\Exception\Exception;
12
use Yiisoft\Db\Exception\InvalidArgumentException;
13
use Yiisoft\Db\Exception\InvalidConfigException;
14
use Yiisoft\Db\Exception\NotSupportedException;
15
use Yiisoft\Db\Expression\Expression;
16
use Yiisoft\Db\Expression\ExpressionInterface;
17
use Yiisoft\Db\Oracle\Conditions\InConditionBuilder;
18
use Yiisoft\Db\Oracle\Conditions\LikeConditionBuilder;
19
use Yiisoft\Db\Query\Query;
20
use Yiisoft\Db\Query\QueryBuilder as AbstractQueryBuilder;
21
use Yiisoft\Db\Query\Conditions\InCondition;
22
use Yiisoft\Db\Query\Conditions\LikeCondition;
23
use Yiisoft\Strings\NumericHelper;
24
25
/**
26
 * QueryBuilder is the query builder for Oracle databases.
27
 */
28
final class QueryBuilder extends AbstractQueryBuilder
29
{
30
    /**
31
     * @var array mapping from abstract column types (keys) to physical column types (values).
32
     */
33
    protected array $typeMap = [
34
        Schema::TYPE_PK => 'NUMBER(10) NOT NULL PRIMARY KEY',
35
        Schema::TYPE_UPK => 'NUMBER(10) UNSIGNED NOT NULL PRIMARY KEY',
36
        Schema::TYPE_BIGPK => 'NUMBER(20) NOT NULL PRIMARY KEY',
37
        Schema::TYPE_UBIGPK => 'NUMBER(20) UNSIGNED NOT NULL PRIMARY KEY',
38
        Schema::TYPE_CHAR => 'CHAR(1)',
39
        Schema::TYPE_STRING => 'VARCHAR2(255)',
40
        Schema::TYPE_TEXT => 'CLOB',
41
        Schema::TYPE_TINYINT => 'NUMBER(3)',
42
        Schema::TYPE_SMALLINT => 'NUMBER(5)',
43
        Schema::TYPE_INTEGER => 'NUMBER(10)',
44
        Schema::TYPE_BIGINT => 'NUMBER(20)',
45
        Schema::TYPE_FLOAT => 'NUMBER',
46
        Schema::TYPE_DOUBLE => 'NUMBER',
47
        Schema::TYPE_DECIMAL => 'NUMBER',
48
        Schema::TYPE_DATETIME => 'TIMESTAMP',
49
        Schema::TYPE_TIMESTAMP => 'TIMESTAMP',
50
        Schema::TYPE_TIME => 'TIMESTAMP',
51
        Schema::TYPE_DATE => 'DATE',
52
        Schema::TYPE_BINARY => 'BLOB',
53
        Schema::TYPE_BOOLEAN => 'NUMBER(1)',
54
        Schema::TYPE_MONEY => 'NUMBER(19,4)',
55
    ];
56
57 243
    protected function defaultExpressionBuilders(): array
58
    {
59 243
        return array_merge(parent::defaultExpressionBuilders(), [
60 243
            InCondition::class => InConditionBuilder::class,
61
            LikeCondition::class => LikeConditionBuilder::class,
62
        ]);
63
    }
64
65 166
    public function buildOrderByAndLimit(string $sql, array $orderBy, $limit, $offset, array &$params = []): string
66
    {
67 166
        $orderBy = $this->buildOrderBy($orderBy, $params);
68
69 166
        if ($orderBy !== '') {
70 6
            $sql .= $this->separator . $orderBy;
71
        }
72
73 166
        $filters = [];
74 166
        if ($this->hasOffset($offset)) {
75 1
            $filters[] = 'rowNumId > ' . $offset;
76
        }
77
78 166
        if ($this->hasLimit($limit)) {
79 9
            $filters[] = 'rownum <= ' . $limit;
80
        }
81
82 166
        if (empty($filters)) {
83 160
            return $sql;
84
        }
85
86 9
        $filter = implode(' AND ', $filters);
87
        return <<<EOD
88 9
WITH USER_SQL AS ($sql),
89
    PAGINATION AS (SELECT USER_SQL.*, rownum as rowNumId FROM USER_SQL)
90
SELECT *
91
FROM PAGINATION
92 9
WHERE $filter
93
EOD;
94
    }
95
96
    /**
97
     * Builds a SQL statement for renaming a DB table.
98
     *
99
     * @param string $oldName
100
     * @param string $newName the new table name. The name will be properly quoted by the method.
101
     *
102
     * @return string the SQL statement for renaming a DB table.
103
     */
104 2
    public function renameTable(string $oldName, string $newName): string
105
    {
106 2
        return 'ALTER TABLE ' . $this->getDb()->quoteTableName($oldName) . ' RENAME TO ' .
107 2
            $this->getDb()->quoteTableName($newName);
108
    }
109
110
    /**
111
     * Builds a SQL statement for changing the definition of a column.
112
     *
113
     * @param string $table the table whose column is to be changed. The table name will be properly quoted by the
114
     * method.
115
     * @param string $column the name of the column to be changed. The name will be properly quoted by the method.
116
     * @param string $type the new column type. The [[getColumnType]] method will be invoked to convert abstract column
117
     * type (if any) into the physical one. Anything that is not recognized as abstract type will be kept in the
118
     * generated SQL.
119
     *
120
     * For example, 'string' will be turned into 'varchar(255)', while 'string not null' will become
121
     * 'varchar(255) not null'.
122
     *
123
     * @return string the SQL statement for changing the definition of a column.
124
     */
125
    public function alterColumn(string $table, string $column, string $type): string
126
    {
127
        $type = $this->getColumnType($type);
128
129
        return 'ALTER TABLE '
130
            . $this->getDb()->quoteTableName($table)
131
            . ' MODIFY '
132
            . $this->getDb()->quoteColumnName($column)
133
            . ' ' . $this->getColumnType($type);
134
    }
135
136
    /**
137
     * Builds a SQL statement for dropping an index.
138
     *
139
     * @param string $name the name of the index to be dropped. The name will be properly quoted by the method.
140
     * @param string $table the table whose index is to be dropped. The name will be properly quoted by the method.
141
     *
142
     * @return string the SQL statement for dropping an index.
143
     */
144 2
    public function dropIndex(string $name, string $table): string
145
    {
146 2
        return 'DROP INDEX ' . $this->getDb()->quoteTableName($name);
147
    }
148
149
    /**
150
     * Creates a SQL statement for resetting the sequence value of a table's primary key.
151
     *
152
     * The sequence will be reset such that the primary key of the next new row inserted will have the specified value
153
     * or 1.
154
     *
155
     * @param string $tableName the name of the table whose primary key sequence will be reset.
156
     * @param array|string|null $value the value for the primary key of the next new row inserted. If this is not set,
157
     * the next new row's primary key will have a value 1.
158
     *
159
     * @throws Exception|InvalidArgumentException|InvalidConfigException|Throwable
160
     */
161 1
    public function executeResetSequence(string $tableName, $value = null): void
162
    {
163 1
        $tableSchema = $this->getDb()->getTableSchema($tableName);
164
165 1
        if ($tableSchema === null) {
166
            throw new InvalidArgumentException("Unknown table: $tableName");
167
        }
168
169 1
        if ($tableSchema->getSequenceName() === null) {
170
            throw new InvalidArgumentException("There is no sequence associated with table: $tableName");
171
        }
172
173 1
        if ($value !== null) {
174 1
            $value = (int) $value;
175
        } else {
176 1
            if (count($tableSchema->getPrimaryKey()) > 1) {
177
                throw new InvalidArgumentException(
178
                    "Can't reset sequence for composite primary key in table: $tableName"
179
                );
180
            }
181
            /** use master connection to get the biggest PK value */
182 1
            $value = $this->getDb()->useMaster(static function (Connection $db) use ($tableSchema) {
183 1
                return $db->createCommand(
184 1
                    'SELECT MAX("' . $tableSchema->getPrimaryKey()[0] . '") FROM "' . $tableSchema->getName() . '"'
185 1
                )->queryScalar();
186 1
            }) + 1;
187
        }
188
189
        /**
190
         *  Oracle needs at least two queries to reset sequence (see adding transactions and/or use alter method to
191
         *  avoid grants' issue?)
192
         */
193 1
        $this->getDb()->createCommand('DROP SEQUENCE "' . $tableSchema->getSequenceName() . '"')->execute();
194 1
        $this->getDb()->createCommand(
195
            'CREATE SEQUENCE "' .
196 1
            $tableSchema->getSequenceName() .
197 1
            '" START WITH ' .
198 1
            $value .
199 1
            ' INCREMENT BY 1 NOMAXVALUE NOCACHE'
200 1
        )->execute();
201 1
    }
202
203 4
    public function addForeignKey(
204
        string $name,
205
        string $table,
206
        $columns,
207
        string $refTable,
208
        $refColumns,
209
        ?string $delete = null,
210
        ?string $update = null
211
    ): string {
212 4
        $sql = 'ALTER TABLE ' . $this->getDb()->quoteTableName($table)
213 4
            . ' ADD CONSTRAINT ' . $this->getDb()->quoteColumnName($name)
214 4
            . ' FOREIGN KEY (' . $this->buildColumns($columns) . ')'
215 4
            . ' REFERENCES ' . $this->getDb()->quoteTableName($refTable)
216 4
            . ' (' . $this->buildColumns($refColumns) . ')';
217
218 4
        if ($delete !== null) {
219 2
            $sql .= ' ON DELETE ' . $delete;
220
        }
221
222 4
        if ($update !== null) {
223
            throw new Exception('Oracle does not support ON UPDATE clause.');
224
        }
225
226 4
        return $sql;
227
    }
228
229 38
    protected function prepareInsertValues(string $table, $columns, array $params = []): array
230
    {
231 38
        [$names, $placeholders, $values, $params] = parent::prepareInsertValues($table, $columns, $params);
232
233 35
        if (!$columns instanceof Query && empty($names)) {
234
            $tableSchema = $this->getDb()->getSchema()->getTableSchema($table);
235
236
            if ($tableSchema !== null) {
237
                $tableColumns = $tableSchema->getColumns();
238
                $columns = !empty($tableSchema->getPrimaryKey())
239
                    ? $tableSchema->getPrimaryKey() : [reset($tableColumns)->getName()];
240
                foreach ($columns as $name) {
241
                    $names[] = $this->getDb()->quoteColumnName($name);
242
                    $placeholders[] = 'DEFAULT';
243
                }
244
            }
245
        }
246
247 35
        return [$names, $placeholders, $values, $params];
248
    }
249
250
    /**
251
     * {@see https://docs.oracle.com/cd/B28359_01/server.111/b28286/statements_9016.htm#SQLRF01606}
252
     *
253
     * @param string $table
254
     * @param $insertColumns
255
     * @param $updateColumns
256
     * @param array $params
257
     *
258
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
259
     *
260
     * @return string
261
     */
262 17
    public function upsert(string $table, $insertColumns, $updateColumns, array &$params = []): string
263
    {
264 17
        $constraints = [];
265
266
        /** @var Constraint[] $constraints */
267 17
        [$uniqueNames, $insertNames, $updateNames] = $this->prepareUpsertColumns(
268 17
            $table,
269
            $insertColumns,
270
            $updateColumns,
271
            $constraints
272
        );
273
274 17
        if (empty($uniqueNames)) {
275 3
            return $this->insert($table, $insertColumns, $params);
276
        }
277
278 14
        if ($updateNames === []) {
279
            /** there are no columns to update */
280
            $updateColumns = false;
281
        }
282
283 14
        $onCondition = ['or'];
284 14
        $quotedTableName = $this->getDb()->quoteTableName($table);
285
286 14
        foreach ($constraints as $constraint) {
287 14
            $constraintCondition = ['and'];
288 14
            foreach ($constraint->getColumnNames() as $name) {
289 14
                $quotedName = $this->getDb()->quoteColumnName($name);
290 14
                $constraintCondition[] = "$quotedTableName.$quotedName=\"EXCLUDED\".$quotedName";
291
            }
292
293 14
            $onCondition[] = $constraintCondition;
294
        }
295
296 14
        $on = $this->buildCondition($onCondition, $params);
297
298 14
        [, $placeholders, $values, $params] = $this->prepareInsertValues($table, $insertColumns, $params);
299
300 14
        if (!empty($placeholders)) {
301 6
            $usingSelectValues = [];
302 6
            foreach ($insertNames as $index => $name) {
303 6
                $usingSelectValues[$name] = new Expression($placeholders[$index]);
304
            }
305
306 6
            $usingSubQuery = (new Query($this->getDb()))
307 6
                ->select($usingSelectValues)
308 6
                ->from('DUAL');
309
310 6
            [$usingValues, $params] = $this->build($usingSubQuery, $params);
311
        }
312
313 14
        $mergeSql = 'MERGE INTO ' . $this->getDb()->quoteTableName($table) . ' '
314 14
            . 'USING (' . ($usingValues ?? ltrim($values, ' ')) . ') "EXCLUDED" '
315 14
            . "ON ($on)";
316
317 14
        $insertValues = [];
318 14
        foreach ($insertNames as $name) {
319 14
            $quotedName = $this->getDb()->quoteColumnName($name);
320
321 14
            if (strrpos($quotedName, '.') === false) {
322 14
                $quotedName = '"EXCLUDED".' . $quotedName;
323
            }
324
325 14
            $insertValues[] = $quotedName;
326
        }
327
328 14
        $insertSql = 'INSERT (' . implode(', ', $insertNames) . ')'
329 14
            . ' VALUES (' . implode(', ', $insertValues) . ')';
330
331 14
        if ($updateColumns === false) {
332 4
            return "$mergeSql WHEN NOT MATCHED THEN $insertSql";
333
        }
334
335 10
        if ($updateColumns === true) {
336 4
            $updateColumns = [];
337 4
            foreach ($updateNames as $name) {
338 4
                $quotedName = $this->getDb()->quoteColumnName($name);
339
340 4
                if (strrpos($quotedName, '.') === false) {
341 4
                    $quotedName = '"EXCLUDED".' . $quotedName;
342
                }
343 4
                $updateColumns[$name] = new Expression($quotedName);
344
            }
345
        }
346
347 10
        [$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);
348
349 10
        $updateSql = 'UPDATE SET ' . implode(', ', $updates);
350
351 10
        return "$mergeSql WHEN MATCHED THEN $updateSql WHEN NOT MATCHED THEN $insertSql";
352
    }
353
354
    /**
355
     * Generates a batch INSERT SQL statement.
356
     *
357
     * For example,
358
     *
359
     * ```php
360
     * $sql = $queryBuilder->batchInsert('user', ['name', 'age'], [
361
     *     ['Tom', 30],
362
     *     ['Jane', 20],
363
     *     ['Linda', 25],
364
     * ]);
365
     * ```
366
     *
367
     * Note that the values in each row must match the corresponding column names.
368
     *
369
     * @param string $table the table that new rows will be inserted into.
370
     * @param array $columns the column names.
371
     * @param array|Generator $rows the rows to be batch inserted into the table.
372
     * @param array $params
373
     *
374
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
375
     *
376
     * @return string the batch INSERT SQL statement.
377
     */
378 14
    public function batchInsert(string $table, array $columns, $rows, array &$params = []): string
379
    {
380 14
        if (empty($rows)) {
381 2
            return '';
382
        }
383
384 13
        $schema = $this->getDb()->getSchema();
385
386 13
        if (($tableSchema = $schema->getTableSchema($table)) !== null) {
387 9
            $columnSchemas = $tableSchema->getColumns();
388
        } else {
389 4
            $columnSchemas = [];
390
        }
391
392 13
        $values = [];
393
394 13
        foreach ($rows as $row) {
395 12
            $vs = [];
396 12
            foreach ($row as $i => $value) {
397 12
                if (isset($columns[$i], $columnSchemas[$columns[$i]])) {
398 5
                    $value = $columnSchemas[$columns[$i]]->dbTypecast($value);
399
                }
400
401 12
                if (is_string($value)) {
402 8
                    $value = $schema->quoteValue($value);
403 7
                } elseif (is_float($value)) {
404
                    /* ensure type cast always has . as decimal separator in all locales */
405 1
                    $value = NumericHelper::normalize($value);
406 7
                } elseif ($value === false) {
407 3
                    $value = 0;
408 7
                } elseif ($value === null) {
409 4
                    $value = 'NULL';
410 4
                } elseif ($value instanceof ExpressionInterface) {
411 3
                    $value = $this->buildExpression($value, $params);
412
                }
413
414 12
                $vs[] = $value;
415
            }
416
417 12
            $values[] = '(' . implode(', ', $vs) . ')';
418
        }
419
420 13
        if (empty($values)) {
421 1
            return '';
422
        }
423
424 12
        foreach ($columns as $i => $name) {
425 11
            $columns[$i] = $schema->quoteColumnName($name);
426
        }
427
428 12
        $tableAndColumns = ' INTO ' . $schema->quoteTableName($table)
429 12
            . ' (' . implode(', ', $columns) . ') VALUES ';
430
431 12
        return 'INSERT ALL ' . $tableAndColumns . implode($tableAndColumns, $values) . ' SELECT 1 FROM SYS.DUAL';
432
    }
433
434 1
    public function selectExists(string $rawSql): string
435
    {
436 1
        return 'SELECT CASE WHEN EXISTS(' . $rawSql . ') THEN 1 ELSE 0 END FROM DUAL';
437
    }
438
439 2
    public function dropCommentFromColumn(string $table, string $column): string
440
    {
441 2
        return 'COMMENT ON COLUMN ' . $this->getDb()->quoteTableName($table) . '.' . $this->getDb()->quoteColumnName($column) . " IS ''";
442
    }
443
444 1
    public function dropCommentFromTable(string $table): string
445
    {
446 1
        return 'COMMENT ON TABLE ' . $this->getDb()->quoteTableName($table) . " IS ''";
447
    }
448
}
449