Passed
Pull Request — master (#727)
by Wilmer
02:16
created

AbstractDMLQueryBuilder::quoteCondition()   A

Complexity

Conditions 5
Paths 7

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 5
eloc 7
c 3
b 0
f 0
nc 7
nop 1
dl 0
loc 15
rs 9.6111
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\QueryBuilder;
6
7
use Generator;
8
use JsonException;
9
use Yiisoft\Db\Constraint\Constraint;
10
use Yiisoft\Db\Constraint\IndexConstraint;
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\ExpressionInterface;
16
use Yiisoft\Db\Query\QueryInterface;
17
use Yiisoft\Db\Schema\ColumnSchemaInterface;
18
use Yiisoft\Db\Schema\QuoterInterface;
19
use Yiisoft\Db\Schema\SchemaInterface;
20
21
use function array_combine;
22
use function array_diff;
23
use function array_filter;
24
use function array_keys;
25
use function array_map;
26
use function array_merge;
27
use function array_unique;
28
use function array_values;
29
use function count;
30
use function implode;
31
use function in_array;
32
use function is_array;
33
use function is_string;
34
use function json_encode;
35
use function preg_match;
36
use function sort;
37
38
/**
39
 * It's used to manipulate data in tables.
40
 *
41
 * This manipulation involves inserting data into database tables, retrieving existing data, deleting data from existing
42
 * tables and modifying existing data.
43
 *
44
 * @link https://en.wikipedia.org/wiki/Data_manipulation_language
45
 */
46
abstract class AbstractDMLQueryBuilder implements DMLQueryBuilderInterface
47
{
48
    public function __construct(
49
        protected QueryBuilderInterface $queryBuilder,
50
        protected QuoterInterface $quoter,
51
        protected SchemaInterface $schema
52
    ) {
53
    }
54
55
    public function batchInsert(string $table, array $columns, iterable|Generator $rows, array &$params = []): string
56
    {
57
        if (empty($rows)) {
58
            return '';
59
        }
60
61
        if (($tableSchema = $this->schema->getTableSchema($table)) !== null) {
62
            $columnSchemas = $tableSchema->getColumns();
63
        } else {
64
            $columnSchemas = [];
65
        }
66
67
        $mappedNames = $this->getNormalizeColumnNames($table, $columns);
68
        $values = [];
69
70
        /** @psalm-var array<array-key, array<array-key, string>> $rows */
71
        foreach ($rows as $row) {
72
            $placeholders = [];
73
            foreach ($row as $index => $value) {
74
                if (isset($columns[$index], $mappedNames[$columns[$index]], $columnSchemas[$mappedNames[$columns[$index]]])) {
75
                    /** @psalm-var mixed $value */
76
                    $value = $this->getTypecastValue($value, $columnSchemas[$mappedNames[$columns[$index]]]);
77
                }
78
79
                if ($value instanceof ExpressionInterface) {
80
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
81
                } else {
82
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
83
                }
84
            }
85
            $values[] = '(' . implode(', ', $placeholders) . ')';
86
        }
87
88
        if (empty($values)) {
89
            return '';
90
        }
91
92
        foreach ($columns as $i => $name) {
93
            $columns[$i] = $this->quoter->quoteColumnName($mappedNames[$name]);
94
        }
95
96
        return 'INSERT INTO '
97
            . $this->quoter->quoteTableName($table)
98
            . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values);
99
    }
100
101
    public function delete(string $table, array|string $condition, array &$params): string
102
    {
103
        $sql = 'DELETE FROM ' . $this->quoter->quoteTableName($table);
104
        $condition = $this->quoteCondition($condition);
105
        $where = $this->queryBuilder->buildWhere($condition, $params);
106
107
        return $where === '' ? $sql : $sql . ' ' . $where;
108
    }
109
110
    public function insert(string $table, QueryInterface|array $columns, array &$params = []): string
111
    {
112
        /**
113
         * @psalm-var string[] $names
114
         * @psalm-var string[] $placeholders
115
         * @psalm-var string $values
116
         */
117
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
118
119
        return 'INSERT INTO '
120
            . $this->quoter->quoteTableName($table)
121
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
122
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : $values);
123
    }
124
125
    public function insertWithReturningPks(string $table, QueryInterface|array $columns, array &$params = []): string
