Passed
Pull Request — master (#820)
by Sergei
04:14 queued 01:55
created

AbstractDMLQueryBuilder   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 492
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 162
c 3
b 0
f 0
dl 0
loc 492
rs 4.08
wmc 59

19 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
A getTableUniqueColumnNames() 0 64 5
B prepareInsertSelectSubQuery() 0 30 7
A extractColumnNames() 0 29 4
A insert() 0 7 3
A prepareUpdateSets() 0 21 4
A prepareUpsertColumns() 0 24 3
A getNormalizeColumnNames() 0 8 2
A delete() 0 6 2
A resetSequence() 0 3 1
A prepareInsertValues() 0 31 6
A update() 0 8 2
A getTypecastValue() 0 7 2
A batchInsert() 0 22 4
A prepareBatchInsertValues() 0 33 6
A normalizeColumnNames() 0 7 1
A upsert() 0 7 1
A insertWithReturningPks() 0 3 1
A prepareTraversable() 0 12 4

How to fix   Complexity   

Complex Class

Complex classes like AbstractDMLQueryBuilder 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 AbstractDMLQueryBuilder, 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 Iterator;
8
use IteratorAggregate;
9
use JsonException;
10
use Traversable;
11
use Yiisoft\Db\Connection\ConnectionInterface;
12
use Yiisoft\Db\Constraint\Constraint;
13
use Yiisoft\Db\Exception\Exception;
14
use Yiisoft\Db\Exception\InvalidArgumentException;
15
use Yiisoft\Db\Exception\InvalidConfigException;
16
use Yiisoft\Db\Exception\NotSupportedException;
17
use Yiisoft\Db\Expression\ExpressionInterface;
18
use Yiisoft\Db\Query\QueryInterface;
19
use Yiisoft\Db\Schema\ColumnSchemaInterface;
20
use Yiisoft\Db\Schema\QuoterInterface;
21
use Yiisoft\Db\Schema\SchemaInterface;
22
23
use function array_combine;
24
use function array_diff;
25
use function array_fill_keys;
26
use function array_filter;
27
use function array_key_exists;
28
use function array_keys;
29
use function array_map;
30
use function array_merge;
31
use function array_unique;
32
use function array_values;
33
use function count;
34
use function get_object_vars;
35
use function implode;
36
use function in_array;
37
use function is_array;
38
use function is_object;
39
use function is_string;
40
use function iterator_to_array;
41
use function json_encode;
42
use function preg_match;
43
use function reset;
44
use function sort;
45
46
/**
47
 * It's used to manipulate data in tables.
48
 *
49
 * This manipulation involves inserting data into database tables, retrieving existing data, deleting data from existing
50
 * tables and modifying existing data.
51
 *
52
 * @link https://en.wikipedia.org/wiki/Data_manipulation_language
53
 *
54
 * @psalm-import-type ParamsType from ConnectionInterface
55
 */
56
abstract class AbstractDMLQueryBuilder implements DMLQueryBuilderInterface
57
{
58
    public function __construct(
59
        protected QueryBuilderInterface $queryBuilder,
60
        protected QuoterInterface $quoter,
61
        protected SchemaInterface $schema
62
    ) {
63
    }
64
65
    public function batchInsert(string $table, array $columns, iterable $rows, array &$params = []): string
66
    {
67
        if (!is_array($rows)) {
68
            $rows = $this->prepareTraversable($rows);
69
        }
70
71
        if (empty($rows)) {
72
            return '';
73
        }
74
75
        $columns = $this->extractColumnNames($rows, $columns);
76
        $values = $this->prepareBatchInsertValues($table, $rows, $columns, $params);
77
78
        $query = 'INSERT INTO ' . $this->quoter->quoteTableName($table);
79
80
        if (count($columns) > 0) {
81
            $quotedColumnNames = array_map([$this->quoter, 'quoteColumnName'], $columns);
82
83
            $query .= ' (' . implode(', ', $quotedColumnNames) . ')';
84
        }
85
86
        return $query . ' VALUES ' . implode(', ', $values);
87
    }
88
89
    public function delete(string $table, array|string $condition, array &$params): string
90
    {
91
        $sql = 'DELETE FROM ' . $this->quoter->quoteTableName($table);
92
        $where = $this->queryBuilder->buildWhere($condition, $params);
93
94
        return $where === '' ? $sql : $sql . ' ' . $where;
95
    }
96
97
    public function insert(string $table, QueryInterface|array $columns, array &$params = []): string
98
    {
99
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
100
101
        return 'INSERT INTO ' . $this->quoter->quoteTableName($table)
102
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
103
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : ' ' . $values);
104
    }
105
106
    public function insertWithReturningPks(string $table, QueryInterface|array $columns, array &$params = []): string
107
    {
108
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
109
    }
110
111
    public function resetSequence(string $table, int|string|null $value = null): string
112
    {
113
        throw new NotSupportedException(__METHOD__ . '() is not supported by this DBMS.');
114
    }
115
116
    public function update(string $table, array $columns, array|string $condition, array &$params = []): string
117
    {
118
        [$lines, $params] = $this->prepareUpdateSets($table, $columns, $params);
119
120
        $sql = 'UPDATE ' . $this->quoter->quoteTableName($table) . ' SET ' . implode(', ', $lines);
121
        $where = $this->queryBuilder->buildWhere($condition, $params);
122
123
        return $where === '' ? $sql : $sql . ' ' . $where;
124
    }
125
126
    public function upsert(
127
        string $table,
128
        QueryInterface|array $insertColumns,
129
        bool|array $updateColumns,
130
        array &$params
131
    ): string {
132
        throw new NotSupportedException(__METHOD__ . ' is not supported by this DBMS.');
133
    }
134
135
    /**
136
     * Prepare traversable for batch insert.
137
     *
138
     * @param Traversable $rows The rows to be batch inserted into the table.
139
     *
140
     * @return array|Iterator The prepared rows.
141
     *
142
     * @psalm-return Iterator|array<iterable<array-key, mixed>>
143
     */
144
    protected function prepareTraversable(Traversable $rows): Iterator|array
145
    {
146
        while ($rows instanceof IteratorAggregate) {
147
            $rows = $rows->getIterator();
148
        }
149
150
        if ($rows instanceof Iterator && !$rows->valid()) {
151
            return [];
152
        }
153
154
        /** @var Iterator $rows */
155
        return $rows;
156
    }
157
158
    /**
159
     * Prepare values for batch insert.
160
     *
161
     * @param string $table The table name.
162
     * @param iterable $rows The rows to be batch inserted into the table.
163
     * @param string[] $columns The column names.
164
     * @param array $params The binding parameters that will be generated by this method.
165
     *
166
     * @return string[] The values.
167
     *
168
     * @psalm-param ParamsType $params
169
     */
170
    protected function prepareBatchInsertValues(string $table, iterable $rows, array $columns, array &$params): array
171
    {
172
        $values = [];
173
        /** @var string[] $columnNames */
174
        $columnNames = array_values($columns);
175
        $columnKeys = array_fill_keys($columnNames, false);
176
        $columnSchemas = $this->schema->getTableSchema($table)?->getColumns() ?? [];
177
178
        foreach ($rows as $row) {
179
            $i = 0;
180
            $placeholders = $columnKeys;
181
182
            /** @var int|string $key */
183
            foreach ($row as $key => $value) {
184
                $columnName = $columns[$key] ?? (isset($columnKeys[$key]) ? $key : $columnNames[$i] ?? $i);
185
186
                if (isset($columnSchemas[$columnName])) {
187
                    $value = $columnSchemas[$columnName]->dbTypecast($value);
188
                }
189
190
                if ($value instanceof ExpressionInterface) {
191
                    $placeholders[$columnName] = $this->queryBuilder->buildExpression($value, $params);
192
                } else {
193
                    $placeholders[$columnName] = $this->queryBuilder->bindParam($value, $params);
194
                }
195
196
                ++$i;
197
            }
198
199
            $values[] = '(' . implode(', ', $placeholders) . ')';
200
        }
201
202
        return $values;
203
    }
204
205
    /**
206
     * Extract column names from columns and rows.
207
     *
208
     * @param array[]|Iterator $rows The rows to be batch inserted into the table.
209
     * @param string[] $columns The column names.
210
     *
211
     * @return string[] The column names.
212
     *
213
     * @psalm-param Iterator|non-empty-array<iterable<array-key, mixed>> $rows
214
     */
215
    protected function extractColumnNames(array|Iterator $rows, array $columns): array
216
    {
217
        $columns = $this->getNormalizeColumnNames('', $columns);
218
219
        if (!empty($columns)) {
220
            return $columns;
221
        }
222
223
        if ($rows instanceof Iterator) {
0 ignored issues
show
introduced by
$rows is never a sub-type of Iterator.
Loading history...
224
            $row = $rows->current();
225
        } else {
226
            $row = reset($rows);
227
        }
228
229
        $row = match (true) {
230
            is_array($row) => $row,
231
            $row instanceof Traversable => iterator_to_array($row),
232
            is_object($row) => get_object_vars($row),
233
            default => [],
234
        };
235
236
        if (array_key_exists(0, $row)) {
237
            return [];
238
        }
239
240
        /** @var string[] $columnNames */
241
        $columnNames = array_keys($row);
242
243
        return array_combine($columnNames, $columnNames);
244
    }
245
246
    /**
247
     * Prepare select-subQuery and field names for `INSERT INTO ... SELECT` SQL statement.
248
     *
249
     * @param QueryInterface $columns Object, which represents a select query.
250
     * @param array $params The parameters to bind to the generated SQL statement. These parameters will be included
251
     * in the result, with the more parameters generated during the query building process.
252
     *
253
     * @throws Exception
254
     * @throws InvalidArgumentException
255
     * @throws InvalidConfigException
256
     * @throws NotSupportedException
257
     *
258
     * @return array Array of quoted column names, values, and params.
259
     *
260
     * @psalm-param ParamsType $params
261
     * @psalm-return array{0: string[], 1: string, 2: array}
262
     */
263
    protected function prepareInsertSelectSubQuery(QueryInterface $columns, array $params = []): array
264
    {
265
        /** @psalm-var string[] $select */
266
        $select = $columns->getSelect();
267
268
        if (empty($select) || in_array('*', $select, true)) {
269
            throw new InvalidArgumentException('Expected select query object with enumerated (named) parameters');
270
        }
271
272
        [$values, $params] = $this->queryBuilder->build($columns, $params);
273
274
        $names = [];
275
276
        foreach ($select as $title => $field) {
277
            if (is_string($title)) {
278
                $names[] = $this->quoter->quoteColumnName($title);
279
            } else {
280
                if ($field instanceof ExpressionInterface) {
281
                    $field = $this->queryBuilder->buildExpression($field, $params);
282
                }
283
284
                if (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $field, $matches)) {
285
                    $names[] = $this->quoter->quoteColumnName($matches[2]);
286
                } else {
287
                    $names[] = $this->quoter->quoteColumnName($field);
288
                }
289
            }
290
        }
291
292
        return [$names, $values, $params];
293
    }
