Passed
Pull Request — master (#820)
by Sergei
02:25
created

AbstractDMLQueryBuilder::prepareTraversable()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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

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

544
    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...
545
    {
546
        foreach ($columns as &$name) {
547
            $name = $this->quoter->ensureColumnName($name);
548
            $name = $this->quoter->unquoteSimpleColumnName($name);
549
        }
550
551
        return $columns;
552
    }
553
}
554