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

AbstractDMLQueryBuilder::quoteCondition()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 4
eloc 6
c 2
b 0
f 0
nc 5
nop 1
dl 0
loc 13
rs 10
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
        /** @psalm-var array $params */
141
        $where = $this->queryBuilder->buildWhere($condition, $params);
142
143
        return $where === '' ? $sql : $sql . ' ' . $where;
144
    }
145
146
    public function upsert(
147
        string $table,
148
        QueryInterface|array $insertColumns,
149
        bool|array $updateColumns,
150
        array &$params
151
    ): string {
152
        throw new NotSupportedException(__METHOD__ . ' is not supported by this DBMS.');
153
    }
154
155
    /**
156
     * Prepare select-subQuery and field names for `INSERT INTO ... SELECT` SQL statement.
157
     *
158
     * @param QueryInterface $columns Object, which represents a select query.
159
     * @param array $params The parameters to bind to the generated SQL statement. These parameters will be included
160
     * in the result, with the more parameters generated during the query building process.
161
     *
162
     * @throws Exception
163
     * @throws InvalidArgumentException
164
     * @throws InvalidConfigException
165
     * @throws NotSupportedException
166
     *
167
     * @return array Array of column names, values, and params.
168
     */
169
    protected function prepareInsertSelectSubQuery(QueryInterface $columns, array $params = []): array
170
    {
171
        if (empty($columns->getSelect()) || in_array('*', $columns->getSelect(), true)) {
172
            throw new InvalidArgumentException('Expected select query object with enumerated (named) parameters');
173
        }
174
175
        [$values, $params] = $this->queryBuilder->build($columns, $params);
176
177
        $names = [];
178
        $values = ' ' . $values;
179
        /** @psalm-var string[] $select */
180
        $select = $columns->getSelect();
181
182
        foreach ($select as $title => $field) {
183
            if (is_string($title)) {
184
                $names[] = $this->quoter->quoteColumnName($title);
185
            } else {
186
                if ($field instanceof ExpressionInterface) {
187
                    $field = $this->queryBuilder->buildExpression($field, $params);
188
                }
189
190
                if (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $field, $matches)) {
191
                    $names[] = $this->quoter->quoteColumnName($matches[2]);
192
                } else {
193
                    $names[] = $this->quoter->quoteColumnName($field);
194
                }
195
            }
196
        }
197
198
        return [$names, $values, $params];
199
    }
200
201
    /**
202
     * Prepare column names and placeholders for `INSERT` SQL statement.
203
     *
204
     * @throws Exception
205
     * @throws InvalidConfigException
206
     * @throws InvalidArgumentException
207
     * @throws NotSupportedException
208
     */
209
    protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array
210
    {
211
        $tableSchema = $this->schema->getTableSchema($table);
212
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
213
        $names = [];
214
        $placeholders = [];
215
        $values = ' DEFAULT VALUES';
216
217
        if ($columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
218
            [$names, $values, $params] = $this->prepareInsertSelectSubQuery($columns, $params);
219
        } else {
220
            $columns = $this->normalizeColumnNames($table, $columns);
221
            /**
222
             * @psalm-var mixed $value
223
             * @psalm-var array<string, mixed> $columns
224
             */
225
            foreach ($columns as $name => $value) {
226
                $names[] = $this->quoter->quoteColumnName($name);
227
                /** @var mixed $value */
228
                $value = $this->getTypecastValue($value, $columnSchemas[$name] ?? null);
229
230
                if ($value instanceof ExpressionInterface) {
231
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
232
                } else {
233
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
234
                }
235
            }
236
        }
237
238
        return [$names, $placeholders, $values, $params];
239
    }
240
241
    /**
242
     * Prepare column names and placeholders for `UPDATE` SQL statement.
243
     *
244
     * @throws Exception
245
     * @throws InvalidConfigException
246
     * @throws InvalidArgumentException
247
     * @throws NotSupportedException
248
     */
