Insert multiple rows with a single statement (#76) - #106
Open
SMI-82 wants to merge 1 commit into
Open
Conversation
Implement multi-row insert support as requested in issue opis#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 opis#76
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Multi-row insert (#76)
This adds support for inserting several rows with a single statement, as requested in #76. Both calling forms proposed in that issue work:
This is a single, self-contained feature. It is the first of two: conflict handling (
INSERT IGNORE/ON CONFLICT/ON DUPLICATE KEY UPDATE) is deliberately left out, because it is dialect-specific and deserves its own discussion. I would rather agree on this part first.Backward compatibility
No existing behaviour changes. All 118 pre-existing tests are untouched and pass; the single-row code path in
Compiler::handleInsertValues()is byte-identical, so the SQL produced for a single row is exactly what it was.Two things worth flagging explicitly:
SQLStatement::getValues()keeps its original contract — a flat list of values. Rows are exposed through a newgetValueRows(), which is what the compilers now use. A third-party compiler overridinginsert()is unaffected.InsertStatement::insert()more than once now appends a row. Previously it producedINSERT INTO t (a, b, a, b) VALUES (?, ?, ?, ?), which was not usable, so nothing that worked before stops working.One caveat I want to be upfront about:
insert()gained a variadic parameter, so a third-party subclass that declaresinsert(array $values)would become an incompatible override. Nothing in this repository does —InsertextendsInsertStatementand only overridesinto(). If you would rather avoid even that, say so and I will move the extra rows behind a separate method instead.How the two argument shapes are told apart
Every argument goes through the same rule: if it is non-empty and every element is an array, it is a list of rows; otherwise it is a single row. This is unambiguous rather than heuristic — a column value can never legitimately be an array, because
Connection::bindValues()binds anything that is not null/int/bool asPDO::PARAM_STRand PDO rejects arrays. Outer keys are not inspected, so a list left over fromarray_filter()still works.The column list is fixed by the first row, in the order given, and is not sorted. A later row listing the same columns in a different order is reordered silently. A row with a different column set throws
InvalidArgumentExceptionnaming the 1-based row position and the column — for exampleRow 3 is missing the column "score". Filling inNULLwould have overridden column defaults and brokenNOT NULLcolumns silently, so it fails loudly instead.Dialects
VALUES (…), (…)is used for MySQL, PostgreSQL, SQLite, SQL Server, DB2 and NuoDB. Oracle and Firebird do not support it, so they get overrides:INSERT ALL INTO t (a, b) VALUES (?, ?) INTO t (a, b) VALUES (?, ?) SELECT * FROM dualINSERT INTO t (a, b) SELECT ?, ? FROM RDB$DATABASE UNION ALL SELECT ?, ? FROM RDB$DATABASEBoth guard with
count($rows) < 2and defer to the parent, so single-row output is untouched.Tests
20 new tests, 138 in total, passing on PHP 7.4 and PHP 8.3.
php -lis clean on PHP 7.0, which is the floor declared incomposer.json, so no PHP 7.1+ syntax slipped in.Covered: both argument forms and a mix of them; column reordering; a non-sequential list of rows; expressions that contribute a different number of placeholders per row;
DateTimevalues;nulland booleans; chained calls appending rows; the row number reported across chained calls; missing and unknown columns; an empty array before a real row; and the Oracle and Firebird output in their own test classes.Beyond the suite, I ran the library against live PostgreSQL 16, MySQL 8 and SQLite: six rows written with three statements, values and column order verified by reading them back, and ragged rows rejected before reaching the server.
What I could not verify: the Oracle and Firebird output is asserted at compiler level only — I have no servers for either. Firebird in particular is known to reject untyped
?in a bare select list, and I could not confirm whether theINSERT … SELECTtarget columns let it infer the types inside aUNION. If you have a way to check that, please do.Not included on purpose
Batches are not split automatically. Silent chunking would break atomicity outside a transaction and change what
into()returns, so the driver limits are the caller's business — SQLite 999 placeholders (32766 from 3.32), PostgreSQL 65535, MySQL 65535 plusmax_allowed_packet, SQL Server 2100 placeholders and 1000 rows perVALUES.into()still returns a boolean, and no last-insert-id is exposed for a batch: MySQL reports the id of the first row while SQLite and PostgreSQL report the last, so there is no portable answer.Closes #76