Return inserted/updated records from insert and update handlers

Append RETURNING * to generated INSERT/UPDATE SQL so the affected
record(s) are returned in payload/singleRes. Vector tables skip RETURNING
on update because SQLite does not support UPDATE ... RETURNING on virtual
tables, falling back to postInsertReturn.

postInsertReturn (affectedRows/insertId) is still populated for backwards
compatibility. Adds a returning?: boolean opt-out (default true) and tests.
This commit is contained in:
2026-10-17 18:43:46 +01:00
parent 7c095f6f16
commit 695347944d
4 changed files with 317 additions and 23 deletions
+25 -7
View File
@@ -395,8 +395,8 @@ All methods return an `APIResponseObject<T>`:
```ts ```ts
{ {
success: boolean; success: boolean;
payload?: T[]; // array of rows (select) payload?: T[]; // array of rows (select; also insert/update)
singleRes?: T; // first row (select) singleRes?: T; // first row (select; also insert/update)
count?: number; // total count (when count: true) count?: number; // total count (when count: true)
postInsertReturn?: { postInsertReturn?: {
affectedRows?: number; affectedRows?: number;
@@ -458,16 +458,22 @@ const res = await BunSQLite.select({
### Insert ### Insert
```ts ```ts
BunSQLite.insert<T>({ table, data }); BunSQLite.insert<T>({ table, data, update_on_duplicate?, returning? });
``` ```
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | -------- | ------------------------------ | | --------------------- | --------- | ---------------------------------------------------------------- |
| `table` | `string` | Table name | | `table` | `string` | Table name |
| `data` | `T[]` | Array of row objects to insert | | `data` | `T[]` | Array of row objects to insert |
| `update_on_duplicate` | `boolean` | Upsert via `ON CONFLICT ... DO UPDATE` |
| `returning` | `boolean` | Return the inserted record(s). Defaults to `true` |
`created_at` and `updated_at` are set automatically to `Date.now()`. `created_at` and `updated_at` are set automatically to `Date.now()`.
On success the inserted record(s) come back in `singleRes` (first row) and
`payload` (all rows, for multi-row inserts). `postInsertReturn` is still
populated for backwards compatibility.
**Example:** **Example:**
```ts ```ts
@@ -479,38 +485,50 @@ const res = await BunSQLite.insert({
], ],
}); });
console.log(res.singleRes); // first inserted record (with its id)
console.log(res.payload); // every inserted record
console.log(res.postInsertReturn?.insertId); // last inserted row ID console.log(res.postInsertReturn?.insertId); // last inserted row ID
``` ```
Pass `returning: false` to skip `RETURNING` (e.g. when inserting very large
`BLOB`/vector values); the response then only contains `postInsertReturn`.
--- ---
### Update ### Update
```ts ```ts
BunSQLite.update<T>({ table, data, query?, targetId? }) BunSQLite.update<T>({ table, data, query?, targetId?, returning? })
``` ```
| Parameter | Type | Description | | Parameter | Type | Description |
| ---------- | --------------------- | --------------------------- | | ---------- | --------------------- | -------------------------------------------------------- |
| `table` | `string` | Table name | | `table` | `string` | Table name |
| `data` | `Partial<T>` | Fields to update | | `data` | `Partial<T>` | Fields to update |
| `query` | `ServerQueryParam<T>` | WHERE clause conditions | | `query` | `ServerQueryParam<T>` | WHERE clause conditions |
| `targetId` | `string \| number` | Shorthand to filter by `id` | | `targetId` | `string \| number` | Shorthand to filter by `id` |
| `returning`| `boolean` | Return the updated record(s). Defaults to `true` |
A WHERE clause is required. If no condition matches, `success` is `false`. A WHERE clause is required. If no condition matches, `success` is `false`.
`updated_at` is set automatically to `Date.now()`. `updated_at` is set automatically to `Date.now()`.
On success the updated record(s) come back in `singleRes` (first row) and
`payload`. Vector tables fall back to `postInsertReturn` automatically, because
SQLite does not support `UPDATE ... RETURNING` on virtual tables.
**Examples:** **Examples:**
```ts ```ts
// Update by ID // Update by ID
await BunSQLite.update({ const res = await BunSQLite.update({
table: "users", table: "users",
targetId: 1, targetId: 1,
data: { first_name: "Alicia" }, data: { first_name: "Alicia" },
}); });
console.log(res.singleRes); // the updated record
// Update with custom query // Update with custom query
await BunSQLite.update({ await BunSQLite.update({
table: "users", table: "users",
+41 -2
View File
@@ -14,6 +14,11 @@ type Params<
table: Table; table: Table;
data: Schema[]; data: Schema[];
update_on_duplicate?: boolean; update_on_duplicate?: boolean;
/**
* Append `RETURNING *` so the inserted record(s) come back in
* `payload`/`singleRes`. Defaults to `true`.
*/
returning?: boolean;
config?: BunSQLiteConfig; config?: BunSQLiteConfig;
}; };
@@ -24,10 +29,13 @@ export default async function DbInsert<
table, table,
data, data,
update_on_duplicate, update_on_duplicate,
returning = true,
config, config,
}: Params<Schema, Table>): Promise<APIResponseObject> { }: Params<Schema, Table>): Promise<APIResponseObject<Schema>> {
let sqlObj: SQLInsertGenReturn | null = null; let sqlObj: SQLInsertGenReturn | null = null;
const useReturning = returning;
try { try {
const finalData: { [k: string]: any }[] = data.map((d) => ({ const finalData: { [k: string]: any }[] = data.map((d) => ({
created_at: Date.now(), created_at: Date.now(),
@@ -43,13 +51,25 @@ export default async function DbInsert<
let sql = sqlObj?.query || ""; let sql = sqlObj?.query || "";
if (!sql) {
return {
success: false,
msg: "No insert data provided",
debug: { sqlObj },
};
}
if (update_on_duplicate && data[0]) { if (update_on_duplicate && data[0]) {
sql = await grabDuplicateSafeInsertSql({ data, table, sql }); sql = await grabDuplicateSafeInsertSql({ data, table, sql });
} }
if (useReturning) {
sql += " RETURNING *";
}
(sqlObj || ({} as any)).query = sql; (sqlObj || ({} as any)).query = sql;
const res = dbHandler({ const res = dbHandler<Schema>({
query: sql, query: sql,
values: sqlObj?.values || [], values: sqlObj?.values || [],
config, config,
@@ -59,6 +79,7 @@ export default async function DbInsert<
return { ...res, debug: { sqlObj } }; return { ...res, debug: { sqlObj } };
} }
if (!useReturning) {
return { return {
success: Boolean(res.postInsertReturn?.insertId), success: Boolean(res.postInsertReturn?.insertId),
postInsertReturn: res.postInsertReturn, postInsertReturn: res.postInsertReturn,
@@ -66,6 +87,24 @@ export default async function DbInsert<
sqlObj, sqlObj,
}, },
}; };
}
const rows = res.payload || [];
return {
success: rows.length > 0,
payload: rows,
singleRes: rows[0] ?? null,
postInsertReturn: {
affectedRows: rows.length,
insertId: rows.length
? Number((rows[rows.length - 1] as any)?.id)
: undefined,
},
debug: {
sqlObj,
},
};
} catch (error: any) { } catch (error: any) {
return { return {
success: false, success: false,
+41 -2
View File
@@ -1,5 +1,6 @@
import dbHandler from "../db-handler"; import dbHandler from "../db-handler";
import _ from "lodash"; import _ from "lodash";
import init from "../../functions/init";
import type { import type {
APIResponseObject, APIResponseObject,
BunSQLiteConfig, BunSQLiteConfig,
@@ -18,6 +19,12 @@ type Params<
data: Schema; data: Schema;
query?: ServerQueryParam<Schema>; query?: ServerQueryParam<Schema>;
targetId?: number | string; targetId?: number | string;
/**
* Append `RETURNING *` so the updated record(s) come back in
* `payload`/`singleRes`. Defaults to `true`. Ignored for vector tables,
* where SQLite does not support `UPDATE ... RETURNING`.
*/
returning?: boolean;
config?: BunSQLiteConfig; config?: BunSQLiteConfig;
}; };
@@ -29,8 +36,9 @@ export default async function DbUpdate<
data, data,
query, query,
targetId, targetId,
returning = true,
config, config,
}: Params<Schema, Table>): Promise<APIResponseObject> { }: Params<Schema, Table>): Promise<APIResponseObject<Schema>> {
let sqlObj: Pick<ReturnType<typeof sqlGenerator>, "string" | "values"> = { let sqlObj: Pick<ReturnType<typeof sqlGenerator>, "string" | "values"> = {
string: "", string: "",
values: [], values: [],
@@ -39,6 +47,13 @@ export default async function DbUpdate<
try { try {
const finalQuery = query || {}; const finalQuery = query || {};
const { dbSchema } = init();
const isVectorTable =
dbSchema.tables.find((t) => t.tableName === table)?.isVector ===
true;
const useReturning = returning && !isVectorTable;
const sqlQueryObj = sqlGenerator({ const sqlQueryObj = sqlGenerator({
tableName: table, tableName: table,
genObject: _.pick(finalQuery, ["query", "searchOperator"]), genObject: _.pick(finalQuery, ["query", "searchOperator"]),
@@ -74,17 +89,23 @@ export default async function DbUpdate<
} }
sql += ` ${whereClause}`; sql += ` ${whereClause}`;
if (useReturning) {
sql += " RETURNING *";
}
values = [...values, ...sqlQueryObj.where.values]; values = [...values, ...sqlQueryObj.where.values];
sqlObj.string = sql; sqlObj.string = sql;
sqlObj.values = values as any[]; sqlObj.values = values as any[];
const res = dbHandler({ query: sql, values, config }); const res = dbHandler<Schema>({ query: sql, values, config });
if (!res.success) { if (!res.success) {
return { ...res, debug: { sqlObj } }; return { ...res, debug: { sqlObj } };
} }
if (!useReturning) {
return { return {
success: Boolean(res.postInsertReturn?.affectedRows), success: Boolean(res.postInsertReturn?.affectedRows),
postInsertReturn: res.postInsertReturn, postInsertReturn: res.postInsertReturn,
@@ -92,6 +113,24 @@ export default async function DbUpdate<
sqlObj, sqlObj,
}, },
}; };
}
const rows = res.payload || [];
return {
success: rows.length > 0,
payload: rows,
singleRes: rows[0] ?? null,
postInsertReturn: {
affectedRows: rows.length,
insertId: rows.length
? Number((rows[rows.length - 1] as any)?.id)
: undefined,
},
debug: {
sqlObj,
},
};
} else { } else {
return { return {
success: false, success: false,
+198
View File
@@ -0,0 +1,198 @@
import { describe, expect, beforeAll, test } from "bun:test";
import { Database } from "bun:sqlite";
import * as sqliteVec from "sqlite-vec";
import DbInsert from "../src/lib/sqlite/db-insert";
import DbUpdate from "../src/lib/sqlite/db-update";
import type { BUN_SQLITE_DatabaseSchemaType } from "../src/types";
const db = new Database(":memory:");
sqliteVec.load(db);
const dbSchema: BUN_SQLITE_DatabaseSchemaType = {
dbName: "test",
tables: [
{
tableName: "users",
fields: [
{
fieldName: "id",
dataType: "INTEGER",
primaryKey: true,
autoIncrement: true,
},
{ fieldName: "first_name", dataType: "TEXT" },
{ fieldName: "email", dataType: "TEXT", unique: true },
{ fieldName: "created_at", dataType: "INTEGER" },
{ fieldName: "updated_at", dataType: "INTEGER" },
],
},
{
tableName: "face_data",
isVector: true,
fields: [
{ fieldName: "user_id", dataType: "INTEGER" },
{ fieldName: "embedding", dataType: "TEXT", isVector: true },
{ fieldName: "note", dataType: "TEXT", sideCar: true },
{ fieldName: "updated_at", dataType: "INTEGER" },
],
},
],
};
global.CONFIG = { db_name: "test" };
global.DB_SCHEMA = dbSchema;
global.SQLITE_CLIENT = db;
beforeAll(() => {
db.run(`CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT,
email TEXT UNIQUE,
created_at INTEGER,
updated_at INTEGER
)`);
db.run(`CREATE VIRTUAL TABLE face_data USING vec0(
user_id INTEGER,
embedding float[3],
+note TEXT,
updated_at INTEGER
)`);
});
describe("DbInsert RETURNING", () => {
test("returns the inserted record", async () => {
const res = await DbInsert({
table: "users",
data: [{ first_name: "Alice", email: "[email protected]" }],
});
expect(res.success).toBe(true);
expect(res.singleRes?.first_name).toBe("Alice");
expect(res.singleRes?.id).toBe(1);
expect(res.singleRes?.created_at).toBeGreaterThan(0);
expect(res.postInsertReturn?.affectedRows).toBe(1);
expect(res.postInsertReturn?.insertId).toBe(1);
expect(res.debug.sqlObj.query).toContain("RETURNING *");
});
test("returns every row for a multi-row insert", async () => {
const res = await DbInsert({
table: "users",
data: [
{ first_name: "Bob", email: "[email protected]" },
{ first_name: "Cara", email: "[email protected]" },
],
});
expect(res.success).toBe(true);
expect(res.payload?.length).toBe(2);
expect(res.payload?.map((r) => r.first_name)).toEqual(["Bob", "Cara"]);
expect(res.postInsertReturn?.insertId).toBe(res.payload?.at(-1)?.id);
});
test("returns the row on an upsert", async () => {
const res = await DbInsert({
table: "users",
update_on_duplicate: true,
data: [
{
first_name: "Alicia",
email: "[email protected]",
created_at: 123,
},
],
});
expect(res.success).toBe(true);
expect(res.singleRes?.first_name).toBe("Alicia");
expect(res.singleRes?.id).toBe(1);
expect(res.singleRes?.created_at).toBe(123);
});
test("returning: false keeps the old postInsertReturn shape", async () => {
const res = await DbInsert({
table: "users",
returning: false,
data: [{ first_name: "Dan", email: "[email protected]" }],
});
expect(res.success).toBe(true);
expect(res.payload).toBeUndefined();
expect(res.singleRes).toBeUndefined();
expect(res.postInsertReturn?.insertId).toBeGreaterThan(0);
});
test("empty data fails without running SQL", async () => {
const res = await DbInsert({ table: "users", data: [] });
expect(res.success).toBe(false);
expect(res.msg).toBe("No insert data provided");
});
});
describe("DbUpdate RETURNING", () => {
test("returns the updated record", async () => {
const res = await DbUpdate({
table: "users",
targetId: 1,
data: { first_name: "Alicia" },
});
expect(res.success).toBe(true);
expect(res.singleRes?.first_name).toBe("Alicia");
expect(res.singleRes?.updated_at).toBeGreaterThan(0);
expect(res.postInsertReturn?.affectedRows).toBe(1);
expect(res.debug.sqlObj.string).toContain("RETURNING *");
});
test("no matching row resolves to success: false", async () => {
const res = await DbUpdate({
table: "users",
targetId: 99999,
data: { first_name: "Nobody" },
});
expect(res.success).toBe(false);
expect(res.payload).toEqual([]);
});
test("vector tables skip RETURNING but still report affectedRows", async () => {
db.run(
"INSERT INTO face_data (user_id, embedding, note, updated_at) VALUES (?, ?, ?, ?)",
4,
new Float32Array([1, 2, 3]),
"hi",
0,
);
const res = await DbUpdate({
table: "face_data",
query: { query: { user_id: { value: 4 } } },
data: { note: "bye" },
});
expect(res.success).toBe(true);
expect(res.payload).toBeUndefined();
expect(res.debug.sqlObj.string).not.toContain("RETURNING");
expect(res.postInsertReturn?.affectedRows).toBeGreaterThan(0);
const row = db
.query("SELECT note FROM face_data WHERE user_id = 4")
.get() as { note: string };
expect(row.note).toBe("bye");
});
test("returning: false keeps the old postInsertReturn shape", async () => {
const res = await DbUpdate({
table: "users",
targetId: 1,
returning: false,
data: { first_name: "Ally" },
});
expect(res.success).toBe(true);
expect(res.payload).toBeUndefined();
expect(res.postInsertReturn?.affectedRows).toBe(1);
});
});