Passed
Push — master ( 486b15...684e74 )
by Def
21:42 queued 19:12
created

DMLQueryBuilder::prepareUpsertColumns()   B

Complexity

Conditions 7
Paths 32

Size

Total Lines 38
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 16
dl 0
loc 38
rs 8.8333
c 0
b 0
f 0
cc 7
nc 32
nop 4
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-param string[] $columns
46
     * @psalm-suppress MixedArrayOffset
47
     */
48
    public function batchInsert(string $table, array $columns, iterable|Generator $rows, array &$params = []): string
49
    {
50
        if (empty($rows)) {
51
            return '';
52
        }
53
54
        if (($tableSchema = $this->schema->getTableSchema($table)) !== null) {
55
            $columnSchemas = $tableSchema->getColumns();
56
        } else {
57
            $columnSchemas = [];
58
        }
59
60
        $mappedNames = $this->getNormalizeColumnNames($table, $columns);
61
        $values = [];
62
63
        /** @psalm-var array<array-key, array<array-key, string>> $rows */
64
        foreach ($rows as $row) {
65
            $placeholders = [];
66
            foreach ($row as $index => $value) {
67
                if (isset($columns[$index], $mappedNames[$columns[$index]], $columnSchemas[$mappedNames[$columns[$index]]])) {
68
                    /** @var mixed $value */
69
                    $value = $this->getTypecastValue($value, $columnSchemas[$mappedNames[$columns[$index]]]);
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
            $values[] = '(' . implode(', ', $placeholders) . ')';
79
        }
80
81
        if (empty($values)) {
82
            return '';
83
        }
84
85
        foreach ($columns as $i => $name) {
86
            $columns[$i] = $this->quoter->quoteColumnName($mappedNames[$name]);
87
        }
88
89
        return 'INSERT INTO '
90
            . $this->quoter->quoteTableName($table)
91
            . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values);
92
    }
93
94
    /**
95
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
96
     */
97
    public function delete(string $table, array|string $condition, array &$params): string
98
    {
99
        $sql = 'DELETE FROM ' . $this->quoter->quoteTableName($table);
100
        $where = $this->queryBuilder->buildWhere($condition, $params);
101
102
        return $where === '' ? $sql : $sql . ' ' . $where;
103
    }
104
105
    public function insert(string $table, QueryInterface|array $columns, array &$params = []): string
106
    {
107
        /**
108
         * @psalm-var string[] $names
109
         * @psalm-var string[] $placeholders
110
         * @psalm-var string $values
111
         */
112
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
113
114
        return 'INSERT INTO '
115
            . $this->quoter->quoteTableName($table)
116
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
117
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : $values);
118
    }
119
120
    /**
121
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
122
     */
123
    public function insertEx(string $table, QueryInterface|array $columns, array &$params = []): string
124
    {
125
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
126
    }
127
128
    /**
129
     * @throws NotSupportedException
130
     */
131
    public function resetSequence(string $tableName, int|string|null $value = null): string
132
    {
133
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
134
    }
135
136
    /**
137
     * @psalm-suppress MixedArgument
138
     */
139
    public function update(string $table, array $columns, array|string $condition, array &$params = []): string
140
    {
141
        /** @psalm-var string[] $lines */
142
        [$lines, $params] = $this->prepareUpdateSets($table, $columns, $params);
143
        $sql = 'UPDATE ' . $this->quoter->quoteTableName($table) . ' SET ' . implode(', ', $lines);
144
        $where = $this->queryBuilder->buildWhere($condition, $params);
145
146
        return $where === '' ? $sql : $sql . ' ' . $where;
147
    }
148
149
    /**
150
     * @throws NotSupportedException
151
     */
152
    public function upsert(
153
        string $table,
154
        QueryInterface|array $insertColumns,
155
        bool|array $updateColumns,
156
        array &$params
157
    ): string {
158
        throw new NotSupportedException(__METHOD__ . ' is not supported by this DBMS.');
159
    }
160
161
    /**
162
     * Prepare select-subquery and field names for INSERT INTO ... SELECT SQL statement.
163
     *
164
     * @param QueryInterface $columns Object, which represents select query.
165
     * @param array $params the parameters to be bound to the generated SQL statement. These parameters will be included
166
     * in the result with the additional parameters generated during the query building process.
167
     *
168
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
169
     *
170
     * @return array array of column names, values and params.
171
     */
172
    protected function prepareInsertSelectSubQuery(QueryInterface $columns, array $params = []): array
