From e9279bee9069235ac2c8cd07c4de047add7d4017 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Mon, 17 Sep 2018 13:33:10 -0700 Subject: [PATCH] mirror: add a helper function for transactions (#844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: In implementing #622, we’ll want to run lots of things inside of transactions. This commit introduces a JavaScript API to do so more easily, properly handling success and failure cases. Test Plan: Unit tests included, with full coverage; run `yarn unit`. wchargin-branch: mirror-transaction-helper --- src/graphql/mirror.js | 41 +++++++++++++ src/graphql/mirror.test.js | 120 +++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/graphql/mirror.js create mode 100644 src/graphql/mirror.test.js diff --git a/src/graphql/mirror.js b/src/graphql/mirror.js new file mode 100644 index 0000000..f39e663 --- /dev/null +++ b/src/graphql/mirror.js @@ -0,0 +1,41 @@ +// @flow + +import type Database from "better-sqlite3"; + +/** + * Execute a function inside a database transaction. + * + * The database must not be in a transaction. A new transaction will be + * entered, and then the callback will be invoked. + * + * If the callback completes normally, then its return value is passed + * up to the caller, and the currently active transaction (if any) is + * committed. + * + * If the callback throws an error, then the error is propagated to the + * caller, and the currently active transaction (if any) is rolled back. + * + * Note that the callback may choose to commit or roll back the + * transaction before returning or throwing an error. Conversely, note + * that if the callback commits the transaction, and then begins a new + * transaction but does not end it, then this function will commit the + * new transaction if the callback returns (or roll it back if it + * throws). + */ +export function _inTransaction(db: Database, fn: () => R): R { + if (db.inTransaction) { + throw new Error("already in transaction"); + } + try { + db.prepare("BEGIN").run(); + const result = fn(); + if (db.inTransaction) { + db.prepare("COMMIT").run(); + } + return result; + } finally { + if (db.inTransaction) { + db.prepare("ROLLBACK").run(); + } + } +} diff --git a/src/graphql/mirror.test.js b/src/graphql/mirror.test.js new file mode 100644 index 0000000..0446784 --- /dev/null +++ b/src/graphql/mirror.test.js @@ -0,0 +1,120 @@ +// @flow + +import Database from "better-sqlite3"; +import tmp from "tmp"; + +import {_inTransaction} from "./mirror"; + +describe("graphql/mirror", () => { + describe("_inTransaction", () => { + it("runs its callback inside a transaction", () => { + // We use an on-disk database file here because we need to open + // two connections. + const filename = tmp.fileSync().name; + const db0 = new Database(filename); + const db1 = new Database(filename); + db0.prepare("CREATE TABLE tab (col PRIMARY KEY)").run(); + + const countRows = (db) => + db.prepare("SELECT COUNT(1) AS n FROM tab").get().n; + expect(countRows(db0)).toEqual(0); + expect(countRows(db1)).toEqual(0); + + let called = false; + _inTransaction(db0, () => { + called = true; + db0.prepare("INSERT INTO tab (col) VALUES (1)").run(); + expect(countRows(db0)).toEqual(1); + expect(countRows(db1)).toEqual(0); + }); + expect(called).toBe(true); + + expect(countRows(db0)).toEqual(1); + expect(countRows(db1)).toEqual(1); + }); + + it("passes up the return value", () => { + const db = new Database(":memory:"); + db.prepare("CREATE TABLE tab (col PRIMARY KEY)").run(); + expect( + _inTransaction(db, () => { + db.prepare("INSERT INTO tab (col) VALUES (3)").run(); + db.prepare("INSERT INTO tab (col) VALUES (4)").run(); + return db.prepare("SELECT TOTAL(col) AS n FROM tab").get().n; + }) + ).toBe(7); + }); + + it("rolls back and rethrows on SQL error", () => { + // In practice, this is a special case of a JavaScript error, but + // we test it explicitly in case it goes down a different codepath + // internally. + const db = new Database(":memory:"); + db.prepare("CREATE TABLE tab (col PRIMARY KEY)").run(); + + let threw = false; + try { + _inTransaction(db, () => { + db.prepare("INSERT INTO tab (col) VALUES (1)").run(); + db.prepare("INSERT INTO tab (col) VALUES (1)").run(); // throws + throw new Error("Should not get here."); + }); + } catch (e) { + threw = true; + expect(e.name).toBe("SqliteError"); + expect(e.code).toBe("SQLITE_CONSTRAINT_PRIMARYKEY"); + } + expect(threw).toBe(true); + + expect(db.prepare("SELECT COUNT(1) AS n FROM tab").get()).toEqual({n: 0}); + }); + + it("rolls back and rethrows on JavaScript error", () => { + const db = new Database(":memory:"); + db.prepare("CREATE TABLE tab (col PRIMARY KEY)").run(); + + expect(() => { + _inTransaction(db, () => { + db.prepare("INSERT INTO tab (col) VALUES (1)").run(); + throw new Error("and then something goes wrong"); + }); + }).toThrow("and then something goes wrong"); + + expect(db.prepare("SELECT COUNT(1) AS n FROM tab").get()).toEqual({n: 0}); + }); + + it("allows the callback to commit the transaction and throw", () => { + const db = new Database(":memory:"); + db.prepare("CREATE TABLE tab (col)").run(); + expect(() => + _inTransaction(db, () => { + db.prepare("INSERT INTO tab (col) VALUES (33)").run(); + db.prepare("COMMIT").run(); + throw new Error("and then something goes wrong"); + }) + ).toThrow("and then something goes wrong"); + expect(db.prepare("SELECT TOTAL(col) AS n FROM tab").get().n).toBe(33); + }); + + it("allows the callback to roll back the transaction and return", () => { + const db = new Database(":memory:"); + db.prepare("CREATE TABLE tab (col)").run(); + expect( + _inTransaction(db, () => { + db.prepare("INSERT INTO tab (col) VALUES (33)").run(); + db.prepare("ROLLBACK").run(); + return "tada"; + }) + ).toEqual("tada"); + expect(db.prepare("SELECT TOTAL(col) AS n FROM tab").get().n).toBe(0); + }); + + it("throws if the database is already in a transaction", () => { + const db = new Database(":memory:"); + db.prepare("BEGIN").run(); + expect(() => _inTransaction(db, () => {})).toThrow( + "already in transaction" + ); + }); + }); +});