249
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
250
    {
251
        $tableSchema = $this->schema->getTableSchema($table);
252
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
253
        $sets = [];
254
        $columns = $this->normalizeColumnNames($table, $columns);
255
256
        /**
257
         * @psalm-var array<string, mixed> $columns
258
         * @psalm-var mixed $value
259
         */
260
        foreach ($columns as $name => $value) {
261
            /** @psalm-var mixed $value */
262
            $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
263
            if ($value instanceof ExpressionInterface) {
264
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
265
            } else {
266
                $placeholder = $this->queryBuilder->bindParam($value, $params);
267
            }
268
269
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
270
        }
271
272
        return [$sets, $params];
273
    }
274
275
    /**
276
     * Prepare column names and placeholders for "upsert" operation.
277
     *
278
     * @throws Exception
279
     * @throws InvalidArgumentException
280
     * @throws InvalidConfigException
281
     * @throws JsonException
282
     * @throws NotSupportedException
283
     *
284
     * @psalm-param Constraint[] $constraints
285
     */
286
    protected function prepareUpsertColumns(
287
        string $table,
288
        QueryInterface|array $insertColumns,
289
        QueryInterface|bool|array $updateColumns,
290
        array &$constraints = []
291
    ): array {
292
        $insertNames = [];
293
294
        if (!$insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
295
            $insertColumns = $this->normalizeColumnNames($table, $insertColumns);
296
        }
297
298
        if (is_array($updateColumns)) {
0 ignored issues
show
introduced by
The condition is_array($updateColumns) is always true.
Loading history...
299
            $updateColumns = $this->normalizeColumnNames($table, $updateColumns);
300
        }
301
302
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
303
            /** @psalm-var list<string> $insertNames */
304
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
305
        } else {
306
            /** @psalm-var array<string, string> $insertColumns */
307
            foreach ($insertColumns as $key => $_value) {
308
                $insertNames[] = $this->quoter->quoteColumnName($key);
309
            }
310
        }
311
312
        /** @psalm-var string[] $uniqueNames */
313
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
314
315
        foreach ($uniqueNames as $key => $name) {
316
            $insertNames[$key] = $this->quoter->quoteColumnName($name);
317
        }
318
319
        if ($updateColumns !== true) {
0 ignored issues
show
introduced by
The condition $updateColumns !== true is always true.
Loading history...
320
            return [$uniqueNames, $insertNames, null];
321
        }
322
323
        return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
324
    }
325
326
    /**
327
     * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
328
     * for the named table removing constraints which didn't cover the specified column list.
329
     *
330
     * The column list will be unique by column names.
331
     *
332
     * @param string $name The table name, may contain schema name if any. Don't quote the table name.
333
     * @param string[] $columns Source column list.
334
     * @param array $constraints This parameter optionally receives a matched constraint list. The constraints
335
     * will be unique by their column names.
336
     *
337
     * @throws JsonException
338
     *
339
     * @return array The column list.
340
     *
341
     * @psalm-param Constraint[] $constraints
342
    */
343
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
344
    {
345
        $primaryKey = $this->schema->getTablePrimaryKey($name);
346
347
        if ($primaryKey !== null) {
348
            $constraints[] = $primaryKey;
349
        }
350
351
        /** @psalm-var IndexConstraint[] $tableIndexes */
352
        $tableIndexes = $this->schema->getTableIndexes($name);
353
354
        foreach ($tableIndexes as $constraint) {
355
            if ($constraint->isUnique()) {
356
                $constraints[] = $constraint;
357
            }
358
        }
359
360
        $constraints = array_merge($constraints, $this->schema->getTableUniques($name));
361
362
        /**
363
         * Remove duplicates
364
         *
365
         * @psalm-var Constraint[] $constraints
366
         */
367
        $constraints = array_combine(
368
            array_map(
369
                static function (Constraint $constraint) {
370
                    $columns = $constraint->getColumnNames() ?? [];
371
                    $columns = is_array($columns) ? $columns : [$columns];
372
                    sort($columns, SORT_STRING);
373
                    return json_encode($columns, JSON_THROW_ON_ERROR);
374
                },
375
                $constraints
376
            ),
377
            $constraints
378
        );
379
380
        $columnNames = [];
381
        $quoter = $this->quoter;
382
383
        // Remove all constraints which don't cover the specified column list.
384
        $constraints = array_values(
385
            array_filter(
386
                $constraints,
387
                static function (Constraint $constraint) use ($quoter, $columns, &$columnNames) {
388
                    /** @psalm-var string[]|string $getColumnNames */
389
                    $getColumnNames = $constraint->getColumnNames() ?? [];
390
                    $constraintColumnNames = [];
391
392
                    if (is_array($getColumnNames)) {
393
                        foreach ($getColumnNames as $columnName) {
394
                            $constraintColumnNames[] = $quoter->quoteColumnName($columnName);
395
                        }
396
                    }
397
398
                    $result = !array_diff($constraintColumnNames, $columns);
399
400
                    if ($result) {
401
                        $columnNames = array_merge((array) $columnNames, $constraintColumnNames);
402
                    }
403
404
                    return $result;
405
                }
406
            )
407
        );
408
409
        /** @psalm-var Constraint[] $columnNames */
410
        return array_unique($columnNames);
411
    }
