Passed
Pull Request — master (#380)
by Wilmer
04:10 queued 01:20
created

DMLQueryBuilder::batchInsert()   B

Complexity

Conditions 9
Paths 37

Size

Total Lines 44
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 24
c 0
b 0
f 0
nc 37
nop 4
dl 0
loc 44
rs 8.0555
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_map;
25
use function array_merge;
26
use function array_unique;
27
use function array_values;
28
use function implode;
29
use function in_array;
30
use function is_array;
31
use function is_string;
32
use function json_encode;
33
use function preg_match;
34
35
abstract class DMLQueryBuilder implements DMLQueryBuilderInterface
36
{
37
    public function __construct(
38
        private QueryBuilderInterface $queryBuilder,
39
        private QuoterInterface $quoter,
40
        private SchemaInterface $schema
41
    ) {
42
    }
43
44
    /**
45
     * @psalm-suppress MixedArrayOffset
46
     */
47
    public function batchInsert(string $table, array $columns, iterable|Generator $rows, array &$params = []): string
48
    {
49
        if (empty($rows)) {
50
            return '';
51
        }
52
53
        if (($tableSchema = $this->schema->getTableSchema($table)) !== null) {
54
            $columnSchemas = $tableSchema->getColumns();
55
        } else {
56
            $columnSchemas = [];
57
        }
58
59
        $values = [];
60
61
        /** @psalm-var array<array-key, array<array-key, string>> $rows */
62
        foreach ($rows as $row) {
63
            $placeholders = [];
64
            foreach ($row as $index => $value) {
65
                if (isset($columns[$index], $columnSchemas[$columns[$index]])) {
66
                    /** @var mixed $value */
67
                    $value = $this->getTypecastValue($value, $columnSchemas[$columns[$index]]);
68
                }
69
70
                if ($value instanceof ExpressionInterface) {
71
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
72
                } else {
73
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
74
                }
75
            }
76
            $values[] = '(' . implode(', ', $placeholders) . ')';
77
        }
78
79
        if (empty($values)) {
80
            return '';
81
        }
82
83
        /** @psalm-var string[] $columns */
84
        foreach ($columns as $i => $name) {
85
            $columns[$i] = $this->quoter->quoteColumnName($name);
86
        }
87
88
        return 'INSERT INTO '
89
            . $this->quoter->quoteTableName($table)
90
            . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values);
91
    }
92
93
    /**
94
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
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
    /**
105
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
106
     */
107
    public function insert(string $table, QueryInterface|array $columns, array &$params = []): string
108
    {
109
        /**
110
         * @psalm-var string[] $names
111
         * @psalm-var string[] $placeholders
112
         * @psalm-var string $values
113
         */
114
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
115
116
        return 'INSERT INTO '
117
            . $this->quoter->quoteTableName($table)
118
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
119
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : $values);
120
    }
121
122
    /**
123
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
124
     */
125
    public function insertEx(string $table, QueryInterface|array $columns, array &$params = []): string
126
    {
127
        return $this->insert($table, $columns, $params);
128
    }
129
130
    /**
131
     * @throws NotSupportedException
132
     */
133
    public function resetSequence(string $tableName, int|string|null $value = null): string
134
    {
135
        throw new NotSupportedException(static::class . ' does not support resetting sequence.');
136
    }
137
138
    /**
139
     * @psalm-suppress MixedArgument
140
     */
141
    public function update(string $table, array $columns, array|string $condition, array &$params = []): string
142
    {
143
        /** @psalm-var string[] $lines */
144
        [$lines, $params] = $this->prepareUpdateSets($table, $columns, $params);
145
        $sql = 'UPDATE ' . $this->quoter->quoteTableName($table) . ' SET ' . implode(', ', $lines);
146
        $where = $this->queryBuilder->buildWhere($condition, $params);
147
148
        return $where === '' ? $sql : $sql . ' ' . $where;
149
    }
150
151
    /**
152
     * @throws NotSupportedException
153
     */
154
    public function upsert(
155
        string $table,
156
        QueryInterface|array $insertColumns,
157
        bool|array $updateColumns,
158
        array &$params
159
    ): string {
160
        throw new NotSupportedException(static::class . ' does not support upsert.');
161
    }
162
163
    /**
164
     * Prepare select-subquery and field names for INSERT INTO ... SELECT SQL statement.
165
     *
166
     * @param QueryInterface $columns Object, which represents select query.
167
     * @param array $params the parameters to be bound to the generated SQL statement. These parameters will be included
168
     * in the result with the additional parameters generated during the query building process.
169
     *
170
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
171
     *
172
     * @return array array of column names, values and params.
173
     */
174
    protected function prepareInsertSelectSubQuery(QueryInterface $columns, array $params = []): array