294
295
    /**
296
     * Prepare column names and placeholders for `INSERT` SQL statement.
297
     *
298
     * @throws Exception
299
     * @throws InvalidConfigException
300
     * @throws InvalidArgumentException
301
     * @throws NotSupportedException
302
     *
303
     * @return array Array of quoted column names, placeholders, values, and params.
304
     *
305
     * @psalm-param ParamsType $params
306
     * @psalm-return array{0: string[], 1: string[], 2: string, 3: array}
307
     */
308
    protected function prepareInsertValues(string $table, array|QueryInterface $columns, array $params = []): array
309
    {
310
        if (empty($columns)) {
311
            return [[], [], 'DEFAULT VALUES', []];
312
        }
313
314
        if ($columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
315
            [$names, $values, $params] = $this->prepareInsertSelectSubQuery($columns, $params);
316
            return [$names, [], $values, $params];
317
        }
318
319
        $names = [];
320
        $placeholders = [];
321
        $columns = $this->normalizeColumnNames('', $columns);
322
        $columnSchemas = $this->schema->getTableSchema($table)?->getColumns() ?? [];
323
324
        foreach ($columns as $name => $value) {
325
            $names[] = $this->quoter->quoteColumnName($name);
326
327
            if (isset($columnSchemas[$name])) {
328
                $value = $columnSchemas[$name]->dbTypecast($value);
329
            }
330
331
            if ($value instanceof ExpressionInterface) {
332
                $placeholders[] = $this->queryBuilder->buildExpression($value, $params);
333
            } else {
334
                $placeholders[] = $this->queryBuilder->bindParam($value, $params);
335
            }
336
        }
337
338
        return [$names, $placeholders, '', $params];
339
    }
