Skip to content

Commit 2301e3d

Browse files
authored
Add batch() returning per-statement result sets (#227)
Implement the per-statement batch() interface from tursodatabase/turso#7343 in both the async (promise.js) and sync (compat.js) Database APIs. batch(statements, mode?) executes statements sequentially and returns one ResultSet per input statement, each with columns, columnTypes, rows (with positional and named access), rowsAffected, lastInsertRowid, and toJSON(). When a transaction mode is provided and the connection is not already in a transaction, the batch is wrapped in BEGIN/COMMIT and rolled back on failure.
2 parents 487c226 + 7bd0ec7 commit 2301e3d

5 files changed

Lines changed: 415 additions & 0 deletions

File tree

compat.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,77 @@ class Database {
196196
}
197197
}
198198

199+
/**
200+
* Executes a batch of SQL statements sequentially, returning one
201+
* result object per input statement.
202+
*
203+
* When `mode` is provided and the connection is not already inside a
204+
* transaction, the batch is wrapped in a `BEGIN <mode>` / `COMMIT`
205+
* transaction that is rolled back if any statement fails.
206+
*
207+
* @param {Array<string | { sql: string, args?: any[] | Record<string, any> }>} statements - The statements to execute.
208+
* @param {string | { mode?: string, raw?: boolean }} [options] - Optional
209+
* transaction mode or batch options. When `mode` is provided and the
210+
* connection is not already inside a transaction, the statements run inside
211+
* a transaction. When `raw` is true, reader rows are returned as arrays.
212+
*
213+
* Batch mutation result sets intentionally expose `rowsAffected` only. Unlike
214+
* `Statement.run()`, they do not include `lastInsertRowid`.
215+
* @returns {Array<{ columns: string[], columnTypes: string[], rows: Array<Record<string, any> | any[]>, rowsAffected: number }>}
216+
*/
217+
batch(statements, options) {
218+
if (!Array.isArray(statements)) {
219+
throw new TypeError("Expected first argument to be an array of statements");
220+
}
221+
222+
const { mode, raw } = normalizeBatchOptions(options);
223+
const wrap = mode != null && !this.inTransaction;
224+
if (wrap) {
225+
this.exec(`BEGIN ${normalizeBatchMode(mode)}`);
226+
}
227+
228+
const results = [];
229+
try {
230+
for (const statement of statements) {
231+
const sql = typeof statement === "string" ? statement : statement.sql;
232+
const args = typeof statement === "string" ? undefined : statement.args;
233+
234+
const stmt = this.prepare(sql);
235+
const cols = stmt.columns();
236+
const columnNames = cols.map((c) => c.name);
237+
const columnTypes = cols.map((c) => c.type ?? "");
238+
239+
if (columnNames.length > 0) {
240+
// Reader statement: collect the returned rows.
241+
if (raw) {
242+
stmt.raw(true);
243+
}
244+
const rows = args !== undefined ? stmt.all(args) : stmt.all();
245+
results.push(makeResultSet(columnNames, columnTypes, rows, 0));
246+
} else {
247+
// Mutating statement: report affected rows only; batch results do not
248+
// expose Statement.run()'s lastInsertRowid by design.
249+
const info = args !== undefined ? stmt.run(args) : stmt.run();
250+
results.push(makeResultSet(columnNames, columnTypes, [], info.changes));
251+
}
252+
}
253+
254+
if (wrap) {
255+
this.exec("COMMIT");
256+
}
257+
} catch (err) {
258+
if (wrap) {
259+
try {
260+
this.exec("ROLLBACK");
261+
} catch (_) {
262+
// ignore rollback failures and surface the original error
263+
}
264+
}
265+
throw convertError(err);
266+
}
267+
return results;
268+
}
269+
199270
/**
200271
* Interrupts the database connection.
201272
*/
@@ -388,3 +459,42 @@ module.exports = Database;
388459
module.exports.SqliteError = SqliteError;
389460
module.exports.Authorization = Authorization;
390461
module.exports.Action = Action;
462+
463+
function normalizeBatchMode(mode) {
464+
switch (String(mode).toLowerCase()) {
465+
case "write":
466+
return "IMMEDIATE";
467+
case "read":
468+
return "DEFERRED";
469+
case "deferred":
470+
return "DEFERRED";
471+
case "immediate":
472+
return "IMMEDIATE";
473+
case "exclusive":
474+
return "EXCLUSIVE";
475+
default:
476+
return String(mode).toUpperCase();
477+
}
478+
}
479+
480+
function normalizeBatchOptions(options) {
481+
if (options != null && typeof options === "object") {
482+
return {
483+
mode: options.mode,
484+
raw: options.raw === true,
485+
};
486+
}
487+
return {
488+
mode: options,
489+
raw: false,
490+
};
491+
}
492+
493+
function makeResultSet(columns, columnTypes, rows, rowsAffected) {
494+
return {
495+
columns,
496+
columnTypes,
497+
rows,
498+
rowsAffected,
499+
};
500+
}