126
    {
127
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
128
    }
129
130
    public function resetSequence(string $table, int|string|null $value = null): string
131
    {
132
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
133
    }
134
135
    public function update(string $table, array $columns, array|string $condition, array &$params = []): string
136
    {
137
        /** @psalm-var string[] $lines */
138
        [$lines, $params] = $this->prepareUpdateSets($table, $columns, $params);
139
        $sql = 'UPDATE ' . $this->quoter->quoteTableName($table) . ' SET ' . implode(', ', $lines);
140
        $condition = $this->quoteCondition($condition);
141
        /** @psalm-var array $params */
142
        $where = $this->queryBuilder->buildWhere($condition, $params);
143
144
        return $where === '' ? $sql : $sql . ' ' . $where;
145
    }
146
147
    public function upsert(
148
        string $table,
149
        QueryInterface|array $insertColumns,
150
        bool|array $updateColumns,
151
        array &$params
152
    ): string {
153
        throw new NotSupportedException(__METHOD__ . ' is not supported by this DBMS.');
154
    }
155
156
    /**
157
     * Prepare select-subQuery and field names for `INSERT INTO ... SELECT` SQL statement.
158
     *
159
     * @param QueryInterface $columns Object, which represents a select query.
160
     * @param array $params The parameters to bind to the generated SQL statement. These parameters will be included
161
     * in the result, with the more parameters generated during the query building process.
162
     *
163
     * @throws Exception
164
     * @throws InvalidArgumentException
165
     * @throws InvalidConfigException
166
     * @throws NotSupportedException
167
     *
168
     * @return array Array of column names, values, and params.
169
     */
170
    protected function prepareInsertSelectSubQuery(QueryInterface $columns, array $params = []): array
171
    {
172
        if (empty($columns->getSelect()) || in_array('*', $columns->getSelect(), true)) {
173
            throw new InvalidArgumentException('Expected select query object with enumerated (named) parameters');
174
        }
175
176
        [$values, $params] = $this->queryBuilder->build($columns, $params);
177
178
        $names = [];
179
        $values = ' ' . $values;
180
        /** @psalm-var string[] $select */
181
        $select = $columns->getSelect();
182
183
        foreach ($select as $title => $field) {
184
            if (is_string($title)) {
185
                $names[] = $this->quoter->quoteColumnName($title);
186
            } else {
187
                if ($field instanceof ExpressionInterface) {
188
                    $field = $this->queryBuilder->buildExpression($field, $params);
189
                }
190
191
                if (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $field, $matches)) {
192
                    $names[] = $this->quoter->quoteColumnName($matches[2]);
193
                } else {
194
                    $names[] = $this->quoter->quoteColumnName($field);
195
                }
196
            }
197
        }
198
199
        return [$names, $values, $params];
200
    }
201
202
    /**
203
     * Prepare column names and placeholders for `INSERT` SQL statement.
204
     *
205
     * @throws Exception
206
     * @throws InvalidConfigException
207
     * @throws InvalidArgumentException
208
     * @throws NotSupportedException
209
     */
210
    protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array
211
    {
212
        $tableSchema = $this->schema->getTableSchema($table);
213
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
214
        $names = [];
215
        $placeholders = [];
216
        $values = ' DEFAULT VALUES';
217
218
        if ($columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
219
            [$names, $values, $params] = $this->prepareInsertSelectSubQuery($columns, $params);
220
        } else {
221
            $columns = $this->normalizeColumnNames($table, $columns);
222
            /**
223
             * @psalm-var mixed $value
224
             * @psalm-var array<string, mixed> $columns
225
             */
226
            foreach ($columns as $name => $value) {
227
                $names[] = $this->quoter->quoteColumnName($name);
228
                /** @var mixed $value */
229
                $value = $this->getTypecastValue($value, $columnSchemas[$name] ?? null);
230
231
                if ($value instanceof ExpressionInterface) {
232
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
233
                } else {
234
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
235
                }
236
            }
237
        }
238
239
        return [$names, $placeholders, $values, $params];
240
    }
241
242
    /**
243
     * Prepare column names and placeholders for `UPDATE` SQL statement.
244
     *
245
     * @throws Exception
246
     * @throws InvalidConfigException
247
     * @throws InvalidArgumentException
248
     * @throws NotSupportedException
249
     */
