Passed
Push — master ( 064e07...085f00 )
by Sergei
29:05 queued 26:47
created

prepareBatchInsertValues()   A

Complexity

Conditions 6
Paths 10

Size

Total Lines 33
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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