173
    {
174
        if (empty($columns->getSelect()) || in_array('*', $columns->getSelect(), true)) {
175
            throw new InvalidArgumentException('Expected select query object with enumerated (named) parameters');
176
        }
177
178
        [$values, $params] = $this->queryBuilder->build($columns, $params);
179
180
        $names = [];
181
        $values = ' ' . $values;
182
        /** @psalm-var string[] $select */
183
        $select = $columns->getSelect();
184
185
        foreach ($select as $title => $field) {
186
            if (is_string($title)) {
187
                $names[] = $this->quoter->quoteColumnName($title);
188
            } elseif (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $field, $matches)) {
189
                $names[] = $this->quoter->quoteColumnName($matches[2]);
190
            } else {
191
                $names[] = $this->quoter->quoteColumnName($field);
192
            }
193
        }
194
195
        return [$names, $values, $params];
196
    }
197
198
    protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array
199
    {
200
        $tableSchema = $this->schema->getTableSchema($table);
201
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
202
        $names = [];
203
        $placeholders = [];
204
        $values = ' DEFAULT VALUES';
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
        } else {
209
            $columns = $this->normalizeColumnNames($table, $columns);
210
            /**
211
             * @var mixed $value
212
             * @psalm-var array<string, mixed> $columns
213
             */
214
            foreach ($columns as $name => $value) {
215
                $names[] = $this->quoter->quoteColumnName($name);
216
                /** @var mixed $value */
217
                $value = $this->getTypecastValue($value, $columnSchemas[$name] ?? null);
218
219
                if ($value instanceof ExpressionInterface) {
220
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
221
                } else {
222
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
223
                }
224
            }
225
        }
226
227
        return [$names, $placeholders, $values, $params];
228
    }
229
230
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
231
    {
232
        $tableSchema = $this->schema->getTableSchema($table);
233
234
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
235
236
        $sets = [];
237
238
        $columns = $this->normalizeColumnNames($table, $columns);
239
240
        /**
241
         * @psalm-var array<string, mixed> $columns
242
         * @psalm-var mixed $value
243
         */
244
        foreach ($columns as $name => $value) {
245
            /** @psalm-var mixed $value */
246
            $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
247
            if ($value instanceof ExpressionInterface) {
248
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
249
            } else {
250
                $placeholder = $this->queryBuilder->bindParam($value, $params);
251
            }
252
253
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
254
        }
255
256
        return [$sets, $params];
257
    }
258
259
    /**
260
     * @psalm-param Constraint[] $constraints
261
     *
262
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
263
     */
264
    protected function prepareUpsertColumns(
265
        string $table,
266
        QueryInterface|array $insertColumns,
267
        QueryInterface|bool|array $updateColumns,
268
        array &$constraints = []
269
    ): array {
270
        $insertNames = [];
271
272
        if (!$insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
273
            $insertColumns = $this->normalizeColumnNames($table, $insertColumns);
274
        }
275
276
        if (is_array($updateColumns)) {
0 ignored issues
show
introduced by
The condition is_array($updateColumns) is always true.
Loading history...
277
            $updateColumns = $this->normalizeColumnNames($table, $updateColumns);
278
        }
279
280
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
281
            /** @psalm-var list<string> $insertNames */
282
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
283
        } else {
284
            /** @psalm-var array<string, string> $insertColumns */
285
            foreach ($insertColumns as $key => $_value) {
286
                $insertNames[] = $this->quoter->quoteColumnName($key);
287
            }
288
        }
289
290
        /** @psalm-var string[] $uniqueNames */
291
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
292
293
        foreach ($uniqueNames as $key => $name) {
294
            $insertNames[$key] = $this->quoter->quoteColumnName($name);
295
        }
296
297
        if ($updateColumns !== true) {
0 ignored issues
show
introduced by
The condition $updateColumns !== true is always true.
Loading history...
298
            return [$uniqueNames, $insertNames, null];
299
        }
300
301
        return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
302
    }
303
304
    /**
305
     * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
306
     * for the named table removing constraints which did not cover the specified column list.
307
     *
308
     * The column list will be unique by column names.
309
     *
310
     * @param string $name table name. The table name may contain schema name if any. Do not quote the table name.
311
     * @param string[] $columns source column list.
312
     * @param Constraint[] $constraints this parameter optionally receives a matched constraint list. The constraints
313
     * will be unique by their column names.
314
     *
315
     * @throws JsonException
316
     *
317
     * @return array column list.
318
     * @psalm-suppress ReferenceConstraintViolation
319
    */
