Passed
Pull Request — master (#795)
by Sergei
14:42 queued 12:27
created

AbstractDMLQueryBuilder::batchInsert()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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

486
    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...
487
    {
488
        /** @var string[] $columnNames */
489
        $columnNames = array_keys($columns);
490
        $normalizedNames = $this->getNormalizeColumnNames('', $columnNames);
491
492
        return array_combine($normalizedNames, $columns);
493
    }
494
495
    /**
496
     * Get normalized column names
497
     *
498
     * @param string $table Not used. Could be empty string. Will be removed in version 2.0.0.
499
     * @param string[] $columns The column names.
500
     *
501
     * @return string[] Normalized column names.
502
     */
503
    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

503
    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...
504
    {
505
        foreach ($columns as &$name) {
506
            $name = $this->quoter->ensureColumnName($name);
507
            $name = $this->quoter->unquoteSimpleColumnName($name);
508
        }
509
510
        return $columns;
511
    }
512
}
513