diff --git a/.github/workflows/active-record.yml b/.github/workflows/active-record.yml index a894fad8a..567575e82 100644 --- a/.github/workflows/active-record.yml +++ b/.github/workflows/active-record.yml @@ -37,7 +37,6 @@ jobs: - ubuntu-latest php: - - 8.0 - 8.1 - 8.2 diff --git a/.github/workflows/rector.yml b/.github/workflows/rector.yml index b5a564913..35411d0ae 100644 --- a/.github/workflows/rector.yml +++ b/.github/workflows/rector.yml @@ -20,4 +20,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.0'] + ['8.3'] diff --git a/CHANGELOG.md b/CHANGELOG.md index 917eb85b2..4b096acd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +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 #794: Add message type to log context (@darkdef) +- Enh #795: Allow to use `DMLQueryBuilderInterface::batchInsert()` method with empty columns (@Tigrov) ## 1.2.0 November 12, 2023 diff --git a/composer.json b/composer.json index 37e3f53c1..f9199cc8b 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "require-dev": { "maglnet/composer-require-checker": "^4.2", "phpunit/phpunit": "^9.6|^10.0", - "rector/rector": "^0.18", + "rector/rector": "^0.19", "roave/infection-static-analysis-plugin": "^1.16", "spatie/phpunit-watcher": "^1.23", "vimeo/psalm": "^4.30|^5.12", @@ -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": { diff --git a/src/QueryBuilder/AbstractDMLQueryBuilder.php b/src/QueryBuilder/AbstractDMLQueryBuilder.php index ba435dc10..dc048eb2c 100644 --- a/src/QueryBuilder/AbstractDMLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDMLQueryBuilder.php @@ -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; @@ -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; /** @@ -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 @@ -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. * diff --git a/tests/AbstractQueryBuilderTest.php b/tests/AbstractQueryBuilderTest.php index 332c57186..938974a47 100644 --- a/tests/AbstractQueryBuilderTest.php +++ b/tests/AbstractQueryBuilderTest.php @@ -217,7 +217,7 @@ public function testBatchInsert( string $expected, array $expectedParams = [], ): void { - $db = $this->getConnection(); + $db = $this->getConnection(true); $qb = $db->getQueryBuilder(); $params = []; diff --git a/tests/Provider/CommandProvider.php b/tests/Provider/CommandProvider.php index 4b1254c9c..689426f80 100644 --- a/tests/Provider/CommandProvider.php +++ b/tests/Provider/CommandProvider.php @@ -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; @@ -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( + << [ + ':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( + << [ + ':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( + << [ + ':qp0' => 1, + ':qp1' => 2.0, + ':qp2' => '10', + ':qp3' => true, + ], + ], ]; } diff --git a/tests/Provider/QueryBuilderProvider.php b/tests/Provider/QueryBuilderProvider.php index 3a960ab6a..f37f97ebf 100644 --- a/tests/Provider/QueryBuilderProvider.php +++ b/tests/Provider/QueryBuilderProvider.php @@ -171,7 +171,7 @@ public static function batchInsert(): array [['no columns passed']], 'expected' => DbHelper::replaceQuotes( << [ + 'non_exists_table', + [], + 'values' => [['1.0', '2', 10, 1]], + 'expected' => DbHelper::replaceQuotes( + << [ + ':qp0' => '1.0', + ':qp1' => '2', + ':qp2' => 10, + ':qp3' => 1, + ], + ], ]; } diff --git a/tests/Support/DbHelper.php b/tests/Support/DbHelper.php index cefe38f43..8aeab5d9c 100644 --- a/tests/Support/DbHelper.php +++ b/tests/Support/DbHelper.php @@ -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