320
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
321
    {
322
        $constraints = [];
323
        $primaryKey = $this->schema->getTablePrimaryKey($name);
324
325
        if ($primaryKey !== null) {
326
            $constraints[] = $primaryKey;
327
        }
328
329
        /** @psalm-var IndexConstraint[] $tableIndexes */
330
        $tableIndexes = $this->schema->getTableIndexes($name);
331
332
        foreach ($tableIndexes as $constraint) {
333
            if ($constraint->isUnique()) {
334
                $constraints[] = $constraint;
335
            }
336
        }
337
338
        $constraints = array_merge($constraints, $this->schema->getTableUniques($name));
339
340
        /** Remove duplicates */
341
        $constraints = array_combine(
342
            array_map(
343
                static function (Constraint $constraint) {
344
                    $columns = $constraint->getColumnNames() ?? [];
345
                    $columns = is_array($columns) ? $columns : [$columns];
346
                    sort($columns, SORT_STRING);
347
                    return json_encode($columns, JSON_THROW_ON_ERROR);
348
                },
349
                $constraints
350
            ),
351
            $constraints
352
        );
353
354
        $columnNames = [];
355
        $quoter = $this->quoter;
356
357
        // Remove all constraints which do not cover the specified column list.
358
        $constraints = array_values(
359
            array_filter(
360
                $constraints,
361
                static function (Constraint $constraint) use ($quoter, $columns, &$columnNames) {
362
                    /** @psalm-var string[]|string $getColumnNames */
363
                    $getColumnNames = $constraint->getColumnNames() ?? [];
364
                    $constraintColumnNames = [];
365
366
                    if (is_array($getColumnNames)) {
367
                        foreach ($getColumnNames as $columnName) {
368
                            $constraintColumnNames[] = $quoter->quoteColumnName($columnName);
369
                        }
370
                    }
371
372
                    $result = !array_diff($constraintColumnNames, $columns);
373
374
                    if ($result) {
375
                        $columnNames = array_merge((array) $columnNames, $constraintColumnNames);
376
                    }
377
378
                    return $result;
379
                }
380
            )
381
        );
382
383
        /** @psalm-var array $columnNames */
384
        return array_unique($columnNames);
385
    }
386
387
    protected function getTypecastValue(mixed $value, ColumnSchemaInterface $columnSchema = null): mixed
388
    {
389
        if ($columnSchema) {
390
            return $columnSchema->dbTypecast($value);
391
        }
392
393
        return $value;
394
    }
395
396
    /**
397
     * Normalizes column names
398
     *
399
     * @param string $table the table that data will be saved into.
400
     * @param array $columns the column data (name => value) to be saved into the table or instance of
401
     * {@see QueryInterface} to perform INSERT INTO ... SELECT SQL statement. Passing of
402
     * {@see QueryInterface}.
403
     *
404
     * @return array normalized columns.
405
     */
406
    protected function normalizeColumnNames(string $table, array $columns): array
407
    {
408
        /** @var string[] $columnsList */
409
        $columnsList = array_keys($columns);
410
        $mappedNames = $this->getNormalizeColumnNames($table, $columnsList);
411
412
        /** @psalm-var mixed[] $normalizedColumns */
413
        $normalizedColumns = [];
414
415
        /**
416
         * @var string $name
417
         * @var mixed $value
418
         */
419
        foreach ($columns as $name => $value) {
420
            $mappedName = $mappedNames[$name] ?? $name;
421
            /** @psalm-suppress MixedAssignment */
422
            $normalizedColumns[$mappedName] = $value;
423
        }
424
425
        return $normalizedColumns;
426
    }
427
428
    /**
429
     * Get map of normalized columns
430
     *
431
     * @param string $table
432
     * @param string[] $columns
433
     *
434
     * @return string[]
435
     */
436
    protected function getNormalizeColumnNames(string $table, array $columns): array
437
    {
438
        $normalizedNames = [];
439
        $rawTableName = $this->schema->getRawTableName($table);
440
441
        foreach ($columns as $name) {
442
            $parts = $this->quoter->getTableNameParts($name, true);
443
444
            if (count($parts) === 2 && $this->schema->getRawTableName($parts[0]) === $rawTableName) {
445
                $normalizedName = $parts[count($parts) - 1];
446
            } else {
447
                $normalizedName = $name;
448
            }
449
            $normalizedName = $this->quoter->ensureColumnName($normalizedName);
450
451
            $normalizedNames[$name] = $normalizedName;
452
        }
453
454
        return $normalizedNames;
455
    }
456
}
457