Passed
Push — master ( 914087...c6011d )
by Alexander
11:22
created

DMLQueryBuilder::prepareInsertValues()   B

Complexity

Conditions 7
Paths 4

Size

Total Lines 32
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 19
c 0
b 0
f 0
dl 0
loc 32
rs 8.8333
cc 7
nc 4
nop 3
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\QuoterInterface;
18
use Yiisoft\Db\Schema\SchemaInterface;
19
use Yiisoft\Strings\NumericHelper;
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
            $vs = [];
64
65
            foreach ($row as $i => $value) {
66
                if (isset($columns[$i], $columnSchemas[$columns[$i]])) {
67
                    /** @var mixed */
68
                    $value = $columnSchemas[$columns[$i]]->dbTypecast($value);
69
                }
70
                if (is_string($value)) {
71
                    /** @var string */
72
                    $value = $this->quoter->quoteValue($value);
73
                } elseif (is_float($value)) {
74
                    /* ensure type cast always has . as decimal separator in all locales */
75
                    $value = NumericHelper::normalize((string) $value);
76
                } elseif ($value === false) {
77
                    $value = 0;
78
                } elseif ($value === null) {
79
                    $value = 'NULL';
80
                } elseif ($value instanceof ExpressionInterface) {
81
                    $value = $this->queryBuilder->buildExpression($value, $params);
82
                }
83
                /** @var string */
84
                $vs[] = $value;
85
            }
86
            $values[] = '(' . implode(', ', $vs) . ')';
87
        }
88
89
        if (empty($values)) {
90
            return '';
91
        }
92
93
        /** @psalm-var string[] $columns */
94
        foreach ($columns as $i => $name) {
95
            $columns[$i] = $this->quoter->quoteColumnName($name);
96
        }
97
98
        return 'INSERT INTO '
99
            . $this->quoter->quoteTableName($table)
100
            . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values);
101
    }
102
103
    /**
104
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
105
     */
106
    public function delete(string $table, array|string $condition, array &$params): string
107
    {
108
        $sql = 'DELETE FROM ' . $this->quoter->quoteTableName($table);
109
        $where = $this->queryBuilder->buildWhere($condition, $params);
110
111
        return $where === '' ? $sql : $sql . ' ' . $where;
112
    }
113
114
    /**
115
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
116
     */
117
    public function insert(string $table, QueryInterface|array $columns, array &$params = []): string
118
    {
119
        /**
120
         * @psalm-var string[] $names
121
         * @psalm-var string[] $placeholders
122
         * @psalm-var string $values
123
         */
124
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
125
126
        return 'INSERT INTO '
127
            . $this->quoter->quoteTableName($table)
128
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
129
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : $values);
130
    }
131
132
    /**
133
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
134
     */
135
    public function insertEx(string $table, QueryInterface|array $columns, array &$params = []): string
136
    {
137
        return $this->insert($table, $columns, $params);
138
    }
139
140
    /**
141
     * @throws NotSupportedException
142
     */
143
    public function resetSequence(string $tableName, array|int|string|null $value = null): string
144
    {
145
        throw new NotSupportedException(static::class . ' does not support resetting sequence.');
146
    }
147
148
    public function truncateTable(string $table): string
149
    {
150
        return 'TRUNCATE TABLE ' . $this->quoter->quoteTableName($table);
151
    }
152
153
    /**
154
     * @psalm-suppress MixedArgument
155
     */
156
    public function update(string $table, array $columns, array|string $condition, array &$params = []): string
157
    {
158
        /** @psalm-var string[] $lines */
159
        [$lines, $params] = $this->prepareUpdateSets($table, $columns, $params);
160
        $sql = 'UPDATE ' . $this->quoter->quoteTableName($table) . ' SET ' . implode(', ', $lines);
161
        $where = $this->queryBuilder->buildWhere($condition, $params);
162
163
        return $where === '' ? $sql : $sql . ' ' . $where;
164
    }
165
166
    /**
167
     * @throws NotSupportedException
168
     */
169
    public function upsert(
170
        string $table,
171
        QueryInterface|array $insertColumns,
172
        bool|array $updateColumns,
173
        array &$params
174
    ): string {
175
        throw new NotSupportedException(static::class . ' does not support upsert.');
176
    }
177
178
    /**
179
     * Prepare select-subquery and field names for INSERT INTO ... SELECT SQL statement.
180
     *
181
     * @param QueryInterface $columns Object, which represents select query.
182
     * @param array $params the parameters to be bound to the generated SQL statement. These parameters will be included
183
     * in the result with the additional parameters generated during the query building process.
184
     *
185
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
186
     *
187
     * @return array array of column names, values and params.
188
     */
189
    protected function prepareInsertSelectSubQuery(QueryInterface $columns, array $params = []): array
190
    {
191
        if (empty($columns->getSelect()) || in_array('*', $columns->getSelect(), true)) {
192
            throw new InvalidArgumentException('Expected select query object with enumerated (named) parameters');
193
        }
194
195
        [$values, $params] = $this->queryBuilder->build($columns, $params);
196
197
        $names = [];
198
        $values = ' ' . $values;
199
        /** @psalm-var string[] */
200
        $select = $columns->getSelect();
201
202
        foreach ($select as $title => $field) {
203
            if (is_string($title)) {
204
                $names[] = $this->quoter->quoteColumnName($title);
205
            } elseif (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $field, $matches)) {
206
                $names[] = $this->quoter->quoteColumnName($matches[2]);
207
            } else {
208
                $names[] = $this->quoter->quoteColumnName($field);
209
            }
210
        }
