Passed
Push — master ( cfd508...1eff36 )
by Def
02:11
created

DMLQueryBuilder::prepareInsertSelectSubQuery()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 30
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 17
dl 0
loc 30
rs 8.8333
c 0
b 0
f 0
cc 7
nc 7
nop 2
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
            } else {
189
                if ($field instanceof ExpressionInterface) {
190
                    $field = $this->queryBuilder->buildExpression($field, $params);
191
                }
192
193
                if (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $field, $matches)) {
194
                    $names[] = $this->quoter->quoteColumnName($matches[2]);
195
                } else {
196
                    $names[] = $this->quoter->quoteColumnName($field);
197
                }
198
            }
199
        }
200
201
        return [$names, $values, $params];
202
    }
203
204
    protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array
205
    {
206
        $tableSchema = $this->schema->getTableSchema($table);
207
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
208
        $names = [];
209
        $placeholders = [];
210
        $values = ' DEFAULT VALUES';
211
212
        if ($columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
213
            [$names, $values, $params] = $this->prepareInsertSelectSubQuery($columns, $params);
214
        } else {
215
            $columns = $this->normalizeColumnNames($table, $columns);
216
            /**
217
             * @var mixed $value
218
             * @psalm-var array<string, mixed> $columns
219
             */
220
            foreach ($columns as $name => $value) {
221
                $names[] = $this->quoter->quoteColumnName($name);
222
                /** @var mixed $value */
223
                $value = $this->getTypecastValue($value, $columnSchemas[$name] ?? null);
224
225
                if ($value instanceof ExpressionInterface) {
226
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
227
                } else {
228
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
229
                }
230
            }
231
        }
232
233
        return [$names, $placeholders, $values, $params];
234
    }
235
236
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
237
    {
238
        $tableSchema = $this->schema->getTableSchema($table);
239
240
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
241
242
        $sets = [];
243
244
        $columns = $this->normalizeColumnNames($table, $columns);
245
246
        /**
247
         * @psalm-var array<string, mixed> $columns
248
         * @psalm-var mixed $value
249
         */
250
        foreach ($columns as $name => $value) {
251
            /** @psalm-var mixed $value */
252
            $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
253
            if ($value instanceof ExpressionInterface) {
254
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
255
            } else {
256
                $placeholder = $this->queryBuilder->bindParam($value, $params);
257
            }
258
259
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
260
        }
261
262
        return [$sets, $params];
263
    }
264
265
    /**
266
     * @psalm-param Constraint[] $constraints
267
     *
268
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
269
     */
270
    protected function prepareUpsertColumns(
271
        string $table,
272
        QueryInterface|array $insertColumns,
273
        QueryInterface|bool|array $updateColumns,
274
        array &$constraints = []
275
    ): array {
276
        $insertNames = [];
277
278
        if (!$insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
279
            $insertColumns = $this->normalizeColumnNames($table, $insertColumns);
280
        }
281
282
        if (is_array($updateColumns)) {
0 ignored issues
show
introduced by
The condition is_array($updateColumns) is always true.
Loading history...
283
            $updateColumns = $this->normalizeColumnNames($table, $updateColumns);
284
        }
285
286
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
287
            /** @psalm-var list<string> $insertNames */
288
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
289
        } else {
290
            /** @psalm-var array<string, string> $insertColumns */
291
            foreach ($insertColumns as $key => $_value) {
292
                $insertNames[] = $this->quoter->quoteColumnName($key);
293
            }
294
        }
295
296
        /** @psalm-var string[] $uniqueNames */
297
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
298
299
        foreach ($uniqueNames as $key => $name) {
300
            $insertNames[$key] = $this->quoter->quoteColumnName($name);
301
        }
302
303
        if ($updateColumns !== true) {
0 ignored issues
show
introduced by
The condition $updateColumns !== true is always true.
Loading history...
304
            return [$uniqueNames, $insertNames, null];
305
        }
306
307
        return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
308
    }
309
310
    /**
311
     * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
312
     * for the named table removing constraints which did not cover the specified column list.
313
     *
314
     * The column list will be unique by column names.
315
     *
316
     * @param string $name table name. The table name may contain schema name if any. Do not quote the table name.
317
     * @param string[] $columns source column list.
318
     * @param Constraint[] $constraints this parameter optionally receives a matched constraint list. The constraints
319
     * will be unique by their column names.
320
     *
321
     * @throws JsonException
322
     *
323
     * @return array column list.
324
     * @psalm-suppress ReferenceConstraintViolation
325
    */