340
341
    /**
342
     * Prepare column names and placeholders for `UPDATE` SQL statement.
343
     *
344
     * @throws Exception
345
     * @throws InvalidConfigException
346
     * @throws InvalidArgumentException
347
     * @throws NotSupportedException
348
     *
349
     * @psalm-param ParamsType $params
350
     * @psalm-return array{0: string[], 1: array}
351
     */
352
    protected function prepareUpdateSets(string $table, array $columns, array $params = []): array
353
    {
354
        $sets = [];
355
        $columns = $this->normalizeColumnNames('', $columns);
356
        $columnSchemas = $this->schema->getTableSchema($table)?->getColumns() ?? [];
357
358
        foreach ($columns as $name => $value) {
359
            if (isset($columnSchemas[$name])) {
360
                $value = $columnSchemas[$name]->dbTypecast($value);
361
            }
362
363
            if ($value instanceof ExpressionInterface) {
364
                $placeholder = $this->queryBuilder->buildExpression($value, $params);
365
            } else {
366
                $placeholder = $this->queryBuilder->bindParam($value, $params);
367
            }
368
369
            $sets[] = $this->quoter->quoteColumnName($name) . '=' . $placeholder;
370
        }
371
372
        return [$sets, $params];
373
    }
374
375
    /**
376
     * Prepare column names and constraints for "upsert" operation.
377
     *
378
     * @throws Exception
379
     * @throws InvalidArgumentException
380
     * @throws InvalidConfigException
381
     * @throws JsonException
382
     * @throws NotSupportedException
383
     *
384
     * @psalm-param array<string, mixed>|QueryInterface $insertColumns
385
     * @psalm-param Constraint[] $constraints
386
     *
387
     * @return array Array of unique, insert and update quoted column names.
388
     * @psalm-return array{0: string[], 1: string[], 2: string[]|null}
389
     */