412
413
    /**
414
     * @return mixed The typecast value of the given column.
415
     */
416
    protected function getTypecastValue(mixed $value, ColumnSchemaInterface $columnSchema = null): mixed
417
    {
418
        if ($columnSchema) {
419
            return $columnSchema->dbTypecast($value);
420
        }
421
422
        return $value;
423
    }
424
425
    /**
426
     * Normalizes the column names for the given table.
427
     *
428
     * @param string $table The table to save the data into.
429
     * @param array $columns The column data (name => value) to save into the table or instance of
430
     * {@see QueryInterface} to perform `INSERT INTO ... SELECT` SQL statement. Passing of {@see QueryInterface}.
431
     *
432
     * @return array The normalized column names (name => value).
433
     */
434
    protected function normalizeColumnNames(string $table, array $columns): array
435
    {
436
        /** @var string[] $columnList */
437
        $columnList = array_keys($columns);
438
        $mappedNames = $this->getNormalizeColumnNames($table, $columnList);
439
440
        /** @psalm-var array $normalizedColumns */
441
        $normalizedColumns = [];
442
443
        /**
444
         * @psalm-var string $name
445
         * @psalm-var mixed $value
446
         */
447
        foreach ($columns as $name => $value) {
448
            $mappedName = $mappedNames[$name] ?? $name;
449
            /** @psalm-var mixed */
450
            $normalizedColumns[$mappedName] = $value;
451
        }
452
453
        return $normalizedColumns;
454
    }
455
456
    /**
457
     * Get a map of normalized columns
458
     *
459
     * @param string $table The table to save the data into.
460
     * @param string[] $columns The column data (name => value) to save into the table or instance of
461
     * {@see QueryInterface} to perform `INSERT INTO ... SELECT` SQL statement. Passing of {@see QueryInterface}.
462
     *
463
     * @return string[] Map of normalized columns.
464
     */
465
    protected function getNormalizeColumnNames(string $table, array $columns): array
466
    {
467
        $normalizedNames = [];
468
        $rawTableName = $this->schema->getRawTableName($table);
469
470
        foreach ($columns as $name) {
471
            $parts = $this->quoter->getTableNameParts($name, true);
472
473
            if (count($parts) === 2 && $this->schema->getRawTableName($parts[0]) === $rawTableName) {
474
                $normalizedName = $parts[count($parts) - 1];
475
            } else {
476
                $normalizedName = $name;
477
            }
478
            $normalizedName = $this->quoter->ensureColumnName($normalizedName);
479
480
            $normalizedNames[$name] = $normalizedName;
481
        }
482
483
        return $normalizedNames;
484
    }
485
486
    private function quoteCondition(array|string $values): array|string
487
    {
488
        if (is_string($values)) {
0 ignored issues
show
introduced by
The condition is_string($values) is always false.
Loading history...
489
            return $values;
490
        }
491
492
        $quoted = [];
493
494
        foreach ($values as $key => $value) {
495
            $quoted[$this->quoter->quoteColumnName($key)] = $this->quoter->quoteValue($value);
496
        }
497
498
        return $quoted !== [] ? $quoted : $values;
499
    }
500
}
501