326
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
327
    {
328
        $constraints = [];
329
        $primaryKey = $this->schema->getTablePrimaryKey($name);
330
331
        if ($primaryKey !== null) {
332
            $constraints[] = $primaryKey;
333
        }
334
335
        /** @psalm-var IndexConstraint[] $tableIndexes */
336
        $tableIndexes = $this->schema->getTableIndexes($name);
337
338
        foreach ($tableIndexes as $constraint) {
339
            if ($constraint->isUnique()) {
340
                $constraints[] = $constraint;
341
            }
342
        }
343
344
        $constraints = array_merge($constraints, $this->schema->getTableUniques($name));
345
346
        /** Remove duplicates */
347
        $constraints = array_combine(
348
            array_map(
349
                static function (Constraint $constraint) {
350
                    $columns = $constraint->getColumnNames() ?? [];
351
                    $columns = is_array($columns) ? $columns : [$columns];
352
                    sort($columns, SORT_STRING);
353
                    return json_encode($columns, JSON_THROW_ON_ERROR);
354
                },
355
                $constraints
356
            ),
357
            $constraints
358
        );
359
360
        $columnNames = [];
361
        $quoter = $this->quoter;
362
363
        // Remove all constraints which do not cover the specified column list.
364
        $constraints = array_values(
365
            array_filter(
366
                $constraints,
367
                static function (Constraint $constraint) use ($quoter, $columns, &$columnNames) {
368
                    /** @psalm-var string[]|string $getColumnNames */
369
                    $getColumnNames = $constraint->getColumnNames() ?? [];
370
                    $constraintColumnNames = [];
371
372
                    if (is_array($getColumnNames)) {
373
                        foreach ($getColumnNames as $columnName) {
374
                            $constraintColumnNames[] = $quoter->quoteColumnName($columnName);
375
                        }
376
                    }
377
378
                    $result = !array_diff($constraintColumnNames, $columns);
379
380
                    if ($result) {
381
                        $columnNames = array_merge((array) $columnNames, $constraintColumnNames);
382
                    }
383
384
                    return $result;
385
                }
386
            )
387
        );
388
389
        /** @psalm-var array $columnNames */
390
        return array_unique($columnNames);
391
    }
392
393
    protected function getTypecastValue(mixed $value, ColumnSchemaInterface $columnSchema = null): mixed
394
    {
395
        if ($columnSchema) {
396
            return $columnSchema->dbTypecast($value);
397
        }
398
399
        return $value;
400
    }
401
402
    /**
403
     * Normalizes column names
404
     *
405
     * @param string $table the table that data will be saved into.
406
     * @param array $columns the column data (name => value) to be saved into the table or instance of
407
     * {@see QueryInterface} to perform INSERT INTO ... SELECT SQL statement. Passing of
408
     * {@see QueryInterface}.
409
     *
410
     * @return array normalized columns.
411
     */
412
    protected function normalizeColumnNames(string $table, array $columns): array
413
    {
414
        /** @var string[] $columnsList */
415
        $columnsList = array_keys($columns);
416
        $mappedNames = $this->getNormalizeColumnNames($table, $columnsList);
417
418
        /** @psalm-var mixed[] $normalizedColumns */
419
        $normalizedColumns = [];
420
421
        /**
422
         * @var string $name
423
         * @var mixed $value
424
         */
425
        foreach ($columns as $name => $value) {
426
            $mappedName = $mappedNames[$name] ?? $name;
427
            /** @psalm-suppress MixedAssignment */
428
            $normalizedColumns[$mappedName] = $value;
429
        }
430
431
        return $normalizedColumns;
432
    }
433
434
    /**
435
     * Get map of normalized columns
436
     *
437
     * @param string $table
438
     * @param string[] $columns
439
     *
440
     * @return string[]
441
     */
442
    protected function getNormalizeColumnNames(string $table, array $columns): array
443
    {
444
        $normalizedNames = [];
445
        $rawTableName = $this->schema->getRawTableName($table);
446
447
        foreach ($columns as $name) {
448
            $parts = $this->quoter->getTableNameParts($name, true);
449
450
            if (count($parts) === 2 && $this->schema->getRawTableName($parts[0]) === $rawTableName) {
451
                $normalizedName = $parts[count($parts) - 1];
452
            } else {
453
                $normalizedName = $name;
454
            }
455
            $normalizedName = $this->quoter->ensureColumnName($normalizedName);
456
457
            $normalizedNames[$name] = $normalizedName;
458
        }
459
460
        return $normalizedNames;
461
    }
462
}
463