docs/api.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,36 @@ Executes a SQL statement.
262262
| sql | <code>string</code> | The SQL statement string to execute. |
263263
| queryOptions | <code>object</code> | Optional per-query overrides (for example, `{ queryTimeout: 100 }`). |
264264

265+
### batch(statements, [options]) ⇒ array of ResultSet
266+
267+
Executes a batch of SQL statements sequentially and returns one `ResultSet`
268+
per input statement. Each statement may be a SQL string or an object of the
269+
form `{ sql, args }`, where `args` is an array (positional) or an object
270+
(named) of bind parameters.
271+
272+
`options` may be a transaction mode string for compatibility, or an object with
273+
`mode` and `raw` fields. Set `raw: true` to return reader rows in the same array
274+
form as `Statement.raw().all()`.
275+
276+
| Param | Type | Description |
277+
| ---------- | ------------------- | ------------------------------------ |
278+
| statements | <code>array</code> | The statements to execute. |
279+
| options | <code>string \| object</code> | Optional transaction mode string or `{ mode, raw }` object. When `mode` is provided and the connection is not already in a transaction, the batch runs inside a transaction that is rolled back if any statement fails. When `raw` is true, reader rows are arrays. |
280+
281+
Each `ResultSet` has the following shape:
282+
283+
| Field | Type | Description |
284+
| --------------- | ----------------------------- | --------------------------------------------- |
285+
| columns | <code>string[]</code> | The column names of the result. |
286+
| columnTypes | <code>string[]</code> | The declared column types of the result. |
287+
| rows | <code>Row[]</code> | Rows returned by `Statement.all()`. |
288+
| rowsAffected | <code>number</code> | The number of rows changed by the statement. |
289+
290+
Mutation result sets intentionally expose `rowsAffected` only. Unlike
291+
`Statement.run()`, `batch()` result sets do not include `lastInsertRowid`.
292+
293+
**Note:** This is an extension in libSQL and not available in `better-sqlite3`.
294+
265295
### interrupt() ⇒ this
266296

267297
Cancel ongoing operations and make them return at earliest opportunity.

integration-tests/tests/async.test.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,79 @@ test.serial("Database.run() forwards queryOptions", async (t) => {
745745
);
746746
});
747747

