Passed
Pull Request — master (#437)
by Def
02:19
created

DMLQueryBuilder::update()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
c 0
b 0
f 0
dl 0
loc 8
rs 10
cc 2
nc 2
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-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
    public function insert(string $table, QueryInterface|array $columns, array &$params = []): string
105
    {
106
        /**
107
         * @psalm-var string[] $names
108
         * @psalm-var string[] $placeholders
109
         * @psalm-var string $values
110
         */
111
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
112
113
        return 'INSERT INTO '
114
            . $this->quoter->quoteTableName($table)
115
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
116
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : $values);
117
    }
118
119
    /**
120
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
121
     */
122
    public function insertEx(string $table, QueryInterface|array $columns, array &$params = []): string
123
    {
124
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
125
    }
126
127
    /**
128
     * @throws NotSupportedException
129
     */
130
    public function resetSequence(string $tableName, int|string|null $value = null): string
131
    {
132
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
133
    }
134
135
    /**
136
     * @psalm-suppress MixedArgument
137
     */
138
    public function update(string $table, array $columns, array|string $condition, array &$params = []): string
139
    {
140
        /** @psalm-var string[] $lines */
141
        [$lines, $params] = $this->prepareUpdateSets($table, $columns, $params);
142
        $sql = 'UPDATE ' . $this->quoter->quoteTableName($table) . ' SET ' . implode(', ', $lines);
143
        $where = $this->queryBuilder->buildWhere($condition, $params);
144
145
        return $where === '' ? $sql : $sql . ' ' . $where;
146
    }
147
148
    /**
149
     * @throws NotSupportedException
150
     */
151
    public function upsert(
152
        string $table,
153
        QueryInterface|array $insertColumns,
154
        bool|array $updateColumns,
155
        array &$params
156
    ): string {
157
        throw new NotSupportedException(__METHOD__ . ' is not supported by this DBMS.');
158
    }
159
160
    /**
161
     * Prepare select-subquery and field names for INSERT INTO ... SELECT SQL statement.
162
     *
163
     * @param QueryInterface $columns Object, which represents select query.
164
     * @param array $params the parameters to be bound to the generated SQL statement. These parameters will be included
165
     * in the result with the additional parameters generated during the query building process.
166
     *
167
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
168
     *
169
     * @return array array of column names, values and params.
170
     */
171
    protected function prepareInsertSelectSubQuery(QueryInterface $columns, array $params = []): array
172
    {
173
        if (empty($columns->getSelect()) || in_array('*', $columns->getSelect(), true)) {
174
            throw new InvalidArgumentException('Expected select query object with enumerated (named) parameters');
175
        }
176
177
        [$values, $params] = $this->queryBuilder->build($columns, $params);
178
179
        $names = [];
180
        $values = ' ' . $values;
181
        /** @psalm-var string[] $select */
182
        $select = $columns->getSelect();
183
184
        foreach ($select as $title => $field) {
185
            if (is_string($title)) {
186
                $names[] = $this->quoter->quoteColumnName($title);
187
            } elseif (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $field, $matches)) {
188
                $names[] = $this->quoter->quoteColumnName($matches[2]);
189
            } else {
190
                $names[] = $this->quoter->quoteColumnName($field);
191
            }
192
        }
193
194
        return [$names, $values, $params];
195
    }
196
197
    protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array
198
    {
199
        $tableSchema = $this->schema->getTableSchema($table);
200
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
201
        $names = [];
202
        $placeholders = [];
203
        $values = ' DEFAULT VALUES';
204
205
        if ($columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
206
            [$names, $values, $params] = $this->prepareInsertSelectSubQuery($columns, $params);
207
        } else {
208
            /**
209
             * @var mixed $value
210
             * @psalm-var array<string, mixed> $columns
211
             */
212
            foreach ($columns as $name => $value) {
213
                $names[] = $this->quoter->quoteColumnName($name);
214
                /** @var mixed $value */
215
                $value = $this->getTypecastValue($value, $columnSchemas[$name] ?? null);
216
217
                if ($value instanceof ExpressionInterface) {
218
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
219
                } else {
220
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
221
                }
222
            }
223
        }
224
225
        return [$names, $placeholders, $values, $params];
226
    }
227
228
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
229
    {
230
        $tableSchema = $this->schema->getTableSchema($table);
231
232
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
233
234
        $sets = [];
235
236
        /**
237
         * @psalm-var array<string, mixed> $columns
238
         * @psalm-var mixed $value
239
         */
240
        foreach ($columns as $name => $value) {
241
            /** @psalm-var mixed $value */
242
            $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
243
            if ($value instanceof ExpressionInterface) {
244
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
245
            } else {
246
                $placeholder = $this->queryBuilder->bindParam($value, $params);
247
            }
248
249
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
250
        }
251
252
        return [$sets, $params];
253
    }
254
255
    /**
256
     * @psalm-param Constraint[] $constraints
257
     *
258
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
259
     */
