Skip to content

Commit

Permalink
Batch insert with empty columns (#795)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tigrov authored Jan 9, 2024
1 parent 064e076 commit 085f007
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 41 deletions.
1 change: 0 additions & 1 deletion .github/workflows/active-record.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ jobs:
- ubuntu-latest

php:
- 8.0
- 8.1
- 8.2

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/rector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ jobs:
os: >-
['ubuntu-latest']
php: >-
['8.0']
['8.3']
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- Enh #784: Remove unused code in `AbstractSchema::getTableIndexes()` (@vjik)
- Bug #788: Fix casting integer to string in `AbstractCommand::getRawSql()` (@Tigrov)
- Enh #789: Remove unnecessary type casting to array in `AbstractDMLQueryBuilder::getTableUniqueColumnNames()` (@Tigrov)
- Enh #795: Allow to use `DMLQueryBuilderInterface::batchInsert()` method with empty columns (@Tigrov)

## 1.2.0 November 12, 2023

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"yiisoft/json": "^1.0",
"yiisoft/log": "^2.0",
"yiisoft/var-dumper": "^1.5",
"yiisoft/yii-debug": "dev-master"
"yiisoft/yii-debug": "dev-master|dev-php80"
},
"autoload": {
"psr-4": {
Expand Down
133 changes: 98 additions & 35 deletions src/QueryBuilder/AbstractDMLQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Yiisoft\Db\QueryBuilder;

use JsonException;
use Traversable;
use Yiisoft\Db\Constraint\Constraint;
use Yiisoft\Db\Exception\Exception;
use Yiisoft\Db\Exception\InvalidArgumentException;
Expand All @@ -20,16 +21,23 @@
use function array_diff;
use function array_fill_keys;
use function array_filter;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_unique;
use function array_values;
use function count;
use function get_object_vars;
use function implode;
use function in_array;
use function is_array;
use function is_object;
use function is_string;
use function iterator_to_array;
use function json_encode;
use function preg_match;
use function reset;
use function sort;

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

$values = [];
$columns = $this->getNormalizeColumnNames('', $columns);
$columnNames = array_values($columns);
$columnKeys = array_fill_keys($columnNames, false);
$columnSchemas = $this->schema->getTableSchema($table)?->getColumns() ?? [];

foreach ($rows as $row) {
$i = 0;
$placeholders = $columnKeys;

foreach ($row as $key => $value) {
/** @psalm-suppress MixedArrayTypeCoercion */
$columnName = $columns[$key] ?? (isset($columnKeys[$key]) ? $key : $columnNames[$i] ?? $i);
/** @psalm-suppress MixedArrayTypeCoercion */
if (isset($columnSchemas[$columnName])) {
$value = $columnSchemas[$columnName]->dbTypecast($value);
}

if ($value instanceof ExpressionInterface) {
$placeholders[$columnName] = $this->queryBuilder->buildExpression($value, $params);
} else {
$placeholders[$columnName] = $this->queryBuilder->bindParam($value, $params);
}

++$i;
}

$values[] = '(' . implode(', ', $placeholders) . ')';
}
$columns = $this->extractColumnNames($rows, $columns);
$values = $this->prepareBatchInsertValues($table, $rows, $columns, $params);

if (empty($values)) {
return '';
}

$columnNames = array_map(
[$this->quoter, 'quoteColumnName'],
$columnNames,
);
$query = 'INSERT INTO ' . $this->quoter->quoteTableName($table);

return 'INSERT INTO ' . $this->quoter->quoteTableName($table)
. ' (' . implode(', ', $columnNames) . ') VALUES ' . implode(', ', $values);
if (count($columns) > 0) {
$quotedColumnNames = array_map([$this->quoter, 'quoteColumnName'], $columns);

$query .= ' (' . implode(', ', $quotedColumnNames) . ')';
}

return $query . ' VALUES ' . implode(', ', $values);
}

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

/**
* Prepare values for batch insert.
*
* @param string $table The table name.
* @param iterable $rows The rows to be batch inserted into the table.
* @param string[] $columns The column names.
* @param array $params The binding parameters that will be generated by this method.
*
* @return string[] The values.
*/
protected function prepareBatchInsertValues(string $table, iterable $rows, array $columns, array &$params): array
{
$values = [];
/** @var string[] $columnNames */
$columnNames = array_values($columns);
$columnKeys = array_fill_keys($columnNames, false);
$columnSchemas = $this->schema->getTableSchema($table)?->getColumns() ?? [];

foreach ($rows as $row) {
$i = 0;
$placeholders = $columnKeys;

/** @var int|string $key */
foreach ($row as $key => $value) {
$columnName = $columns[$key] ?? (isset($columnKeys[$key]) ? $key : $columnNames[$i] ?? $i);

if (isset($columnSchemas[$columnName])) {
$value = $columnSchemas[$columnName]->dbTypecast($value);
}

if ($value instanceof ExpressionInterface) {
$placeholders[$columnName] = $this->queryBuilder->buildExpression($value, $params);
} else {
$placeholders[$columnName] = $this->queryBuilder->bindParam($value, $params);
}

++$i;
}

$values[] = '(' . implode(', ', $placeholders) . ')';
}

return $values;
}

/**
* Extract column names from columns and rows.
*
* @param string $table The column schemas.
* @param iterable $rows The rows to be batch inserted into the table.
* @param string[] $columns The column names.
*
* @return string[] The column names.
*/
protected function extractColumnNames(iterable $rows, array $columns): array
{
$columns = $this->getNormalizeColumnNames('', $columns);

if ($columns !== [] || !is_array($rows)) {
return $columns;
}

$row = reset($rows);
$row = match (true) {
is_array($row) => $row,
$row instanceof Traversable => iterator_to_array($row),
is_object($row) => get_object_vars($row),
default => [],
};

if (array_key_exists(0, $row)) {
return [];
}

/** @var string[] $columnNames */
$columnNames = array_keys($row);

return array_combine($columnNames, $columnNames);
}

/**
* Prepare select-subQuery and field names for `INSERT INTO ... SELECT` SQL statement.
*
Expand Down
2 changes: 1 addition & 1 deletion tests/AbstractQueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ public function testBatchInsert(
string $expected,
array $expectedParams = [],
): void {
$db = $this->getConnection();
$db = $this->getConnection(true);
$qb = $db->getQueryBuilder();
$params = [];
Expand Down
52 changes: 52 additions & 0 deletions tests/Provider/CommandProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\Db\Tests\Provider;

use ArrayIterator;
use Yiisoft\Db\Command\DataType;
use Yiisoft\Db\Command\Param;
use Yiisoft\Db\Expression\Expression;
Expand Down Expand Up @@ -427,6 +428,57 @@ public static function batchInsert(): array
':qp3' => 2.0,
],
],
'empty columns and associative values' => [
'type',
[],
'values' => [['int_col' => '1.0', 'float_col' => '2', 'char_col' => 10, 'bool_col' => 1]],
'expected' => DbHelper::replaceQuotes(
<<<SQL
INSERT INTO [[type]] ([[int_col]], [[float_col]], [[char_col]], [[bool_col]]) VALUES (:qp0, :qp1, :qp2, :qp3)
SQL,
static::$driverName,
),
'expectedParams' => [
':qp0' => 1,
':qp1' => 2.0,
':qp2' => '10',
':qp3' => true,
],
],
'empty columns and objects' => [
'type',
[],
'values' => [(object)['int_col' => '1.0', 'float_col' => '2', 'char_col' => 10, 'bool_col' => 1]],
'expected' => DbHelper::replaceQuotes(
<<<SQL
INSERT INTO [[type]] ([[int_col]], [[float_col]], [[char_col]], [[bool_col]]) VALUES (:qp0, :qp1, :qp2, :qp3)
SQL,
static::$driverName,
),
'expectedParams' => [
':qp0' => 1,
':qp1' => 2.0,
':qp2' => '10',
':qp3' => true,
],
],
'empty columns and Traversable' => [
'type',
[],
'values' => [new ArrayIterator(['int_col' => '1.0', 'float_col' => '2', 'char_col' => 10, 'bool_col' => 1])],
'expected' => DbHelper::replaceQuotes(
<<<SQL
INSERT INTO [[type]] ([[int_col]], [[float_col]], [[char_col]], [[bool_col]]) VALUES (:qp0, :qp1, :qp2, :qp3)
SQL,
static::$driverName,
),
'expectedParams' => [
':qp0' => 1,
':qp1' => 2.0,
':qp2' => '10',
':qp3' => true,
],
],
];
}

Expand Down
19 changes: 18 additions & 1 deletion tests/Provider/QueryBuilderProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ public static function batchInsert(): array
[['no columns passed']],
'expected' => DbHelper::replaceQuotes(
<<<SQL
INSERT INTO [[customer]] () VALUES (:qp0)
INSERT INTO [[customer]] VALUES (:qp0)
SQL,
static::$driverName,
),
Expand Down Expand Up @@ -235,6 +235,23 @@ public static function batchInsert(): array
})(),
'',
],
'empty columns and non-exists table' => [
'non_exists_table',
[],
'values' => [['1.0', '2', 10, 1]],
'expected' => DbHelper::replaceQuotes(
<<<SQL
INSERT INTO [[non_exists_table]] VALUES (:qp0, :qp1, :qp2, :qp3)
SQL,
static::$driverName,
),
'expectedParams' => [
':qp0' => '1.0',
':qp1' => '2',
':qp2' => 10,
':qp3' => 1,
],
],
];
}

Expand Down
2 changes: 1 addition & 1 deletion tests/Support/DbHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final class DbHelper
{
public static function changeSqlForOracleBatchInsert(string &$str): void
{
$str = str_replace('INSERT INTO', 'INSERT ALL INTO', $str) . ' SELECT 1 FROM SYS.DUAL';
$str = str_replace('INSERT INTO', 'INSERT ALL INTO', $str) . ' SELECT 1 FROM SYS.DUAL';
}

public static function getPsrCache(): CacheInterface
Expand Down

0 comments on commit 085f007

Please sign in to comment.