From cf2020ad2fc99d2f620a1323946a4276ec2fbe33 Mon Sep 17 00:00:00 2001 From: smiPC Date: Sat, 5 Sep 2026 19:31:59 +0300 Subject: [PATCH] feat: Support multi-row inserts in a single statement Implement multi-row insert support as requested in issue #76, allowing rows to be inserted with a single statement via two calling forms: $db->insert($row1, $row2, $row3)->into('tags'); $db->insert([$row1, $row2, $row3])->into('tags'); - Database::insert() and InsertStatement::insert() accept additional rows as variadic arguments; each argument is either a single row or a list of rows. - Column list is fixed by the first row and maintained in order. Later rows with the same columns in a different order are reordered silently; rows with different column sets throw InvalidArgumentException with the 1-based row position and offending column name. - SQLStatement stores values as a list of rows with new addValues() and getValueRows() methods; getValues() retains its flat-list contract for backward compatibility with third-party compilers. - Compiler::handleInsertMultipleValues() emits standard multi-row VALUES syntax; single-row output via handleInsertValues() remains unchanged. - Oracle and Firebird use database-specific syntax: Oracle uses INSERT ALL ... SELECT * FROM dual, Firebird uses SELECT ... FROM RDB$DATABASE UNION ALL ... - Repeated insert() calls now append rows instead of duplicating columns. Added 20 new tests; all 118 pre-existing tests pass unchanged on PHP 7.4 and 8.3. Closes #76 --- CHANGELOG.md | 20 ++++ src/Database.php | 7 +- src/SQL/Compiler.php | 23 +++- src/SQL/Compiler/Firebird.php | 33 ++++++ src/SQL/Compiler/Oracle.php | 31 +++++ src/SQL/InsertStatement.php | 115 +++++++++++++++++- src/SQL/SQLStatement.php | 45 ++++++- tests/SQL/InsertFirebirdTest.php | 48 ++++++++ tests/SQL/InsertOracleTest.php | 47 ++++++++ tests/SQL/InsertTest.php | 195 +++++++++++++++++++++++++++++++ 10 files changed, 553 insertions(+), 11 deletions(-) create mode 100644 tests/SQL/InsertFirebirdTest.php create mode 100644 tests/SQL/InsertOracleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b46941..2e9e831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Added + +- Support for inserting multiple rows with a single statement. `Opis\Database\Database::insert` + and `Opis\Database\SQL\InsertStatement::insert` now accept a list of rows, besides a single row. + See [issue #76](https://github.com/opis/database/issues/76) +- Additional rows may also be passed as extra arguments to `Opis\Database\Database::insert` and + `Opis\Database\SQL\InsertStatement::insert`, instead of a single list of rows in the first argument +- Added `Opis\Database\SQL\SQLStatement::addValues` method +- Added `Opis\Database\SQL\SQLStatement::getValueRows` method +- Added `Opis\Database\SQL\Compiler::handleInsertMultipleValues` method +- Added `INSERT ALL` fallback for Oracle and `UNION ALL` fallback for Firebird, + since neither supports a multi-row `VALUES` clause + +### Changed + +- Calling `Opis\Database\SQL\InsertStatement::insert` more than once now appends a row + instead of duplicating the column list + ## v4.3.0 - 2024-09-29 ### Added diff --git a/src/Database.php b/src/Database.php index d5e77ec..e01c0ba 100644 --- a/src/Database.php +++ b/src/Database.php @@ -75,13 +75,14 @@ public function from($tables): QueryCommand /** * Insert new records into a table. * - * @param array $values An array of values. + * @param array $values A single row, or a list of rows + * @param array ...$rows Additional rows, each given the same way as $values * * @return InsertCommand|InsertStatement */ - public function insert(array $values): InsertCommand + public function insert(array $values, array ...$rows): InsertCommand { - return (new InsertCommand($this->connection))->insert($values); + return (new InsertCommand($this->connection))->insert($values, ...$rows); } /** diff --git a/src/SQL/Compiler.php b/src/SQL/Compiler.php index 65dbea6..f6cb7d1 100644 --- a/src/SQL/Compiler.php +++ b/src/SQL/Compiler.php @@ -64,11 +64,14 @@ public function select(SQLStatement $select): string public function insert(SQLStatement $insert): string { $columns = $this->handleColumns($insert->getColumns()); + $rows = $insert->getValueRows(); $sql = 'INSERT INTO '; $sql .= $this->handleTables($insert->getTables()); $sql .= ($columns === '*') ? '' : ' (' . $columns . ')'; - $sql .= $this->handleInsertValues($insert->getValues()); + $sql .= count($rows) > 1 + ? $this->handleInsertMultipleValues($rows) + : $this->handleInsertValues($rows === [] ? [] : reset($rows)); return $sql; } @@ -518,6 +521,24 @@ protected function handleInsertValues(array $values) return ' VALUES (' . $this->params($values) . ')'; } + /** + * Handle multiple rows of insert values + * + * @param array $rows + * + * @return string + */ + protected function handleInsertMultipleValues(array $rows): string + { + $sql = []; + + foreach ($rows as $row) { + $sql[] = '(' . $this->params($row) . ')'; + } + + return ' VALUES ' . implode(', ', $sql); + } + /** * Handle limits * diff --git a/src/SQL/Compiler/Firebird.php b/src/SQL/Compiler/Firebird.php index 2352841..9725b9e 100644 --- a/src/SQL/Compiler/Firebird.php +++ b/src/SQL/Compiler/Firebird.php @@ -71,4 +71,37 @@ public function select(SQLStatement $select): string return $sql; } + + /** + * Compiles an INSERT statement. + * + * Firebird has no multi-row VALUES clause, so several rows are compiled + * into a UNION ALL of single-row selects instead. + * + * @access public + * @param SQLStatement $insert + * @return string + */ + public function insert(SQLStatement $insert): string + { + $rows = $insert->getValueRows(); + + if (count($rows) < 2) { + return parent::insert($insert); + } + + $columns = $this->handleColumns($insert->getColumns()); + + $sql = 'INSERT INTO '; + $sql .= $this->handleTables($insert->getTables()); + $sql .= ($columns === '*') ? '' : ' (' . $columns . ')'; + + $selects = []; + + foreach ($rows as $row) { + $selects[] = 'SELECT ' . $this->params($row) . ' FROM RDB$DATABASE'; + } + + return $sql . ' ' . implode(' UNION ALL ', $selects); + } } diff --git a/src/SQL/Compiler/Oracle.php b/src/SQL/Compiler/Oracle.php index 176cf7e..745eda3 100644 --- a/src/SQL/Compiler/Oracle.php +++ b/src/SQL/Compiler/Oracle.php @@ -61,6 +61,37 @@ public function select(SQLStatement $select): string return 'SELECT * FROM (SELECT M1.*, ROWNUM AS OPIS_ROWNUM FROM (' . $sql . ') M1 WHERE ROWNUM <= ' . $limit . ') WHERE OPIS_ROWNUM >= ' . $offset; } + /** + * Compiles an INSERT statement. + * + * Oracle has no multi-row VALUES clause, so several rows are compiled + * into an INSERT ALL statement instead. + * + * @param SQLStatement $insert + * + * @return string + */ + public function insert(SQLStatement $insert): string + { + $rows = $insert->getValueRows(); + + if (count($rows) < 2) { + return parent::insert($insert); + } + + $columns = $this->handleColumns($insert->getColumns()); + $columns = ($columns === '*') ? '' : ' (' . $columns . ')'; + $table = $this->handleTables($insert->getTables()); + + $sql = 'INSERT ALL'; + + foreach ($rows as $row) { + $sql .= ' INTO ' . $table . $columns . ' VALUES (' . $this->params($row) . ')'; + } + + return $sql . ' SELECT * FROM dual'; + } + /** * @param mixed $value * diff --git a/src/SQL/InsertStatement.php b/src/SQL/InsertStatement.php index 27c021d..9a67cb5 100644 --- a/src/SQL/InsertStatement.php +++ b/src/SQL/InsertStatement.php @@ -23,6 +23,9 @@ class InsertStatement /** @var SQLStatement */ protected $sql; + /** @var string[]|null The column list, fixed by the first inserted row */ + protected $insertColumns; + /** * InsertStatement constructor. * @param SQLStatement|null $statement @@ -45,14 +48,29 @@ public function getSQLStatement(): SQLStatement } /** - * @param array $values + * Adds one or more rows of values to the statement. + * + * Each argument is handled independently: it may be a single row, given as a + * column-value map, or a list of such rows, exactly like a single call with + * {@see insert()} would treat it. + * + * @param array $values A single row, given as a column-value map, or a list of such rows + * @param array ...$rows Additional rows, each given the same way as $values * @return InsertStatement + * @throws \InvalidArgumentException If a row does not match the columns of the first row */ - public function insert(array $values): self + public function insert(array $values, array ...$rows): self { - foreach ($values as $column => $value) { - $this->sql->addColumn($column); - $this->sql->addValue($value); + array_unshift($rows, $values); + + foreach ($rows as $argument) { + if ($this->holdsMultipleRows($argument)) { + foreach ($argument as $row) { + $this->addRow($row); + } + } else { + $this->addRow($argument); + } } return $this; @@ -73,4 +91,91 @@ public function __clone() { $this->sql = clone $this->sql; } + + /** + * Tells whether the given argument holds multiple rows instead of a single one. + * + * @param array $values + * @return bool + */ + private function holdsMultipleRows(array $values): bool + { + if ($values === []) { + return false; + } + + foreach ($values as $row) { + if (!is_array($row)) { + return false; + } + } + + return true; + } + + /** + * Registers a single row, fixing the statement's column list on the first one. + * + * An empty row contributes nothing: no columns and no values row. + * + * @param array $row + * @return void + * @throws \InvalidArgumentException If the row does not match the fixed column list + */ + private function addRow(array $row) + { + if ($row === []) { + return; + } + + if ($this->insertColumns === null) { + $this->insertColumns = array_keys($row); + + foreach ($this->insertColumns as $column) { + $this->sql->addColumn($column); + } + + $this->sql->addValues(array_values($row)); + + return; + } + + $this->sql->addValues($this->alignRow($row)); + } + + /** + * Validates a row against the statement's column list and reorders its values accordingly. + * + * The row number reported in any thrown exception is 1-based and reflects the row's + * position within the whole statement, counting up across chained {@see insert()} calls. + * + * @param array $row + * @return array The row's values, ordered like the statement's column list + * @throws \InvalidArgumentException If the row misses a column or holds an unknown one + */ + private function alignRow(array $row): array + { + $index = count($this->sql->getValueRows()) + 1; + $values = []; + + foreach ($this->insertColumns as $column) { + if (!array_key_exists($column, $row)) { + throw new \InvalidArgumentException( + sprintf('Row %d is missing the column "%s"', $index, $column) + ); + } + + $values[] = $row[$column]; + } + + foreach ($row as $column => $value) { + if (!in_array($column, $this->insertColumns, true)) { + throw new \InvalidArgumentException( + sprintf('Row %d contains an unknown column "%s"', $index, $column) + ); + } + } + + return $values; + } } diff --git a/src/SQL/SQLStatement.php b/src/SQL/SQLStatement.php index f9b57e0..8319649 100644 --- a/src/SQL/SQLStatement.php +++ b/src/SQL/SQLStatement.php @@ -34,6 +34,7 @@ class SQLStatement protected $intoTable; protected $intoDatabase; protected $from = []; + /** @var array[] Rows of values, each row ordered like the statement's column list */ protected $values = []; /** @@ -411,11 +412,34 @@ public function setFrom(array $from) } /** - * @param $value + * Appends a single value to the last row of values, starting a first row if none exists. + * + * @param mixed $value + * @return void */ public function addValue($value) { - $this->values[] = $this->closureToExpression($value); + if ($this->values === []) { + $this->values[] = []; + } + + $this->values[count($this->values) - 1][] = $this->closureToExpression($value); + } + + /** + * Appends a whole row of values, ordered like the statement's column list. + * + * @param array $values + * @return void + */ + public function addValues(array $values) + { + foreach ($values as &$value) { + $value = $this->closureToExpression($value); + } + unset($value); + + $this->values[] = array_values($values); } /** @@ -523,9 +547,26 @@ public function getFrom(): array } /** + * A multi-row statement returns every row's values one after another, flattened + * into a single list. Use {@see getValueRows()} to get the values grouped by row. + * * @return array */ public function getValues(): array + { + if (count($this->values) < 2) { + return $this->values === [] ? [] : reset($this->values); + } + + return call_user_func_array('array_merge', $this->values); + } + + /** + * Returns the rows of values to be inserted. + * + * @return array[] A list of rows, each row ordered like the statement's column list + */ + public function getValueRows(): array { return $this->values; } diff --git a/tests/SQL/InsertFirebirdTest.php b/tests/SQL/InsertFirebirdTest.php new file mode 100644 index 0000000..180535c --- /dev/null +++ b/tests/SQL/InsertFirebirdTest.php @@ -0,0 +1,48 @@ +db->insert([ + ['name' => 'foo', 'age' => 18], + ['name' => 'bar', 'age' => 21], + ])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testSingleRowIsUnaffected() + { + $expected = 'INSERT INTO "users" ("name", "age") VALUES (\'foo\', 18)'; + $actual = $this->db->insert(['name' => 'foo', 'age' => 18])->into('users'); + $this->assertEquals($expected, $actual); + } +} diff --git a/tests/SQL/InsertOracleTest.php b/tests/SQL/InsertOracleTest.php new file mode 100644 index 0000000..34ae42a --- /dev/null +++ b/tests/SQL/InsertOracleTest.php @@ -0,0 +1,47 @@ +db->insert([ + ['name' => 'foo', 'age' => 18], + ['name' => 'bar', 'age' => 21], + ])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testSingleRowIsUnaffected() + { + $expected = 'INSERT INTO "USERS" ("NAME", "AGE") VALUES (\'foo\', 18)'; + $actual = $this->db->insert(['name' => 'foo', 'age' => 18])->into('users'); + $this->assertEquals($expected, $actual); + } +} diff --git a/tests/SQL/InsertTest.php b/tests/SQL/InsertTest.php index 7f46668..49a59a4 100644 --- a/tests/SQL/InsertTest.php +++ b/tests/SQL/InsertTest.php @@ -52,4 +52,199 @@ public function testInsertExpressions() ])->into('users'); $this->assertEquals($expected, $actual); } + + public function testInsertMultipleRows() + { + $expected = 'INSERT INTO "users" ("name", "age") VALUES (\'foo\', 18), (\'bar\', 21)'; + $actual = $this->db->insert([ + ['name' => 'foo', 'age' => 18], + ['name' => 'bar', 'age' => 21], + ])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testInsertMultipleRowsPreservesFirstRowColumnOrder() + { + $expected = 'INSERT INTO "users" ("name", "age") VALUES (\'foo\', 18), (\'bar\', 21)'; + $actual = $this->db->insert([ + ['name' => 'foo', 'age' => 18], + ['age' => 21, 'name' => 'bar'], + ])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testInsertMultipleRowsFromNonSequentialList() + { + $rows = [ + ['name' => 'foo', 'age' => 18], + ['name' => 'skipped', 'age' => 0], + ['name' => 'bar', 'age' => 21], + ]; + + $rows = array_filter($rows, function (array $row) { + return $row['age'] > 0; + }); + + $expected = 'INSERT INTO "users" ("name", "age") VALUES (\'foo\', 18), (\'bar\', 21)'; + $actual = $this->db->insert($rows)->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testInsertMultipleRowsWithExpressions() + { + $expected = 'INSERT INTO "users" ("name") VALUES (LCASE( \'foo\' )), (LCASE( \'bar\' ))'; + $actual = $this->db->insert([ + [ + 'name' => function (Expression $expr) { + $expr->{'LCASE('}->value('foo')->{')'}; + }, + ], + [ + 'name' => function (Expression $expr) { + $expr->{'LCASE('}->value('bar')->{')'}; + }, + ], + ])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testInsertMultipleRowsWithNullAndBoolean() + { + $expected = 'INSERT INTO "test" ("foo", "bar") VALUES (NULL, TRUE), (FALSE, NULL)'; + $actual = $this->db->insert([ + ['foo' => null, 'bar' => true], + ['foo' => false, 'bar' => null], + ])->into('test'); + $this->assertEquals($expected, $actual); + } + + public function testChainedInsertAppendsRows() + { + $expected = 'INSERT INTO "users" ("name", "age") VALUES (\'foo\', 18), (\'bar\', 21)'; + $actual = $this->db->insert(['name' => 'foo', 'age' => 18]) + ->insert(['name' => 'bar', 'age' => 21]) + ->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testInsertRejectsRowWithMissingColumn() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Row 3 is missing the column "score"'); + + $this->db->insert([ + ['tag' => 'a', 'score' => 1], + ['tag' => 'b', 'score' => 2], + ['tag' => 'c'], + ])->into('tags'); + } + + public function testInsertRejectsRowWithUnknownColumn() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Row 3 contains an unknown column "extra"'); + + $this->db->insert([ + ['tag' => 'a', 'score' => 1], + ['tag' => 'b', 'score' => 2], + ['tag' => 'c', 'score' => 3, 'extra' => 4], + ])->into('tags'); + } + + public function testInsertEmptyArrayIsUnchanged() + { + $expected = 'INSERT INTO "users" VALUES ()'; + $actual = $this->db->insert([])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testInsertMultipleRowsWithVaryingExpressionArity() + { + $expected = 'INSERT INTO "users" ("name") VALUES (CONCAT( \'foo\' , \'bar\' )), (NOW()), (\'baz\')'; + $actual = $this->db->insert([ + [ + 'name' => function (Expression $expr) { + $expr->{'CONCAT('}->value('foo')->{','}->value('bar')->{')'}; + }, + ], + [ + 'name' => function (Expression $expr) { + $expr->now(); + }, + ], + [ + 'name' => 'baz', + ], + ])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testInsertMultipleRowsWithDateTime() + { + $expected = 'INSERT INTO "users" ("name", "created_at") VALUES (\'foo\', \'2023-01-01 10:00:00\'), (\'bar\', \'2023-01-01 12:00:00\')'; + $actual = $this->db->insert([ + ['name' => 'foo', 'created_at' => new \DateTime('2023-01-01 10:00:00')], + ['name' => 'bar', 'created_at' => new \DateTime('2023-01-01 12:00:00')], + ])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testChainedInsertReportsRowPositionAcrossCalls() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Row 3 contains an unknown column "extra"'); + + $this->db->insert(['name' => 'foo', 'age' => 18]) + ->insert(['name' => 'bar', 'age' => 21]) + ->insert(['name' => 'baz', 'age' => 30, 'extra' => 1]) + ->into('users'); + } + + public function testInsertEmptyArrayThenRow() + { + $expected = 'INSERT INTO "users" ("name") VALUES (\'foo\')'; + $actual = $this->db->insert([])->insert(['name' => 'foo'])->into('users'); + $this->assertEquals($expected, $actual); + } + + public function testInsertMultipleRowsAsSeparateArguments() + { + $expected = $this->db->insert([ + ['tag' => 'asd', 'score' => 1], + ['tag' => 'asd2', 'score' => 2], + ['tag' => 'asd3', 'score' => 3], + ])->into('tags'); + + $actual = $this->db->insert( + ['tag' => 'asd', 'score' => 1], + ['tag' => 'asd2', 'score' => 2], + ['tag' => 'asd3', 'score' => 3] + )->into('tags'); + + $this->assertEquals($expected, $actual); + } + + public function testInsertMixedArgumentForms() + { + $expected = 'INSERT INTO "tags" ("tag", "score") VALUES (\'a\', 1), (\'b\', 2), (\'c\', 3)'; + $actual = $this->db->insert( + [ + ['tag' => 'a', 'score' => 1], + ['tag' => 'b', 'score' => 2], + ], + ['tag' => 'c', 'score' => 3] + )->into('tags'); + $this->assertEquals($expected, $actual); + } + + public function testInsertSeparateArgumentsRejectMismatchedRow() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Row 2 is missing the column "score"'); + + $this->db->insert( + ['tag' => 'a', 'score' => 1], + ['tag' => 'b'] + )->into('tags'); + } } \ No newline at end of file