Passed
Pull Request — master (#746)
by
unknown
02:24
created

AbstractDMLQueryBuilder::prepareUpsertColumns()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 25
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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