250
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
251
    {
252
        $tableSchema = $this->schema->getTableSchema($table);
253
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
254
        $sets = [];
255
        $columns = $this->normalizeColumnNames($table, $columns);
256
257
        /**
258
         * @psalm-var array<string, mixed> $columns
259
         * @psalm-var mixed $value
260
         */
261
        foreach ($columns as $name => $value) {
262
            /** @psalm-var mixed $value */
263
            $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
264
            if ($value instanceof ExpressionInterface) {
265
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
266
            } else {
267
                $placeholder = $this->queryBuilder->bindParam($value, $params);
268
            }
269
270
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
271
        }
272
273
        return [$sets, $params];
274
    }
275
276
    /**
277
     * Prepare column names and placeholders for "upsert" operation.
278
     *
279
     * @throws Exception
280
     * @throws InvalidArgumentException
281
     * @throws InvalidConfigException
282
     * @throws JsonException
283
     * @throws NotSupportedException
284
     *
285
     * @psalm-param Constraint[] $constraints
286
     */
287
    protected function prepareUpsertColumns(
288
        string $table,
289
        QueryInterface|array $insertColumns,
290
        QueryInterface|bool|array $updateColumns,
291
        array &$constraints = []
292
    ): array {
293
        $insertNames = [];
294
295
        if (!$insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
296
            $insertColumns = $this->normalizeColumnNames($table, $insertColumns);
297
        }
298
299
        if (is_array($updateColumns)) {
0 ignored issues
show
introduced by
The condition is_array($updateColumns) is always true.
Loading history...
300
            $updateColumns = $this->normalizeColumnNames($table, $updateColumns);
301
        }
302
303
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
304
            /** @psalm-var list<string> $insertNames */
305
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
306
        } else {
307
            /** @psalm-var array<string, string> $insertColumns */
308
            foreach ($insertColumns as $key => $_value) {
309
                $insertNames[] = $this->quoter->quoteColumnName($key);
310
            }
311
        }
312
313
        /** @psalm-var string[] $uniqueNames */
314
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
315
316
        foreach ($uniqueNames as $key => $name) {
317
            $insertNames[$key] = $this->quoter->quoteColumnName($name);
318
        }
319
320
        if ($updateColumns !== true) {
0 ignored issues
show
introduced by
The condition $updateColumns !== true is always true.
Loading history...
321
            return [$uniqueNames, $insertNames, null];
322
        }
323
324
        return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
325
    }
326
327
    /**
328
     * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
329
     * for the named table removing constraints which didn't cover the specified column list.
330
     *
331
     * The column list will be unique by column names.
332
     *
333
     * @param string $name The table name, may contain schema name if any. Don't quote the table name.
334
     * @param string[] $columns Source column list.
335
     * @param array $constraints This parameter optionally receives a matched constraint list. The constraints
336
     * will be unique by their column names.
337
     *
338
     * @throws JsonException
339
     *
340
     * @return array The column list.
341
     *
342
     * @psalm-param Constraint[] $constraints
343
    */
344
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
345
    {
346
        $primaryKey = $this->schema->getTablePrimaryKey($name);
347
348
        if ($primaryKey !== null) {
349
            $constraints[] = $primaryKey;
350
        }
351
352
        /** @psalm-var IndexConstraint[] $tableIndexes */
353
        $tableIndexes = $this->schema->getTableIndexes($name);
354
355
        foreach ($tableIndexes as $constraint) {
356
            if ($constraint->isUnique()) {
357
                $constraints[] = $constraint;
358
            }
359
        }
360
361
        $constraints = array_merge($constraints, $this->schema->getTableUniques($name));
362
363
        /**
364
         * Remove duplicates
365
         *
366
         * @psalm-var Constraint[] $constraints
367
         */
368
        $constraints = array_combine(
369
            array_map(
370
                static function (Constraint $constraint) {
371
                    $columns = $constraint->getColumnNames() ?? [];
372
                    $columns = is_array($columns) ? $columns : [$columns];
373
                    sort($columns, SORT_STRING);
374
                    return json_encode($columns, JSON_THROW_ON_ERROR);
375
                },
376
                $constraints
377
            ),
378
            $constraints
379
        );
380
381
        $columnNames = [];
382
        $quoter = $this->quoter;
383
384
        // Remove all constraints which don't cover the specified column list.
385
        $constraints = array_values(
386
            array_filter(
387
                $constraints,
388
                static function (Constraint $constraint) use ($quoter, $columns, &$columnNames) {
389
                    /** @psalm-var string[]|string $getColumnNames */
390
                    $getColumnNames = $constraint->getColumnNames() ?? [];
391
                    $constraintColumnNames = [];
392
393
                    if (is_array($getColumnNames)) {
394
                        foreach ($getColumnNames as $columnName) {
395
                            $constraintColumnNames[] = $quoter->quoteColumnName($columnName);
396
                        }
397
                    }
398
399
                    $result = !array_diff($constraintColumnNames, $columns);
400
401
                    if ($result) {
402
                        $columnNames = array_merge((array) $columnNames, $constraintColumnNames);
403
                    }
404
405
                    return $result;
406
                }
407
            )
408
        );
409
410
        /** @psalm-var Constraint[] $columnNames */
411
        return array_unique($columnNames);
412
    }