260
    protected function prepareUpsertColumns(
261
        string $table,
262
        QueryInterface|array $insertColumns,
263
        QueryInterface|bool|array $updateColumns,
264
        array &$constraints = []
265
    ): array {
266
        $insertNames = [];
267
268
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
269
            /** @psalm-var list<string> $insertNames */
270
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
271
        } else {
272
            /** @psalm-var array<string, string> $insertColumns */
273
            foreach ($insertColumns as $key => $_value) {
274
                $insertNames[] = $this->quoter->quoteColumnName($key);
275
            }
276
        }
277
278
        /** @psalm-var string[] $uniqueNames */
279
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
280
281
        foreach ($uniqueNames as $key => $name) {
282
            $insertNames[$key] = $this->quoter->quoteColumnName($name);
283
        }
284
285
        if ($updateColumns !== true) {
0 ignored issues
show
introduced by
The condition $updateColumns !== true is always true.
Loading history...
286
            return [$uniqueNames, $insertNames, null];
287
        }
288
289
        return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
290
    }
291
292
    /**
293
     * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
294
     * for the named table removing constraints which did not cover the specified column list.
295
     *
296
     * The column list will be unique by column names.
297
     *
298
     * @param string $name table name. The table name may contain schema name if any. Do not quote the table name.
299
     * @param string[] $columns source column list.
300
     * @param Constraint[] $constraints this parameter optionally receives a matched constraint list. The constraints
301
     * will be unique by their column names.
302
     *
303
     * @throws JsonException
304
     *
305
     * @return array column list.
306
     * @psalm-suppress ReferenceConstraintViolation
307
    */
308
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
309
    {
310
        $constraints = [];
311
        $primaryKey = $this->schema->getTablePrimaryKey($name);
312
313
        if ($primaryKey !== null) {
314
            $constraints[] = $primaryKey;
315
        }
316
317
        /** @psalm-var IndexConstraint[] $tableIndexes */
318
        $tableIndexes = $this->schema->getTableIndexes($name);
319
320
        foreach ($tableIndexes as $constraint) {
321
            if ($constraint->isUnique()) {
322
                $constraints[] = $constraint;
323
            }
324
        }
325
326
        $constraints = array_merge($constraints, $this->schema->getTableUniques($name));
327
328
        /** Remove duplicates */
329
        $constraints = array_combine(
330
            array_map(
331
                static function (Constraint $constraint) {
332
                    $columns = $constraint->getColumnNames() ?? [];
333
                    $columns = is_array($columns) ? $columns : [$columns];
334
                    sort($columns, SORT_STRING);
335
                    return json_encode($columns, JSON_THROW_ON_ERROR);
336
                },
337
                $constraints
338
            ),
339
            $constraints
340
        );
341
342
        $columnNames = [];
343
        $quoter = $this->quoter;
344
345
        // Need get filtered column names. Without name of table. And remove columns from other tables
346
        $unquotedColumns = $simpleColumns = [];
347
        $rawTableName = $this->schema->getRawTableName($name);
348
        foreach ($columns as $column) {
349
            $parts = $quoter->getTableNameParts($column, true);
350
351
            // Skip columns from other tables
352
            if (count($parts) === 2 && $this->schema->getRawTableName($parts[0]) !== $rawTableName) {
353
                continue;
354
            }
355
356
            $columnName = $quoter->ensureColumnName($parts[count($parts)-1]);
357
            $unquotedColumns[$column] = $simpleColumns[] = $quoter->quoteColumnName($columnName);
358
        }
359
360
        // Remove all constraints which do not cover the specified column list.
361
        $constraints = array_values(
362
            array_filter(
363
                $constraints,
364
                static function (Constraint $constraint) use ($quoter, $simpleColumns, &$columnNames) {
365
                    /** @psalm-var string[]|string $getColumnNames */
366
                    $getColumnNames = $constraint->getColumnNames() ?? [];
367
                    $constraintColumnNames = [];
368
369
                    if (is_array($getColumnNames)) {
370
                        foreach ($getColumnNames as $columnName) {
371
                            $constraintColumnNames[] = $quoter->quoteColumnName($columnName);
372
                        }
373
                    }
374
375
                    $result = !array_diff($constraintColumnNames, $simpleColumns);
376
377
                    if ($result) {
378
                        $columnNames = array_merge((array) $columnNames, $constraintColumnNames);
379
                    }
380
381
                    return $result;
382
                }
383
            )
384
        );
385
386
        // restore original column names
387
        $originalColumnNames = [];
388
        /** @psalm-var string[] $columnNames */
389
        foreach ($columnNames as $columnName) {
390
            $originalColumnNames[] = $unquotedColumns[$columnName] ?? $columnName;
391
        }
392
393
        return array_unique($originalColumnNames);
394
    }
395
396
    protected function getTypecastValue(mixed $value, ColumnSchemaInterface $columnSchema = null): mixed
397
    {
398
        if ($columnSchema) {
399
            return $columnSchema->dbTypecast($value);
400
        }
401
402
        return $value;
403
    }
404
}
405