Passed
Pull Request — master (#746)
by Sergei
13:31
created

AbstractDMLQueryBuilder::prepareUpdateSets()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 26
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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