Passed
Push — master ( 4f7d24...a5450e )
by Def
04:44 queued 02:33
created

DMLQueryBuilder   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 364
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 53
eloc 133
dl 0
loc 364
rs 6.96
c 0
b 0
f 0

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
B getTableUniqueColumnNames() 0 65 8
A prepareInsertSelectSubQuery() 0 24 6
A prepareInsertValues() 0 32 6
A insertEx() 0 3 1
B batchInsert() 0 44 9
A update() 0 8 2
A prepareUpsertColumns() 0 30 5
A resetSequence() 0 3 1
A insert() 0 13 3
A delete() 0 6 2
A prepareUpdateSets() 0 25 5
A upsert() 0 7 1
A truncateTable() 0 3 1
A getTypecastValue() 0 7 2

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