mirror: add a helper function for transactions (#844)

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
This commit is contained in:
William Chargin 2018-09-17 13:33:10 -07:00 committed by GitHub
parent f966ce300f
commit e9279bee90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 161 additions and 0 deletions

41
src/graphql/mirror.js Normal file
View File

@ -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<R>(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();
}
}
}

120
src/graphql/mirror.test.js Normal file
View File

@ -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"
);
});
});
});