748+
test.serial("Database.batch() returns per-statement result sets", async (t) => {
749+
const db = t.context.db;
750+
751+
const results = await db.batch([
752+
{ sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", args: [3, "Carol", "carol@example.org"] },
753+
{ sql: "UPDATE users SET email = ? WHERE id = ?", args: ["alice@new.org", 1] },
754+
"SELECT id, name FROM users ORDER BY id",
755+
]);
756+
757+
t.true(Array.isArray(results));
758+
t.is(results.length, 3);
759+
760+
// INSERT
761+
t.deepEqual(results[0].columns, []);
762+
t.deepEqual(results[0].columnTypes, []);
763+
t.deepEqual(results[0].rows, []);
764+
t.is(results[0].rowsAffected, 1);
765+
766+
// UPDATE
767+
t.is(results[1].rowsAffected, 1);
768+
769+
// SELECT
770+
t.deepEqual(results[2].columns, ["id", "name"]);
771+
t.is(results[2].rowsAffected, 0);
772+
t.is(results[2].rows.length, 3);
773+
774+
// Default Statement.all() row shape
775+
const row = results[2].rows[0];
776+
t.false(Array.isArray(row));
777+
t.is(row.id, 1);
778+
t.is(row.name, "Alice");
779+
780+
t.is(results[0].toJSON, undefined);
781+
});
782+
783+
test.serial("Database.batch() with named args", async (t) => {
784+
const db = t.context.db;
785+
const results = await db.batch([
786+
{ sql: "SELECT * FROM users WHERE id = :id", args: { id: 2 } },
787+
]);
788+
t.is(results.length, 1);
789+
t.is(results[0].rows.length, 1);
790+
t.is(results[0].rows[0].name, "Bob");
791+
});
792+
793+
test.serial("Database.batch() with raw rows", async (t) => {
794+
const db = t.context.db;
795+
const results = await db.batch([
796+
{ sql: "SELECT id, name FROM users WHERE id = ?", args: [1] },
797+
], { raw: true });
798+
t.is(results.length, 1);
799+
t.deepEqual(results[0].rows, [[1, "Alice"]]);
800+
});
801+
802+
test.serial("Database.batch() rolls back on error when given a mode", async (t) => {
803+
const db = t.context.db;
804+
await t.throwsAsync(async () => {
805+
await db.batch([
806+
{ sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", args: [10, "Dan", "dan@example.org"] },
807+
{ sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", args: [1, "Dup", "dup@example.org"] },
808+
], "write");
809+
});
810+
// The first insert must have been rolled back.
811+
const stmt = await db.prepare("SELECT count(*) AS c FROM users WHERE id = 10");
812+
const row = await stmt.get();
813+
t.is(row.c, 0);
814+
});
815+
816+
test.serial("Database.batch() rejects non-array argument", async (t) => {
817+
const db = t.context.db;
818+
await t.throwsAsync(() => db.batch("SELECT 1"), { instanceOf: TypeError });
819+
});
820+
748821
const connect = async (path_opt, options = {}) => {
749822
const path = path_opt ?? "hello.db";
750823
const provider = process.env.PROVIDER;

integration-tests/tests/sync.test.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,98 @@ test.serial("Statement.reader [DELETE RETURNING is true]", async (t) => {
667667
t.is(stmt.reader, true);
668668
});
669669

670+
test.serial("Database.batch() returns per-statement result sets", async (t) => {
671+
if (t.context.provider !== "libsql") {
672+
t.pass();
673+
return;
674+
}
675+
const db = t.context.db;
676+
677+
const results = db.batch([
678+
{ sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", args: [3, "Carol", "carol@example.org"] },
679+
{ sql: "UPDATE users SET email = ? WHERE id = ?", args: ["alice@new.org", 1] },
680+
"SELECT id, name FROM users ORDER BY id",
681+
]);
682+
683+
t.true(Array.isArray(results));
684+
t.is(results.length, 3);
685+
686+
// INSERT
687+
t.deepEqual(results[0].columns, []);
688+
t.deepEqual(results[0].columnTypes, []);
689+
t.deepEqual(results[0].rows, []);
690+
t.is(results[0].rowsAffected, 1);
691+
692+
// UPDATE
693+
t.is(results[1].rowsAffected, 1);
694+
695+
// SELECT
696+
t.deepEqual(results[2].columns, ["id", "name"]);
697+
t.is(results[2].rowsAffected, 0);
698+
t.is(results[2].rows.length, 3);
699+
700+
// Default Statement.all() row shape
701+
const row = results[2].rows[0];
702+
t.false(Array.isArray(row));
703+
t.is(row.id, 1);
704+
t.is(row.name, "Alice");
705+
706+
t.is(results[0].toJSON, undefined);
707+
});
708+
709+
test.serial("Database.batch() with named args", async (t) => {
710+
if (t.context.provider !== "libsql") {
711+
t.pass();
712+
return;
713+
}
714+
const db = t.context.db;
715+
const results = db.batch([
716+
{ sql: "SELECT * FROM users WHERE id = :id", args: { id: 2 } },
717+
]);
718+
t.is(results.length, 1);
719+
t.is(results[0].rows.length, 1);
720+
t.is(results[0].rows[0].name, "Bob");
721+
});
722+
723+
test.serial("Database.batch() with raw rows", async (t) => {
724+
if (t.context.provider !== "libsql") {
725+
t.pass();
726+
return;
727+
}
728+
const db = t.context.db;
729+
const results = db.batch([
730+
{ sql: "SELECT id, name FROM users WHERE id = ?", args: [1] },
731+
], { raw: true });
732+
t.is(results.length, 1);
733+
t.deepEqual(results[0].rows, [[1, "Alice"]]);
734+
});
735+
736+
test.serial("Database.batch() rolls back on error when given a mode", async (t) => {
737+
if (t.context.provider !== "libsql") {
738+
t.pass();
739+
return;
740+
}
741+
const db = t.context.db;
742+
t.throws(() => {
743+
db.batch([
744+
{ sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", args: [10, "Dan", "dan@example.org"] },
745+
{ sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", args: [1, "Dup", "dup@example.org"] },
746+
], "write");
747+
});
748+
// The first insert must have been rolled back.
749+
const row = db.prepare("SELECT count(*) AS c FROM users WHERE id = 10").get();
750+
t.is(row.c, 0);
751+
});
752+
753+
test.serial("Database.batch() rejects non-array argument", async (t) => {
754+
if (t.context.provider !== "libsql") {
755+
t.pass();
756+
return;
757+
}
758+
const db = t.context.db;
759+
t.throws(() => db.batch("SELECT 1"), { instanceOf: TypeError });
760+
});
761+
670762
const connect = async (path_opt, options = {}) => {
671763
const path = path_opt ?? "hello.db";
672764
const provider = process.env.PROVIDER;

0 commit comments

Comments
 (0)