Passed
Pull Request — master (#820)
by Sergei
07:15 queued 04:54
created

AbstractDMLQueryBuilder   F

Complexity

Total Complexity 60

Size/Duplication

Total Lines 491
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 164
c 2
b 0
f 0
dl 0
loc 491
rs 3.6
wmc 60

19 Methods

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

522
    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...
523
    {
524
        /** @var string[] $columnNames */
525
        $columnNames = array_keys($columns);
526
        $normalizedNames = $this->getNormalizeColumnNames('', $columnNames);
527
528
        return array_combine($normalizedNames, $columns);
529
    }
530
531
    /**
532
     * Get normalized column names
533
     *
534
     * @param string $table Not used. Could be empty string. Will be removed in version 2.0.0.
535
     * @param string[] $columns The column names.
536
     *
537
     * @return string[] Normalized column names.
538
     */
539
    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

539
    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...
540
    {
541
        foreach ($columns as &$name) {
542
            $name = $this->quoter->ensureColumnName($name);
543
            $name = $this->quoter->unquoteSimpleColumnName($name);
544
        }
545
546
        return $columns;
547
    }
548
}
549