diff --git a/README.md b/README.md index 69ce130..9fed5b7 100644 --- a/README.md +++ b/README.md @@ -395,8 +395,8 @@ All methods return an `APIResponseObject`: ```ts { success: boolean; - payload?: T[]; // array of rows (select) - singleRes?: T; // first row (select) + payload?: T[]; // array of rows (select; also insert/update) + singleRes?: T; // first row (select; also insert/update) count?: number; // total count (when count: true) postInsertReturn?: { affectedRows?: number; @@ -458,16 +458,22 @@ const res = await BunSQLite.select({ ### Insert ```ts -BunSQLite.insert({ table, data }); +BunSQLite.insert({ table, data, update_on_duplicate?, returning? }); ``` -| Parameter | Type | Description | -| --------- | -------- | ------------------------------ | -| `table` | `string` | Table name | -| `data` | `T[]` | Array of row objects to insert | +| Parameter | Type | Description | +| --------------------- | --------- | ---------------------------------------------------------------- | +| `table` | `string` | Table name | +| `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()`. +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:** ```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 ``` +Pass `returning: false` to skip `RETURNING` (e.g. when inserting very large +`BLOB`/vector values); the response then only contains `postInsertReturn`. + --- ### Update ```ts -BunSQLite.update({ table, data, query?, targetId? }) +BunSQLite.update({ table, data, query?, targetId?, returning? }) ``` -| Parameter | Type | Description | -| ---------- | --------------------- | --------------------------- | -| `table` | `string` | Table name | -| `data` | `Partial` | Fields to update | -| `query` | `ServerQueryParam` | WHERE clause conditions | -| `targetId` | `string \| number` | Shorthand to filter by `id` | +| Parameter | Type | Description | +| ---------- | --------------------- | -------------------------------------------------------- | +| `table` | `string` | Table name | +| `data` | `Partial` | Fields to update | +| `query` | `ServerQueryParam` | WHERE clause conditions | +| `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`. `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:** ```ts // Update by ID -await BunSQLite.update({ +const res = await BunSQLite.update({ table: "users", targetId: 1, data: { first_name: "Alicia" }, }); +console.log(res.singleRes); // the updated record + // Update with custom query await BunSQLite.update({ table: "users", diff --git a/src/lib/sqlite/db-insert.ts b/src/lib/sqlite/db-insert.ts index 64924aa..81d20ef 100644 --- a/src/lib/sqlite/db-insert.ts +++ b/src/lib/sqlite/db-insert.ts @@ -14,6 +14,11 @@ type Params< table: Table; data: Schema[]; update_on_duplicate?: boolean; + /** + * Append `RETURNING *` so the inserted record(s) come back in + * `payload`/`singleRes`. Defaults to `true`. + */ + returning?: boolean; config?: BunSQLiteConfig; }; @@ -24,10 +29,13 @@ export default async function DbInsert< table, data, update_on_duplicate, + returning = true, config, -}: Params): Promise { +}: Params): Promise> { let sqlObj: SQLInsertGenReturn | null = null; + const useReturning = returning; + try { const finalData: { [k: string]: any }[] = data.map((d) => ({ created_at: Date.now(), @@ -43,13 +51,25 @@ export default async function DbInsert< let sql = sqlObj?.query || ""; + if (!sql) { + return { + success: false, + msg: "No insert data provided", + debug: { sqlObj }, + }; + } + if (update_on_duplicate && data[0]) { sql = await grabDuplicateSafeInsertSql({ data, table, sql }); } + if (useReturning) { + sql += " RETURNING *"; + } + (sqlObj || ({} as any)).query = sql; - const res = dbHandler({ + const res = dbHandler({ query: sql, values: sqlObj?.values || [], config, @@ -59,9 +79,28 @@ export default async function DbInsert< return { ...res, debug: { sqlObj } }; } + if (!useReturning) { + return { + success: Boolean(res.postInsertReturn?.insertId), + postInsertReturn: res.postInsertReturn, + debug: { + sqlObj, + }, + }; + } + + const rows = res.payload || []; + return { - success: Boolean(res.postInsertReturn?.insertId), - postInsertReturn: res.postInsertReturn, + 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, }, diff --git a/src/lib/sqlite/db-update.ts b/src/lib/sqlite/db-update.ts index 881053b..1ea10d5 100644 --- a/src/lib/sqlite/db-update.ts +++ b/src/lib/sqlite/db-update.ts @@ -1,5 +1,6 @@ import dbHandler from "../db-handler"; import _ from "lodash"; +import init from "../../functions/init"; import type { APIResponseObject, BunSQLiteConfig, @@ -18,6 +19,12 @@ type Params< data: Schema; query?: ServerQueryParam; 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; }; @@ -29,8 +36,9 @@ export default async function DbUpdate< data, query, targetId, + returning = true, config, -}: Params): Promise { +}: Params): Promise> { let sqlObj: Pick, "string" | "values"> = { string: "", values: [], @@ -39,6 +47,13 @@ export default async function DbUpdate< try { const finalQuery = query || {}; + const { dbSchema } = init(); + const isVectorTable = + dbSchema.tables.find((t) => t.tableName === table)?.isVector === + true; + + const useReturning = returning && !isVectorTable; + const sqlQueryObj = sqlGenerator({ tableName: table, genObject: _.pick(finalQuery, ["query", "searchOperator"]), @@ -74,20 +89,44 @@ export default async function DbUpdate< } sql += ` ${whereClause}`; + + if (useReturning) { + sql += " RETURNING *"; + } + values = [...values, ...sqlQueryObj.where.values]; sqlObj.string = sql; sqlObj.values = values as any[]; - const res = dbHandler({ query: sql, values, config }); + const res = dbHandler({ query: sql, values, config }); if (!res.success) { return { ...res, debug: { sqlObj } }; } + if (!useReturning) { + return { + success: Boolean(res.postInsertReturn?.affectedRows), + postInsertReturn: res.postInsertReturn, + debug: { + sqlObj, + }, + }; + } + + const rows = res.payload || []; + return { - success: Boolean(res.postInsertReturn?.affectedRows), - postInsertReturn: res.postInsertReturn, + 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, }, diff --git a/tests/crud-returning.test.ts b/tests/crud-returning.test.ts new file mode 100644 index 0000000..af5f424 --- /dev/null +++ b/tests/crud-returning.test.ts @@ -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: "alice@example.com" }], + }); + + 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: "bob@example.com" }, + { first_name: "Cara", email: "cara@example.com" }, + ], + }); + + 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: "alice@example.com", + 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: "dan@example.com" }], + }); + + 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); + }); +});