390
    protected function prepareUpsertColumns(
391
        string $table,
392
        QueryInterface|array $insertColumns,
393
        QueryInterface|bool|array $updateColumns,
394
        array &$constraints = []
395
    ): array {
396
        if ($insertColumns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$insertColumns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
397
            [$insertNames] = $this->prepareInsertSelectSubQuery($insertColumns);
398
        } else {
399
            $insertNames = $this->getNormalizeColumnNames('', array_keys($insertColumns));
400
401
            $insertNames = array_map(
402
                [$this->quoter, 'quoteColumnName'],
403
                $insertNames,
404
            );
405
        }
406
407
        $uniqueNames = $this->getTableUniqueColumnNames($table, $insertNames, $constraints);
408
409
        if ($updateColumns === true) {
0 ignored issues
show
introduced by
The condition $updateColumns === true is always false.
Loading history...
410
            return [$uniqueNames, $insertNames, array_diff($insertNames, $uniqueNames)];
411
        }
412
413
        return [$uniqueNames, $insertNames, null];
414
    }
415
416
    /**
417
     * Returns all quoted column names belonging to constraints enforcing uniqueness (`PRIMARY KEY`, `UNIQUE INDEX`, etc.)
418
     * for the named table removing constraints which didn't cover the specified column list.
419
     *
420
     * The column list will be unique by column names.
421
     *
422
     * @param string $name The table name, may contain schema name if any. Don't quote the table name.
423
     * @param string[] $columns Source column list.
424
     * @param array $constraints This parameter optionally receives a matched constraint list. The constraints
425
     * will be unique by their column names.
426
     *
427
     * @throws JsonException
428
     *
429
     * @return string[] The quoted column names.
430
     *
431
     * @psalm-param Constraint[] $constraints
432
    */
