Skip to content

Commit 085f007

Browse files
authored
Batch insert with empty columns (#795)
1 parent 064e076 commit 085f007

File tree

9 files changed

+173
-41
lines changed

9 files changed

+173
-41
lines changed

.github/workflows/active-record.yml

-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ jobs:
3737
- ubuntu-latest
3838

3939
php:
40-
- 8.0
4140
- 8.1
4241
- 8.2
4342

.github/workflows/rector.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ jobs:
2020
os: >-
2121
['ubuntu-latest']
2222
php: >-
23-
['8.0']
23+
['8.3']

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- Enh #784: Remove unused code in `AbstractSchema::getTableIndexes()` (@vjik)
1717
- Bug #788: Fix casting integer to string in `AbstractCommand::getRawSql()` (@Tigrov)
1818
- Enh #789: Remove unnecessary type casting to array in `AbstractDMLQueryBuilder::getTableUniqueColumnNames()` (@Tigrov)
19+
- Enh #795: Allow to use `DMLQueryBuilderInterface::batchInsert()` method with empty columns (@Tigrov)
1920

2021
## 1.2.0 November 12, 2023
2122

composer.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"yiisoft/json": "^1.0",
4343
"yiisoft/log": "^2.0",
4444
"yiisoft/var-dumper": "^1.5",
45-
"yiisoft/yii-debug": "dev-master"
45+
"yiisoft/yii-debug": "dev-master|dev-php80"
4646
},
4747
"autoload": {
4848
"psr-4": {

src/QueryBuilder/AbstractDMLQueryBuilder.php

+98-35
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Yiisoft\Db\QueryBuilder;
66

77
use JsonException;
8+
use Traversable;
89
use Yiisoft\Db\Constraint\Constraint;
910
use Yiisoft\Db\Exception\Exception;
1011
use Yiisoft\Db\Exception\InvalidArgumentException;
@@ -20,16 +21,23 @@
2021
use function array_diff;
2122
use function array_fill_keys;
2223
use function array_filter;
24+
use function array_key_exists;
2325
use function array_keys;
2426
use function array_map;
2527
use function array_merge;
2628
use function array_unique;
2729
use function array_values;
30+
use function count;
31+
use function get_object_vars;
2832
use function implode;
2933
use function in_array;
34+
use function is_array;
35+
use function is_object;
3036
use function is_string;
37+
use function iterator_to_array;
3138
use function json_encode;
3239
use function preg_match;
40+
use function reset;
3341
use function sort;
3442

3543
/**
@@ -55,47 +63,22 @@ public function batchInsert(string $table, array $columns, iterable $rows, array
5563
return '';
5664
}
5765

58-
$values = [];
59-
$columns = $this->getNormalizeColumnNames('', $columns);
60-
$columnNames = array_values($columns);
61-
$columnKeys = array_fill_keys($columnNames, false);
62-
$columnSchemas = $this->schema->getTableSchema($table)?->getColumns() ?? [];
63-
64-
foreach ($rows as $row) {
65-
$i = 0;
66-
$placeholders = $columnKeys;
67-
68-
foreach ($row as $key => $value) {
69-
/** @psalm-suppress MixedArrayTypeCoercion */
70-
$columnName = $columns[$key] ?? (isset($columnKeys[$key]) ? $key : $columnNames[$i] ?? $i);
71-
/** @psalm-suppress MixedArrayTypeCoercion */
72-
if (isset($columnSchemas[$columnName])) {
73-
$value = $columnSchemas[$columnName]->dbTypecast($value);
74-
}
75-
76-
if ($value instanceof ExpressionInterface) {
77-
$placeholders[$columnName] = $this->queryBuilder->buildExpression($value, $params);
78-
} else {
79-
$placeholders[$columnName] = $this->queryBuilder->bindParam($value, $params);
80-
}
81-
82-
++$i;
83-
}
84-
85-
$values[] = '(' . implode(', ', $placeholders) . ')';
86-
}
66+
$columns = $this->extractColumnNames($rows, $columns);
67+
$values = $this->prepareBatchInsertValues($table, $rows, $columns, $params);
8768

8869
if (empty($values)) {
8970
return '';
9071
}
9172

