Skip to content

Insert multiple rows with a single statement (#76) - #106

Open
SMI-82 wants to merge 1 commit into
opis:masterfrom
SMI-82:feature/multi-row-insert
Open

Insert multiple rows with a single statement (#76)#106
SMI-82 wants to merge 1 commit into
opis:masterfrom
SMI-82:feature/multi-row-insert

Conversation

@SMI-82

@SMI-82 SMI-82 commented Sep 5, 2026

Copy link
Copy Markdown

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:

// one row per argument
$db->insert(
    ['tag' => 'asd',  'score' => rand()],
    ['tag' => 'asd2', 'score' => rand()],
    ['tag' => 'asd3', 'score' => rand()],
)->into('tags');

// a list of rows
$db->insert([
    ['tag' => 'asd',  'score' => rand()],
    ['tag' => 'asd2', 'score' => rand()],
])->into('tags');

// INSERT INTO "tags" ("tag", "score") VALUES (?, ?), (?, ?)

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 new getValueRows(), which is what the compilers now use. A third-party compiler overriding insert() is unaffected.
  • Calling InsertStatement::insert() more than once now appends a row. Previously it produced INSERT 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 declares insert(array $values) would become an incompatible override. Nothing in this repository does — Insert extends InsertStatement and only overrides into(). 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 as PDO::PARAM_STR and PDO rejects arrays. Outer keys are not inspected, so a list left over from array_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 InvalidArgumentException naming the 1-based row position and the column — for example Row 3 is missing the column "score". Filling in NULL would have overridden column defaults and broken NOT NULL columns 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:

  • Oracle — INSERT ALL INTO t (a, b) VALUES (?, ?) INTO t (a, b) VALUES (?, ?) SELECT * FROM dual
  • Firebird — INSERT INTO t (a, b) SELECT ?, ? FROM RDB$DATABASE UNION ALL SELECT ?, ? FROM RDB$DATABASE

Both guard with count($rows) < 2 and 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 -l is clean on PHP 7.0, which is the floor declared in composer.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; DateTime values; null and 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 the INSERT … SELECT target columns let it infer the types inside a UNION. 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 plus max_allowed_packet, SQL Server 2100 placeholders and 1000 rows per VALUES.

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

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adding multiple data with a single query

1 participant