Passed
Pull Request — master (#795)
by Sergei
20:23 queued 17:56
created

AbstractDMLQueryBuilder::prepareUpdateSets()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

465
    protected function normalizeColumnNames(/** @scrutinizer ignore-unused */ string $table, array $columns): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
466
    {
467
        /** @var string[] $columnNames */
468
        $columnNames = array_keys($columns);
469
        $normalizedNames = $this->getNormalizeColumnNames('', $columnNames);
470
471
        return array_combine($normalizedNames, $columns);
472
    }
473
474
    /**
475
     * Get normalized column names
476
     *
477
     * @param string $table Not used. Could be empty string. Will be removed in version 2.0.0.
478
     * @param string[] $columns The column names.
479
     *
480
     * @return string[] Normalized column names.
481
     */
482
    protected function getNormalizeColumnNames(string $table, array $columns): array
0 ignored issues
show
Unused Code introduced by
The parameter $table is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

482
    protected function getNormalizeColumnNames(/** @scrutinizer ignore-unused */ string $table, array $columns): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
483
    {
484
        foreach ($columns as &$name) {
485
            $name = $this->quoter->ensureColumnName($name);
486
            $name = $this->quoter->unquoteSimpleColumnName($name);
487
        }
488
489
        return $columns;
490
    }
491
}
492