92-
$columnNames = array_map(
93-
[$this->quoter, 'quoteColumnName'],
94-
$columnNames,
95-
);
73+
$query = 'INSERT INTO ' . $this->quoter->quoteTableName($table);
9674

97-
return 'INSERT INTO ' . $this->quoter->quoteTableName($table)
98-
. ' (' . implode(', ', $columnNames) . ') VALUES ' . implode(', ', $values);
75+
if (count($columns) > 0) {
76+
$quotedColumnNames = array_map([$this->quoter, 'quoteColumnName'], $columns);
77+
78+
$query .= ' (' . implode(', ', $quotedColumnNames) . ')';
79+
}
80+
81+
return $query . ' VALUES ' . implode(', ', $values);
9982
}
10083

10184
public function delete(string $table, array|string $condition, array &$params): string
@@ -144,6 +127,86 @@ public function upsert(
144127
throw new NotSupportedException(__METHOD__ . ' is not supported by this DBMS.');
145128
}
146129

130+
/**
131+
* Prepare values for batch insert.
132+
*
133+
* @param string $table The table name.
134+
* @param iterable $rows The rows to be batch inserted into the table.
135+
* @param string[] $columns The column names.
136+
* @param array $params The binding parameters that will be generated by this method.
137+
*
138+
* @return string[] The values.
139+
*/
140+
protected function prepareBatchInsertValues(string $table, iterable $rows, array $columns, array &$params): array
141+
{
142+
$values = [];
143+
/** @var string[] $columnNames */
144+
$columnNames = array_values($columns);
145+
$columnKeys = array_fill_keys($columnNames, false);
146+
$columnSchemas = $this->schema->getTableSchema($table)?->getColumns() ?? [];
147+
148+
foreach ($rows as $row) {
149+
$i = 0;
150+
$placeholders = $columnKeys;
151+
152+
/** @var int|string $key */
153+
foreach ($row as $key => $value) {
154+
$columnName = $columns[$key] ?? (isset($columnKeys[$key]) ? $key : $columnNames[$i] ?? $i);
155+
156+
if (isset($columnSchemas[$columnName])) {
157+
$value = $columnSchemas[$columnName]->dbTypecast($value);
158+
}
159+
160+
if ($value instanceof ExpressionInterface) {
161+
$placeholders[$columnName] = $this->queryBuilder->buildExpression($value, $params);
162+
} else {
163+
$placeholders[$columnName] = $this->queryBuilder->bindParam($value, $params);
164+
}
165+
166+
++$i;
167+
}
168+
169+
$values[] = '(' . implode(', ', $placeholders) . ')';
170+
}
171+
172+
return $values;
173+
}
174+
175+
/**
176+
* Extract column names from columns and rows.
177+
*
178+
* @param string $table The column schemas.
179+
* @param iterable $rows The rows to be batch inserted into the table.
180+
* @param string[] $columns The column names.
181+
*
182+
* @return string[] The column names.
183+
*/
184+
protected function extractColumnNames(iterable $rows, array $columns): array
185+
{
186+
$columns = $this->getNormalizeColumnNames('', $columns);
187+
188+
if ($columns !== [] || !is_array($rows)) {
189+
return $columns;
190+
}
191+
192+
$row = reset($rows);
193+
$row = match (true) {
194+
is_array($row) => $row,
195+
$row instanceof Traversable => iterator_to_array($row),
196+
is_object($row) => get_object_vars($row),
197+
default => [],
198+
};
199+
200+
if (array_key_exists(0, $row)) {
201+
return [];
202+
}
203+
204+
/** @var string[] $columnNames */
205+
$columnNames = array_keys($row);
206+
207+
return array_combine($columnNames, $columnNames);
208+
}
209+
147210
/**
148211
* Prepare select-subQuery and field names for `INSERT INTO ... SELECT` SQL statement.
149212
*

tests/AbstractQueryBuilderTest.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ public function testBatchInsert(
217217
string $expected,
218218
array $expectedParams = [],
219219
): void {
220-
$db = $this->getConnection();
220+
$db = $this->getConnection(true);
221221
$qb = $db->getQueryBuilder();
222222
223223
$params = [];

tests/Provider/CommandProvider.php

+52
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Yiisoft\Db\Tests\Provider;
66

7+
use ArrayIterator;
78
use Yiisoft\Db\Command\DataType;
89
use Yiisoft\Db\Command\Param;
910
use Yiisoft\Db\Expression\Expression;
@@ -427,6 +428,57 @@ public static function batchInsert(): array
427428
':qp3' => 2.0,
428429
],
429430
],
431+
'empty columns and associative values' => [
432+
'type',
433+
[],
434+
'values' => [['int_col' => '1.0', 'float_col' => '2', 'char_col' => 10, 'bool_col' => 1]],
435+
'expected' => DbHelper::replaceQuotes(
436+
<<<SQL
437+
INSERT INTO [[type]] ([[int_col]], [[float_col]], [[char_col]], [[bool_col]]) VALUES (:qp0, :qp1, :qp2, :qp3)
438+
SQL,
439+
static::$driverName,
440+
),
441+
'expectedParams' => [
442+
':qp0' => 1,
443+
':qp1' => 2.0,
444+
':qp2' => '10',
445+
':qp3' => true,
446+
],
447+
],
448+
'empty columns and objects' => [
449+
'type',
450+
[],
451+
'values' => [(object)['int_col' => '1.0', 'float_col' => '2', 'char_col' => 10, 'bool_col' => 1]],
452+
'expected' => DbHelper::replaceQuotes(
453+
<<<SQL
454+
INSERT INTO [[type]] ([[int_col]], [[float_col]], [[char_col]], [[bool_col]]) VALUES (:qp0, :qp1, :qp2, :qp3)
455+
SQL,
456+
static::$driverName,
457+
),
458+
'expectedParams' => [
459+
':qp0' => 1,
460+
':qp1' => 2.0,
461+
':qp2' => '10',
462+
':qp3' => true,
463+
],
464+
],
465+
'empty columns and Traversable' => [
466+
'type',
467+
[],
468+
'values' => [new ArrayIterator(['int_col' => '1.0', 'float_col' => '2', 'char_col' => 10, 'bool_col' => 1])],
469+
'expected' => DbHelper::replaceQuotes(
470+
<<<SQL
471+
INSERT INTO [[type]] ([[int_col]], [[float_col]], [[char_col]], [[bool_col]]) VALUES (:qp0, :qp1, :qp2, :qp3)
472+
SQL,
473+
static::$driverName,
474+
),
475+
'expectedParams' => [
476+
':qp0' => 1,
477+
':qp1' => 2.0,
478+
':qp2' => '10',
479+
':qp3' => true,
480+
],
481+
],
430482
];
431483
}
432484

tests/Provider/QueryBuilderProvider.php

+18-1
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ public static function batchInsert(): array
171171
[['no columns passed']],
172172
'expected' => DbHelper::replaceQuotes(
173173
<<<SQL
174-
INSERT INTO [[customer]] () VALUES (:qp0)
174+
INSERT INTO [[customer]] VALUES (:qp0)
175175
SQL,
176176
static::$driverName,
177177
),
@@ -235,6 +235,23 @@ public static function batchInsert(): array
235235
})(),
236236
'',
237237
],
238+
'empty columns and non-exists table' => [
239+
'non_exists_table',
240+
[],
241+
'values' => [['1.0', '2', 10, 1]],
242+
'expected' => DbHelper::replaceQuotes(
243+
<<<SQL
244+
INSERT INTO [[non_exists_table]] VALUES (:qp0, :qp1, :qp2, :qp3)
245+
SQL,
246+
static::$driverName,
247+
),
248+
'expectedParams' => [
249+
':qp0' => '1.0',
250+
':qp1' => '2',
251+
':qp2' => 10,
252+
':qp3' => 1,
253+
],
254+
],
238255
];
239256
}
240257

tests/Support/DbHelper.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ final class DbHelper
2121
{
2222
public static function changeSqlForOracleBatchInsert(string &$str): void
2323
{
24-
$str = str_replace('INSERT INTO', 'INSERT ALL INTO', $str) . ' SELECT 1 FROM SYS.DUAL';
24+
$str = str_replace('INSERT INTO', 'INSERT ALL INTO', $str) . ' SELECT 1 FROM SYS.DUAL';
2525
}
2626

2727
public static function getPsrCache(): CacheInterface

0 commit comments

Comments
 (0)