211
212
        return [$names, $values, $params];
213
    }
214
215
    protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array
216
    {
217
        $tableSchema = $this->schema->getTableSchema($table);
218
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
219
        $names = [];
220
        $placeholders = [];
221
        $values = ' DEFAULT VALUES';
222
223
        if ($columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
224
            [$names, $values, $params] = $this->prepareInsertSelectSubQuery($columns, $params);
225
        } else {
226
            /**
227
             * @var mixed $value
228
             * @psalm-var array<string, mixed> $columns
229
             */
230
            foreach ($columns as $name => $value) {
231
                $names[] = $this->quoter->quoteColumnName($name);
232
                /** @var mixed $value */
233
                $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
234
235
                if ($value instanceof ExpressionInterface) {
236
                    $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
237
                } elseif ($value instanceof QueryInterface) {
238
                    [$sql, $params] = $this->queryBuilder->build($value, $params);
239
                    $placeholders[] = "($sql)";
240
                } else {
241
                    $placeholders[] = $this->queryBuilder->bindParam($value, $params);
242
                }
243
            }
244
        }
245
246
        return [$names, $placeholders, $values, $params];
247
    }
248
249
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
250
    {
251
        $tableSchema = $this->schema->getTableSchema($table);
252
253
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
254
255
        $sets = [];
256
257
        /**
258
         * @psalm-var array<string, mixed> $columns
259
         * @psalm-var mixed $value
260
         */
261
        foreach ($columns as $name => $value) {
262
            /** @var mixed */
263
            $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
264
            if ($value instanceof ExpressionInterface) {
265
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
266
            } else {
267
                $placeholder = $this->queryBuilder->bindParam($value, $params);
268
            }
269
270
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
271
        }
272
273
        return [$sets, $params];
274
    }
275
276
    /**
277
     * @psalm-param Constraint[] $constraints
278
     *
279
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
280
     */
281
    protected function prepareUpsertColumns(
282
        string $table,
283
        QueryInterface|array $insertColumns,
284
        QueryInterface|bool|array $updateColumns,
285
        array &$constraints = []
286
    ): array {
287
        $insertNames = [];
288
289
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
290
            /** @psalm-var list<string> $insertNames */
291
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
292
        } else {
293
            /** @psalm-var array<string, string> $insertColumns */
294
            foreach ($insertColumns as $key => $_value) {
295
                $insertNames[] = $this->quoter->quoteColumnName($key);
296
            }
297
        }
298
299
        /** @psalm-var string[] */
300
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
301
302
        foreach ($uniqueNames as $key => $name) {
303
            $insertNames[$key] = $this->quoter->quoteColumnName($name);
304
        }
305
306
        if ($updateColumns !== true) {
0 ignored issues
show
introduced by
The condition $updateColumns !== true is always true.
Loading history...
307
            return [$uniqueNames, $insertNames, null];
308
        }
309
310
        return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
311
    }
312
313
    /**
314
     * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
315
     * for the named table removing constraints which did not cover the specified column list.
316
     *
317
     * The column list will be unique by column names.
318
     *
319
     * @param string $name table name. The table name may contain schema name if any. Do not quote the table name.
320
     * @param string[] $columns source column list.
321
     * @param Constraint[] $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 column list.
327
     * @psalm-suppress ReferenceConstraintViolation
328
    */
329
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
330
    {
331
        $constraints = [];
332
        $primaryKey = $this->schema->getTablePrimaryKey($name);
333
334
        if ($primaryKey !== null) {
335
            $constraints[] = $primaryKey;
336
        }
337
338
        /** @psalm-var IndexConstraint[] */
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
        /** Remove duplicates */
350
        $constraints = array_combine(
351
            array_map(
352
                static function (Constraint $constraint) {
353
                    $columns = $constraint->getColumnNames() ?? [];
354
                    $columns = is_array($columns) ? $columns : [$columns];
355
                    sort($columns, SORT_STRING);
356
                    return json_encode($columns, JSON_THROW_ON_ERROR);
357
                },
358
                $constraints
359
            ),
360
            $constraints
361
        );
362
363
        $columnNames = [];
364
        $quoter = $this->quoter;
365
366
        // Remove all constraints which do not cover the specified column list.
367
        $constraints = array_values(
368
            array_filter(
369
                $constraints,
370
                static function (Constraint $constraint) use ($quoter, $columns, &$columnNames) {
371
                    /** @psalm-var string[]|string */
372
                    $getColumnNames = $constraint->getColumnNames() ?? [];
373
                    $constraintColumnNames = [];
374
375
                    if (is_array($getColumnNames)) {
376
                        foreach ($getColumnNames as $columnName) {
377
                            $constraintColumnNames[] = $quoter->quoteColumnName($columnName);
378
                        }
379
                    }
380
381
                    $result = !array_diff($constraintColumnNames, $columns);
382
383
                    if ($result) {
384
                        $columnNames = array_merge((array) $columnNames, $constraintColumnNames);
385
                    }
386
387
                    return $result;
388
                }
389
            )
390
        );
391
392
        /** @psalm-var array $columnNames */
393
        return array_unique($columnNames);
394
    }
395
}
396