Passed
Pull Request — master (#15)
by Wilmer
01:39
created

QueryBuilder::executeResetSequence()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 40
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

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