Passed
Pull Request — master (#746)
by Sergei
02:20
created

AbstractDMLQueryBuilder::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

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