433
    private function getTableUniqueColumnNames(string $name, array $columns, array &$constraints = []): array
434
    {
435
        $primaryKey = $this->schema->getTablePrimaryKey($name);
436
437
        if ($primaryKey !== null) {
438
            $constraints[] = $primaryKey;
439
        }
440
441
        $tableIndexes = $this->schema->getTableIndexes($name);
442
443
        foreach ($tableIndexes as $constraint) {
444
            if ($constraint->isUnique()) {
445
                $constraints[] = $constraint;
446
            }
447
        }
448
449
        $constraints = array_merge($constraints, $this->schema->getTableUniques($name));
450
451
        /**
452
         * Remove duplicates
453
         *
454
         * @psalm-var Constraint[] $constraints
455
         */
456
        $constraints = array_combine(
457
            array_map(
458
                static function (Constraint $constraint): string {
459
                    $columns = (array) $constraint->getColumnNames();
460
                    sort($columns, SORT_STRING);
461
                    return json_encode($columns, JSON_THROW_ON_ERROR);
462
                },
463
                $constraints
464
            ),
465
            $constraints
466
        );
467
468
        $columnNames = [];
469
        $quoter = $this->quoter;
470
471
        // Remove all constraints which don't cover the specified column list.
472
        $constraints = array_values(
473
            array_filter(
474
                $constraints,
475
                static function (Constraint $constraint) use ($quoter, $columns, &$columnNames): bool {
476
                    /** @psalm-var string[] $constraintColumnNames */
477
                    $constraintColumnNames = (array) $constraint->getColumnNames();
478
479
                    $constraintColumnNames = array_map(
480
                        [$quoter, 'quoteColumnName'],
481
                        $constraintColumnNames,
482
                    );
483
484
                    $result = empty(array_diff($constraintColumnNames, $columns));
485
486
                    if ($result) {
487
                        $columnNames = array_merge($columnNames, $constraintColumnNames);
488
                    }
489
490
                    return $result;
491
                }
492
            )
493
        );
494
495
        /** @psalm-var string[] $columnNames */
496
        return array_unique($columnNames);
497
    }
498
499
    /**
500
     * @return mixed The typecast value of the given column.
501
     *
502
     * @deprecated will be removed in version 2.0.0
503
     */
504
    protected function getTypecastValue(mixed $value, ColumnSchemaInterface $columnSchema = null): mixed
505
    {
506
        if ($columnSchema) {
507
            return $columnSchema->dbTypecast($value);
508
        }
509
510
        return $value;
511
    }
512
513
    /**
514
     * Normalizes the column names.
515
     *
516
     * @param string $table Not used. Could be empty string. Will be removed in version 2.0.0.
517
     * @param array $columns The column data (name => value).
518
     *
519
     * @return array The normalized column names (name => value).
520
     *
521
     * @psalm-return array<string, mixed>
522
     */
523
    protected function normalizeColumnNames(string $table, array $columns): array
0 ignored issues
show
Unused Code introduced by
The parameter $table is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

523
    protected function normalizeColumnNames(/** @scrutinizer ignore-unused */ string $table, array $columns): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
524
    {
525
        /** @var string[] $columnNames */
526
        $columnNames = array_keys($columns);
527
        $normalizedNames = $this->getNormalizeColumnNames('', $columnNames);
528
529
        return array_combine($normalizedNames, $columns);
530
    }
531
532
    /**
533
     * Get normalized column names
534
     *
535
     * @param string $table Not used. Could be empty string. Will be removed in version 2.0.0.
536
     * @param string[] $columns The column names.
537
     *
538
     * @return string[] Normalized column names.
539
     */
540
    protected function getNormalizeColumnNames(string $table, array $columns): array
0 ignored issues
show
Unused Code introduced by
The parameter $table is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

540
    protected function getNormalizeColumnNames(/** @scrutinizer ignore-unused */ string $table, array $columns): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
541
    {
542
        foreach ($columns as &$name) {
543
            $name = $this->quoter->ensureColumnName($name);
544
            $name = $this->quoter->unquoteSimpleColumnName($name);
545
        }
546
547
        return $columns;
548
    }
549
}
550