413
414
    /**
415
     * @return mixed The typecast value of the given column.
416
     */
417
    protected function getTypecastValue(mixed $value, ColumnSchemaInterface $columnSchema = null): mixed
418
    {
419
        if ($columnSchema) {
420
            return $columnSchema->dbTypecast($value);
421
        }
422
423
        return $value;
424
    }
425
426
    /**
427
     * Normalizes the column names for the given table.
428
     *
429
     * @param string $table The table to save the data into.
430
     * @param array $columns The column data (name => value) to save into the table or instance of
431
     * {@see QueryInterface} to perform `INSERT INTO ... SELECT` SQL statement. Passing of {@see QueryInterface}.
432
     *
433
     * @return array The normalized column names (name => value).
434
     */
435
    protected function normalizeColumnNames(string $table, array $columns): array
436
    {
437
        /** @var string[] $columnList */
438
        $columnList = array_keys($columns);
439
        $mappedNames = $this->getNormalizeColumnNames($table, $columnList);
440
441
        /** @psalm-var array $normalizedColumns */
442
        $normalizedColumns = [];
443
444
        /**
445
         * @psalm-var string $name
446
         * @psalm-var mixed $value
447
         */
448
        foreach ($columns as $name => $value) {
449
            $mappedName = $mappedNames[$name] ?? $name;
450
            /** @psalm-var mixed */
451
            $normalizedColumns[$mappedName] = $value;
452
        }
453
454
        return $normalizedColumns;
455
    }
456
457
    /**
458
     * Get a map of normalized columns
459
     *
460
     * @param string $table The table to save the data into.
461
     * @param string[] $columns The column data (name => value) to save into the table or instance of
462
     * {@see QueryInterface} to perform `INSERT INTO ... SELECT` SQL statement. Passing of {@see QueryInterface}.
463
     *
464
     * @return string[] Map of normalized columns.
465
     */
466
    protected function getNormalizeColumnNames(string $table, array $columns): array
467
    {
468
        $normalizedNames = [];
469
        $rawTableName = $this->schema->getRawTableName($table);
470
471
        foreach ($columns as $name) {
472
            $parts = $this->quoter->getTableNameParts($name, true);
473
474
            if (count($parts) === 2 && $this->schema->getRawTableName($parts[0]) === $rawTableName) {
475
                $normalizedName = $parts[count($parts) - 1];
476
            } else {
477
                $normalizedName = $name;
478
            }
479
            $normalizedName = $this->quoter->ensureColumnName($normalizedName);
480
481
            $normalizedNames[$name] = $normalizedName;
482
        }
483
484
        return $normalizedNames;
485
    }
486
487
    private function quoteCondition(array|string $values): array|string
488
    {
489
        if (is_string($values)) {
0 ignored issues
show
introduced by
The condition is_string($values) is always false.
Loading history...
490
            return $values;
491
        }
492
493
        $quoted = [];
494
495
        foreach ($values as $key => $value) {
496
            if (is_string($key)) {
497
                $quoted[$this->quoter->quoteColumnName($key)] = $value;
498
            }
499
        }
500
501
        return $quoted !== [] ? $quoted : $values;
502
    }
503
}
504