Passed
Pull Request — master (#443)
by Def
11:26
created

DMLQueryBuilder   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 414
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 58
eloc 151
dl 0
loc 414
rs 4.5599
c 0
b 0
f 0

16 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
B getTableUniqueColumnNames() 0 65 8
A prepareInsertSelectSubQuery() 0 24 6
A prepareInsertValues() 0 29 5
A insertEx() 0 3 1
B batchInsert() 0 44 9
A update() 0 10 2
A getTypecastValue() 0 7 2
A prepareUpsertColumns() 0 30 5
A normalizeColumnNames() 0 20 2
A resetSequence() 0 3 1
A insert() 0 17 4
A delete() 0 6 2
A getNormalizeColumnNames() 0 19 4
A prepareUpdateSets() 0 25 5
A upsert() 0 7 1

How to fix   Complexity   

Complex Class

Complex classes like DMLQueryBuilder often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DMLQueryBuilder, and based on these observations, apply Extract Interface, too.

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
        if (!$columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
108
            $columns = $this->normalizeColumnNames($table, $columns);
109
        }
110
111
        /**
112
         * @psalm-var string[] $names
113
         * @psalm-var string[] $placeholders
114
         * @psalm-var string $values
115
         */
116
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
117
118
        return 'INSERT INTO '
119
            . $this->quoter->quoteTableName($table)
120
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
121
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : $values);
122
    }
123
124
    /**
125
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
126
     */
127
    public function insertEx(string $table, QueryInterface|array $columns, array &$params = []): string
128
    {
129
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
130
    }
131
132
    /**
133
     * @throws NotSupportedException
134
     */
135
    public function resetSequence(string $tableName, int|string|null $value = null): string
136
    {
137
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
138
    }
139
140
    /**
141
     * @psalm-suppress MixedArgument
142
     */
143
    public function update(string $table, array $columns, array|string $condition, array &$params = []): string
144
    {
145
        $columns = $this->normalizeColumnNames($table, $columns);
146
147
        /** @psalm-var string[] $lines */
148
        [$lines, $params] = $this->prepareUpdateSets($table, $columns, $params);
149
        $sql = 'UPDATE ' . $this->quoter->quoteTableName($table) . ' SET ' . implode(', ', $lines);
150
        $where = $this->queryBuilder->buildWhere($condition, $params);
151
152
        return $where === '' ? $sql : $sql . ' ' . $where;
153
    }
154
155
    /**
156
     * @throws NotSupportedException
157
     */
158
    public function upsert(
159
        string $table,
160
        QueryInterface|array $insertColumns,
161
        bool|array $updateColumns,
162
        array &$params
163
    ): string {
164
        throw new NotSupportedException(__METHOD__ . ' is not supported by this DBMS.');
165
    }
166
167
    /**
168
     * Prepare select-subquery and field names for INSERT INTO ... SELECT SQL statement.
169
     *
170
     * @param QueryInterface $columns Object, which represents select query.
171
     * @param array $params the parameters to be bound to the generated SQL statement. These parameters will be included
172
     * in the result with the additional parameters generated during the query building process.
173
     *
174
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
175
     *
176
     * @return array array of column names, values and params.
177
     */
178
    protected function prepareInsertSelectSubQuery(QueryInterface $columns, array $params = []): array