175
    {
176
        if (empty($columns->getSelect()) || in_array('*', $columns->getSelect(), true)) {
177
            throw new InvalidArgumentException('Expected select query object with enumerated (named) parameters');
178
        }
179
180
        [$values, $params] = $this->queryBuilder->build($columns, $params);
181
182
        $names = [];
183
        $values = ' ' . $values;
184
        /** @psalm-var string[] */
185
        $select = $columns->getSelect();
186
187
        foreach ($select as $title => $field) {
188
            if (is_string($title)) {
189
                $names[] = $this->quoter->quoteColumnName($title);
190
            } elseif (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
        return [$names, $values, $params];
198
    }
199
200
    protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array
201
    {
202
        $tableSchema = $this->schema->getTableSchema($table);
203
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
204
        $names = [];
205
        $placeholders = [];
206
        $values = ' DEFAULT VALUES';
207
208
        if ($columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
209
            [$names, $values, $params] = $this->prepareInsertSelectSubQuery($columns, $params);
210
        } else {
211
            /**
212
             * @var mixed $value
213
             * @psalm-var array<string, mixed> $columns
214
             */
215
            foreach ($columns as $name => $value) {
216
                $names[] = $this->quoter->quoteColumnName($name);
217
                /** @var mixed $value */
218
                $value = $this->getTypecastValue($value, $columnSchemas[$name] ?? null);
219
220
                if ($value instanceof ExpressionInterface) {
221
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
222
                } elseif ($value instanceof QueryInterface) {
223
                    [$sql, $params] = $this->queryBuilder->build($value, $params);
224
                    $placeholders[] = "($sql)";
225
                } else {
226
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
227
                }
228
            }
229
        }
230
231
        return [$names, $placeholders, $values, $params];
232
    }
233
234
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
235
    {
236
        $tableSchema = $this->schema->getTableSchema($table);
237
238
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
239
240
        $sets = [];
241
242
        /**
243
         * @psalm-var array<string, mixed> $columns
244
         * @psalm-var mixed $value
245
         */
246
        foreach ($columns as $name => $value) {
247
            /** @var mixed */
248
            $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
249
            if ($value instanceof ExpressionInterface) {
250
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
251
            } else {
252
                $placeholder = $this->queryBuilder->bindParam($value, $params);
253
            }
254
255
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
256
        }
257
258
        return [$sets, $params];
259
    }
260
261
    /**
262
     * @psalm-param Constraint[] $constraints
263
     *
264
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
265
     */
266
    protected function prepareUpsertColumns(
267
        string $table,
268
        QueryInterface|array $insertColumns,
269
        QueryInterface|bool|array $updateColumns,
270
        array &$constraints = []
271
    ): array {
272
        $insertNames = [];
273
274
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
275
            /** @psalm-var list<string> $insertNames */
276
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
277
        } else {
278
            /** @psalm-var array<string, string> $insertColumns */
279
            foreach ($insertColumns as $key => $_value) {
280
                $insertNames[] = $this->quoter->quoteColumnName($key);
281
            }
282
        }
283
284
        /** @psalm-var string[] */
285
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
286
287
        foreach ($uniqueNames as $key => $name) {
288
            $insertNames[$key] = $this->quoter->quoteColumnName($name);
289
        }
290
291
        if ($updateColumns !== true) {
0 ignored issues
show
introduced by
The condition $updateColumns !== true is always true.
Loading history...
292
            return [$uniqueNames, $insertNames, null];
293
        }
294
295
        return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
296
    }
297
298
    /**
299
     * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
300
     * for the named table removing constraints which did not cover the specified column list.
301
     *
302
     * The column list will be unique by column names.
303
     *
304
     * @param string $name table name. The table name may contain schema name if any. Do not quote the table name.
305
     * @param string[] $columns source column list.
306
     * @param Constraint[] $constraints this parameter optionally receives a matched constraint list. The constraints
307
     * will be unique by their column names.
308
     *
309
     * @throws JsonException
310
     *
311
     * @return array column list.
312
     * @psalm-suppress ReferenceConstraintViolation
313
    */
314
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
315
    {
316
        $constraints = [];
317
        $primaryKey = $this->schema->getTablePrimaryKey($name);
318
319
        if ($primaryKey !== null) {
320
            $constraints[] = $primaryKey;
321
        }
322
323
        /** @psalm-var IndexConstraint[] */
324
        $tableIndexes = $this->schema->getTableIndexes($name);
325
326
        foreach ($tableIndexes as $constraint) {
327
            if ($constraint->isUnique()) {
328
                $constraints[] = $constraint;
329
            }
330
        }
331
332
        $constraints = array_merge($constraints, $this->schema->getTableUniques($name));
333
334
        /** Remove duplicates */
335
        $constraints = array_combine(
336
            array_map(
337
                static function (Constraint $constraint) {
338
                    $columns = $constraint->getColumnNames() ?? [];
339
                    $columns = is_array($columns) ? $columns : [$columns];
340
                    sort($columns, SORT_STRING);
341
                    return json_encode($columns, JSON_THROW_ON_ERROR);
342
                },
343
                $constraints
344
            ),
345
            $constraints
346
        );
347
348
        $columnNames = [];
349
        $quoter = $this->quoter;
350
351
        // Remove all constraints which do not cover the specified column list.
352
        $constraints = array_values(
353
            array_filter(
354
                $constraints,
355
                static function (Constraint $constraint) use ($quoter, $columns, &$columnNames) {
356
                    /** @psalm-var string[]|string */
357
                    $getColumnNames = $constraint->getColumnNames() ?? [];
358
                    $constraintColumnNames = [];
359
360
                    if (is_array($getColumnNames)) {
361
                        foreach ($getColumnNames as $columnName) {
362
                            $constraintColumnNames[] = $quoter->quoteColumnName($columnName);
363
                        }
364
                    }
365
366
                    $result = !array_diff($constraintColumnNames, $columns);
367
368
                    if ($result) {
369
                        $columnNames = array_merge((array) $columnNames, $constraintColumnNames);
370
                    }
371
372
                    return $result;
373
                }
374
            )
375
        );
376
377
        /** @psalm-var array $columnNames */
378
        return array_unique($columnNames);
379
    }
380
381
    protected function getTypecastValue(mixed $value, ColumnSchemaInterface $columnSchema = null): mixed
382
    {
383
        if ($columnSchema) {
384
            return $columnSchema->dbTypecast($value);
385
        }
386
387
        return $value;
388
    }
389
}
390