179
    {
180
        if (empty($columns->getSelect()) || in_array('*', $columns->getSelect(), true)) {
181
            throw new InvalidArgumentException('Expected select query object with enumerated (named) parameters');
182
        }
183
184
        [$values, $params] = $this->queryBuilder->build($columns, $params);
185
186
        $names = [];
187
        $values = ' ' . $values;
188
        /** @psalm-var string[] $select */
189
        $select = $columns->getSelect();
190
191
        foreach ($select as $title => $field) {
192
            if (is_string($title)) {
193
                $names[] = $this->quoter->quoteColumnName($title);
194
            } elseif (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $field, $matches)) {
195
                $names[] = $this->quoter->quoteColumnName($matches[2]);
196
            } else {
197
                $names[] = $this->quoter->quoteColumnName($field);
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
            /**
216
             * @var mixed $value
217
             * @psalm-var array<string, mixed> $columns
218
             */
219
            foreach ($columns as $name => $value) {
220
                $names[] = $this->quoter->quoteColumnName($name);
221
                /** @var mixed $value */
222
                $value = $this->getTypecastValue($value, $columnSchemas[$name] ?? null);
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
232
        return [$names, $placeholders, $values, $params];
233
    }
234
235
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
236
    {
237
        $tableSchema = $this->schema->getTableSchema($table);
238
239
        $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
240
241
        $sets = [];
242
243
        /**
244
         * @psalm-var array<string, mixed> $columns
245
         * @psalm-var mixed $value
246
         */
247
        foreach ($columns as $name => $value) {
248
            /** @psalm-var mixed $value */
249
            $value = isset($columnSchemas[$name]) ? $columnSchemas[$name]->dbTypecast($value) : $value;
250
            if ($value instanceof ExpressionInterface) {
251
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
252
            } else {
253
                $placeholder = $this->queryBuilder->bindParam($value, $params);
254
            }
255
256
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
257
        }
258
259
        return [$sets, $params];
260
    }
261
262
    /**
263
     * @psalm-param Constraint[] $constraints
264
     *
265
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
266
     */
267
    protected function prepareUpsertColumns(
268
        string $table,
269
        QueryInterface|array $insertColumns,
270
        QueryInterface|bool|array $updateColumns,
271
        array &$constraints = []
272
    ): array {
273
        $insertNames = [];
274
275
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
276
            /** @psalm-var list<string> $insertNames */
277
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
278
        } else {
279
            /** @psalm-var array<string, string> $insertColumns */
280
            foreach ($insertColumns as $key => $_value) {
281
                $insertNames[] = $this->quoter->quoteColumnName($key);
282
            }
283
        }
284
285
        /** @psalm-var string[] $uniqueNames */
286
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
287
288
        foreach ($uniqueNames as $key => $name) {
289
            $insertNames[$key] = $this->quoter->quoteColumnName($name);
290
        }
291
292
        if ($updateColumns !== true) {
0 ignored issues
show
introduced by
The condition $updateColumns !== true is always true.
Loading history...
293
            return [$uniqueNames, $insertNames, null];
294
        }
295
296
        return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
297
    }
298
299
    /**
300
     * Returns all column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
301
     * for the named table removing constraints which did not cover the specified column list.
302
     *
303
     * The column list will be unique by column names.
304
     *
305
     * @param string $name table name. The table name may contain schema name if any. Do not quote the table name.
306
     * @param string[] $columns source column list.
307
     * @param Constraint[] $constraints this parameter optionally receives a matched constraint list. The constraints
308
     * will be unique by their column names.
309
     *
310
     * @throws JsonException
311
     *
312
     * @return array column list.
313
     * @psalm-suppress ReferenceConstraintViolation
314
    */
315
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
316
    {
317
        $constraints = [];
318
        $primaryKey = $this->schema->getTablePrimaryKey($name);
319
320
        if ($primaryKey !== null) {
321
            $constraints[] = $primaryKey;
322
        }
323
324
        /** @psalm-var IndexConstraint[] $tableIndexes */
325
        $tableIndexes = $this->schema->getTableIndexes($name);
326
327
        foreach ($tableIndexes as $constraint) {
328
            if ($constraint->isUnique()) {
329
                $constraints[] = $constraint;
330
            }
331
        }
332
333
        $constraints = array_merge($constraints, $this->schema->getTableUniques($name));
334
335
        /** Remove duplicates */
336
        $constraints = array_combine(
337
            array_map(
338
                static function (Constraint $constraint) {
339
                    $columns = $constraint->getColumnNames() ?? [];
340
                    $columns = is_array($columns) ? $columns : [$columns];
341
                    sort($columns, SORT_STRING);
342
                    return json_encode($columns, JSON_THROW_ON_ERROR);
343
                },
344
                $constraints
345
            ),
346
            $constraints
347
        );
348
349
        $columnNames = [];
350
        $quoter = $this->quoter;
351
352
        // Remove all constraints which do not cover the specified column list.
353
        $constraints = array_values(
354
            array_filter(
355
                $constraints,
356
                static function (Constraint $constraint) use ($quoter, $columns, &$columnNames) {
357
                    /** @psalm-var string[]|string $getColumnNames */
358
                    $getColumnNames = $constraint->getColumnNames() ?? [];
359
                    $constraintColumnNames = [];
360
361
                    if (is_array($getColumnNames)) {
362
                        foreach ($getColumnNames as $columnName) {
363
                            $constraintColumnNames[] = $quoter->quoteColumnName($columnName);
364
                        }
365
                    }
366
367
                    $result = !array_diff($constraintColumnNames, $columns);
368
369
                    if ($result) {
370
                        $columnNames = array_merge((array) $columnNames, $constraintColumnNames);
371
                    }
372
373
                    return $result;
374
                }
375
            )
376
        );
377
378
        /** @psalm-var array $columnNames */
379
        return array_unique($columnNames);
380
    }
381
382
    protected function getTypecastValue(mixed $value, ColumnSchemaInterface $columnSchema = null): mixed
383
    {
384
        if ($columnSchema) {
385
            return $columnSchema->dbTypecast($value);
386
        }
387
388
        return $value;
389
    }
390
391
    /**
392
     * Normalizes column names
393
     *
394
     * @param string $table the table that data will be saved into.
395
     * @param array $columns the column data (name => value) to be saved into the table or instance of
396
     * {@see QueryInterface} to perform INSERT INTO ... SELECT SQL statement. Passing of
397
     * {@see QueryInterface}.
398
     *
399
     * @return array normalized columns.
400
     */
401
    protected function normalizeColumnNames(string $table, array $columns): array
402
    {
403
        /** @var string[] $columnsList */
404
        $columnsList = array_keys($columns);
405
        $mappedNames = $this->getNormalizeColumnNames($table, $columnsList);
406
407
        /** @psalm-var mixed[] $normalizedColumns */
408
        $normalizedColumns = [];
409
410
        /**
411
         * @var string $name
412
         * @var mixed $value
413
         */
414
        foreach ($columns as $name => $value) {
415
            $mappedName = $mappedNames[$name] ?? $name;
416
            /** @psalm-suppress MixedAssignment */
417
            $normalizedColumns[$mappedName] = $value;
418
        }
419
420
        return $normalizedColumns;
421
    }
422
423
    /**
424
     * Get map of normalized columns
425
     * @param string $table
426
     * @param string[] $columns
427
     *
428
     * @return string[]
429
     */
430
    protected function getNormalizeColumnNames(string $table, array $columns): array
431
    {
432
        $normalizedNames = [];
433
        $rawTableName = $this->schema->getRawTableName($table);
434
435
        foreach ($columns as $name) {
436
            $parts = $this->quoter->getTableNameParts($name, true);
437
438
            if (count($parts) === 2 && $this->schema->getRawTableName($parts[0]) === $rawTableName) {
439
                $normalizedName = $parts[count($parts) - 1];
440
            } else {
441
                $normalizedName = $name;
442
            }
443
            $normalizedName = $this->quoter->ensureColumnName($normalizedName);
444
445
            $normalizedNames[$name] = $normalizedName;
446
        }
447
448
        return $normalizedNames;
449
    }
450
}
451