diff --git a/package.json b/package.json index c265220..d2ea541 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "test/mysql", "test/postgres", "test/sqlite", + "test/betterSqlite3", "test/next", "test/next16", "test/typescript-esm", diff --git a/src/hooks/betterSqlite3.ts b/src/hooks/betterSqlite3.ts new file mode 100644 index 0000000..7ec6016 --- /dev/null +++ b/src/hooks/betterSqlite3.ts @@ -0,0 +1,284 @@ +import { getActiveRecordings, isActive } from "../recorder"; +import { getTime } from "../util/getTime"; + +// https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md +// +// better-sqlite3 exports its Database class. Statement is not exported, so its +// prototype is picked up from the first statement that `prepare` returns and +// patched once; methods that compile statements of their own prime that patch +// before they run. Every method is synchronous, which keeps recording simple: +// a sql_query call event before the call and a return (or exception) event +// right after it, on the same async context. + +type AnyFunction = (this: unknown, ...args: unknown[]) => unknown; +type SqlOf = (thisArg: unknown, args: unknown[]) => string | undefined; + +const patchedModules = new WeakSet(); + +export default function betterSqlite3Hook(mod: unknown) { + if (typeof mod !== "function" || typeof mod.prototype !== "object" || mod.prototype === null) + return mod; + + // The require hook runs for every require of the module, cache hits + // included, so patching has to be idempotent: wrapping a wrapper would + // record one event per layer. + if (patchedModules.has(mod)) return mod; + patchedModules.add(mod); + + const proto = mod.prototype as Record; + + if (typeof proto.exec === "function") + proto.exec = createRecordingProxy(proto.exec as AnyFunction, (_, args) => stringArg(args[0])); + + if (typeof proto.prepare === "function") { + const prepare = proto.prepare as AnyFunction; + proto.prepare = createPrepareProxy(prepare); + + // pragma() and transaction() run statements that never pass through + // prepare(): pragma() compiles its own, and the transaction controller + // compiles BEGIN, COMMIT and ROLLBACK on the native database handle. Both + // are recorded by the Statement patch, but neither can install it, and + // BEGIN has already run by the time a transaction callback prepares + // anything. Prime the patch before either of them runs instead. + for (const method of ["pragma", "transaction"]) + if (typeof proto[method] === "function") + proto[method] = createPrimingProxy(proto[method] as AnyFunction, prepare); + } + + return mod; +} + +betterSqlite3Hook.applicable = function (id: string) { + return id === "better-sqlite3"; +}; + +const patchedStatementPrototypes = new WeakSet(); + +function createPrepareProxy(prepare: AnyFunction) { + return new Proxy(prepare, { + apply(target, thisArg, argArray: unknown[]) { + const statement: unknown = Reflect.apply(target, thisArg, argArray); + if (statement !== null && typeof statement === "object") patchStatementPrototype(statement); + return statement; + }, + }); +} + +const primedPrepares = new WeakSet(); + +// Runs `fn` with the Statement prototype already patched, so that statements +// `fn` compiles behind our back are recorded too. The prototype is shared by +// every statement of a module, so a single throwaway statement is enough to +// get hold of it, once. +function createPrimingProxy(fn: AnyFunction, prepare: AnyFunction) { + return new Proxy(fn, { + apply(target, thisArg, argArray: unknown[]) { + primeStatementPrototype(prepare, thisArg); + return Reflect.apply(target, thisArg, argArray); + }, + }); +} + +function primeStatementPrototype(prepare: AnyFunction, database: unknown) { + if (primedPrepares.has(prepare)) return; + try { + const statement: unknown = Reflect.apply(prepare, database, ["SELECT 1"]); + if (statement === null || typeof statement !== "object") return; + patchStatementPrototype(statement); + primedPrepares.add(prepare); + } catch { + // A connection that cannot compile even this is in no state to run + // anything else either, and it may not be the only connection around. + // Leave the prototype to the application's own prepare() call and try + // again on the next one. + } +} + +function patchStatementPrototype(statement: object) { + const proto: unknown = Object.getPrototypeOf(statement); + if (proto === null || typeof proto !== "object" || patchedStatementPrototypes.has(proto)) return; + patchedStatementPrototypes.add(proto); + + const p = proto as Record; + const sqlOfStatement: SqlOf = (thisArg) => statementSource(thisArg); + for (const method of ["run", "get", "all"]) + if (typeof p[method] === "function") + p[method] = createRecordingProxy(p[method] as AnyFunction, sqlOfStatement); + if (typeof p.iterate === "function") + p.iterate = createIterateProxy(p.iterate as AnyFunction, sqlOfStatement); +} + +function stringArg(arg: unknown): string | undefined { + return typeof arg === "string" ? arg : undefined; +} + +function statementSource(statement: unknown): string | undefined { + if (statement !== null && typeof statement === "object" && "source" in statement) + return stringArg(statement.source); + return undefined; +} + +// Emits a sql_query call event, runs the method, and emits the return or +// exception event as soon as it comes back. +function createRecordingProxy(proxyTarget: T, sqlOf: SqlOf) { + return new Proxy(proxyTarget, { + apply(target, thisArg, argArray: unknown[]) { + const sql = sqlOf(thisArg, argArray); + // No SQL to report (for example, pragma() called with a non-string): + // short circuit to the original function and let it handle the arguments. + if (sql === undefined) return Reflect.apply(target, thisArg, argArray); + + const recordings = getActiveRecordings(); + const callEvents = recordings.map((recording) => recording.sqlQuery("sqlite", sql)); + const startTime = getTime(); + try { + const result: unknown = Reflect.apply(target, thisArg, argArray); + recordings.forEach( + (recording, idx) => + isActive(recording) && + recording.functionReturn(callEvents[idx].id, undefined, startTime), + ); + return result; + } catch (exn: unknown) { + recordings.forEach( + (recording, idx) => + isActive(recording) && recording.functionException(callEvents[idx].id, exn, startTime), + ); + throw exn; + } + }, + }); +} + +interface IteratorLike { + next(...args: unknown[]): IteratorResult; + return?(...args: unknown[]): IteratorResult; + // better-sqlite3 freezes the statement being iterated onto the iterator. + statement?: unknown; +} + +function isIteratorLike(obj: unknown): obj is IteratorLike { + return obj !== null && typeof obj === "object" && "next" in obj && typeof obj.next === "function"; +} + +// better-sqlite3 locks a statement for as long as an iterator is holding it, +// and only releases it when the iterator is cleaned up, so `busy` says whether +// there are still rows to come. An iterator we cannot ask is treated as over, +// which is what every other method reports anyway. +function stillIterating({ statement }: IteratorLike): boolean { + if (statement === null || typeof statement !== "object" || !("busy" in statement)) return false; + return statement.busy === true; +} + +// iterate() hands rows out lazily, so the query is only finished when the +// iterator is exhausted, returned early (a `break` in a for..of), or throws. +// The return event is emitted at that point. +// +// An iterator that is simply abandoned therefore leaves its call event +// unterminated, and that is left alone on purpose: for..of always settles the +// iterator, and an application that drops a live one has already broken +// itself. better-sqlite3 gives the iterator no finalizer, so the statement it +// holds is never released -- that statement stays unusable and db.close() +// throws from then on, with or without us watching. +// +// The native iterator's methods must be called on the native object, not on +// the proxy, so they are bound explicitly rather than reached through the +// proxy's receiver. +function createIterateProxy(iterate: AnyFunction, sqlOf: SqlOf) { + return new Proxy(iterate, { + apply(target, thisArg, argArray: unknown[]) { + const sql = sqlOf(thisArg, argArray); + if (sql === undefined) return Reflect.apply(target, thisArg, argArray); + + const recordings = getActiveRecordings(); + const callEvents = recordings.map((recording) => recording.sqlQuery("sqlite", sql)); + const startTime = getTime(); + + let iterator: unknown; + try { + iterator = Reflect.apply(target, thisArg, argArray); + } catch (exn: unknown) { + recordings.forEach( + (recording, idx) => + isActive(recording) && recording.functionException(callEvents[idx].id, exn, startTime), + ); + throw exn; + } + if (!isIteratorLike(iterator)) { + recordings.forEach( + (recording, idx) => + isActive(recording) && + recording.functionReturn(callEvents[idx].id, undefined, startTime), + ); + return iterator; + } + + let finished = false; + // The failure is passed boxed so that a thrown undefined still finishes + // the query as an exception rather than as a successful return. + const finish = (failure?: { exception: unknown }) => { + if (finished) return; + finished = true; + recordings.forEach((recording, idx) => { + if (!isActive(recording)) return; + if (failure) + recording.functionException(callEvents[idx].id, failure.exception, startTime); + else recording.functionReturn(callEvents[idx].id, undefined, startTime); + }); + }; + + const native = iterator; + + // A throw does not always end the iteration. better-sqlite3 refuses to + // touch an iterator while the connection is busy, and that check comes + // before it looks at the iterator at all, so the rows are still to come + // and the query is not over. It only really ends when the statement is + // released, which is what `busy` reports. + const overOn = (exn: unknown) => { + if (!stillIterating(native)) finish({ exception: exn }); + throw exn; + }; + + const next = (...args: unknown[]): IteratorResult => { + try { + const result = native.next(...args); + if (result.done) finish(); + return result; + } catch (exn: unknown) { + return overOn(exn); + } + }; + + const settle = (...args: unknown[]): IteratorResult => { + try { + const result = native.return ? native.return(...args) : { done: true, value: undefined }; + finish(); + return result; + } catch (exn: unknown) { + return overOn(exn); + } + }; + + // Everything not recorded here is forwarded to the native iterator, so + // that recording iterate() does not change the shape of what it returns: + // `statement`, which better-sqlite3 freezes onto the iterator, and the + // iterator's identity. Methods are bound to the native object, which + // cannot be unwrapped from a receiver that is not itself. + const proxy: IteratorLike = new Proxy(native, { + get(target, property) { + if (property === "next") return next; + if (property === "return") return settle; + // Handing for..of the native iterator would bypass recording. + if (property === Symbol.iterator) return () => proxy; + + const value: unknown = Reflect.get(target, property, target); + // `constructor` is a class rather than a method: binding it would + // rename it, and it does not need a receiver anyway. + if (property === "constructor" || typeof value !== "function") return value; + return value.bind(target) as unknown; + }, + }); + return proxy; + }, + }); +} diff --git a/src/requireHook.ts b/src/requireHook.ts index feabcb6..a37f637 100644 --- a/src/requireHook.ts +++ b/src/requireHook.ts @@ -1,3 +1,4 @@ +import betterSqlite3Hook from "./hooks/betterSqlite3"; import httpHook from "./hooks/http"; import mongoHook from "./hooks/mongo"; import mysqlHook from "./hooks/mysql"; @@ -18,6 +19,7 @@ const hooks: Hook[] = [ mysqlHook, pgHook, sqliteHook, + betterSqlite3Hook, prismaHook, librariesHook, ]; diff --git a/test/__snapshots__/betterSqlite3.test.ts.snap b/test/__snapshots__/betterSqlite3.test.ts.snap new file mode 100644 index 0000000..a4f262d --- /dev/null +++ b/test/__snapshots__/betterSqlite3.test.ts.snap @@ -0,0 +1,326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`mapping better-sqlite3 calls 1`] = ` +{ + "classMap": [ + { + "children": [ + { + "children": [ + { + "location": "index.js:5", + "name": "main", + "static": true, + "type": "function", + }, + ], + "name": "index", + "type": "class", + }, + ], + "name": "better-sqlite3-appmap-node-test", + "type": "package", + }, + ], + "events": [ + { + "defined_class": "index", + "event": "call", + "id": 1, + "lineno": 5, + "method_id": "main", + "parameters": [], + "path": "index.js", + "static": true, + "thread_id": 0, + }, + { + "event": "call", + "id": 2, + "sql_query": { + "database_type": "sqlite", + "sql": "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 3, + "parent_id": 2, + "thread_id": 0, + }, + { + "event": "call", + "id": 4, + "sql_query": { + "database_type": "sqlite", + "sql": "PRAGMA journal_mode", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 5, + "parent_id": 4, + "thread_id": 0, + }, + { + "event": "call", + "id": 6, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 7, + "parent_id": 6, + "thread_id": 0, + }, + { + "event": "call", + "id": 8, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 9, + "parent_id": 8, + "thread_id": 0, + }, + { + "event": "call", + "id": 10, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT name FROM people WHERE id = ?", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 11, + "parent_id": 10, + "thread_id": 0, + }, + { + "event": "call", + "id": 12, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT id, name FROM people ORDER BY id", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 13, + "parent_id": 12, + "thread_id": 0, + }, + { + "event": "call", + "id": 14, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT name FROM people ORDER BY id", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 15, + "parent_id": 14, + "thread_id": 0, + }, + { + "event": "call", + "id": 16, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT name FROM people ORDER BY id", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 17, + "parent_id": 16, + "thread_id": 0, + }, + { + "event": "call", + "id": 18, + "sql_query": { + "database_type": "sqlite", + "sql": "SELECT boom(name) AS name FROM people ORDER BY id", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "Error", + "message": "boom", + "object_id": 1, + }, + ], + "id": 19, + "parent_id": 18, + "thread_id": 0, + }, + { + "event": "call", + "id": 20, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES ('never inserted')", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "TypeError", + "message": "This statement does not return data. Use run() instead", + "object_id": 2, + }, + ], + "id": 21, + "parent_id": 20, + "thread_id": 0, + }, + { + "event": "call", + "id": 22, + "sql_query": { + "database_type": "sqlite", + "sql": "BEGIN", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 23, + "parent_id": 22, + "thread_id": 0, + }, + { + "event": "call", + "id": 24, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 25, + "parent_id": 24, + "thread_id": 0, + }, + { + "event": "call", + "id": 26, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (name) VALUES (?)", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 27, + "parent_id": 26, + "thread_id": 0, + }, + { + "event": "call", + "id": 28, + "sql_query": { + "database_type": "sqlite", + "sql": "COMMIT", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 29, + "parent_id": 28, + "thread_id": 0, + }, + { + "event": "call", + "id": 30, + "sql_query": { + "database_type": "sqlite", + "sql": "INSERT INTO people (id, name) VALUES (1, 'duplicate')", + }, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "exceptions": [ + { + "class": "SqliteError", + "message": "UNIQUE constraint failed: people.id", + "object_id": 3, + }, + ], + "id": 31, + "parent_id": 30, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 32, + "parent_id": 1, + "thread_id": 0, + }, + ], + "metadata": { + "app": "better-sqlite3-appmap-node-test", + "client": { + "name": "appmap-node", + "url": "https://github.com/getappmap/appmap-node", + "version": "test node-appmap version", + }, + "language": { + "engine": "Node.js", + "name": "javascript", + "version": "test node version", + }, + "name": "test process recording", + "recorder": { + "name": "process", + "type": "process", + }, + }, + "version": "1.12", +} +`; diff --git a/test/betterSqlite3.test.ts b/test/betterSqlite3.test.ts new file mode 100644 index 0000000..e52c6f8 --- /dev/null +++ b/test/betterSqlite3.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert"; + +import type * as AppMap from "../src/AppMap"; +import { integrationTest, readAppmap, runAppmapNode } from "./helpers"; + +integrationTest("mapping better-sqlite3 calls", () => { + expect(runAppmapNode("index.js").status).toBe(0); + expect(readAppmap()).toMatchSnapshot(); +}); + +integrationTest("recording an iterator that outlives a failed call", () => { + expect(runAppmapNode("iterateBusyFailure.js").status).toBe(0); + + // The iterated query is still recorded once, and it succeeded: the failure + // was the settle() call, which left the iteration running. + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "INSERT INTO people (name) VALUES ('alice'), ('bob'), ('carol')", + "SELECT name FROM people ORDER BY id", + "SELECT settle() AS done", + ]); + + expect(outcomeOf("SELECT name FROM people ORDER BY id")?.exceptions).toBeUndefined(); + expect(outcomeOf("SELECT settle() AS done")?.exceptions).toMatchObject([ + { class: "TypeError", message: expect.stringContaining("busy") as string }, + ]); +}); + +integrationTest("recording queries when the module is required more than once", () => { + expect(runAppmapNode("doubleRequire.js").status).toBe(0); + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "PRAGMA journal_mode", + "INSERT INTO people (name) VALUES (?)", + ]); +}); + +integrationTest("recording a transaction started before any prepared statement", () => { + expect(runAppmapNode("transactionFirst.js").status).toBe(0); + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "BEGIN", + "INSERT INTO people (name) VALUES (?)", + "COMMIT", + ]); +}); + +integrationTest("recording how a transaction ends", () => { + expect(runAppmapNode("transactionOutcomes.js").status).toBe(0); + const insert = "INSERT INTO people (name) VALUES (?)"; + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + // A callback that throws rolls back. + "BEGIN", + insert, + "ROLLBACK", + // A transaction nested in another runs on a savepoint. The savepoint's + // name is better-sqlite3's own business, so only the statement is pinned. + "BEGIN", + insert, + expect.stringMatching(/^SAVEPOINT /) as string, + insert, + expect.stringMatching(/^RELEASE /) as string, + "COMMIT", + "SELECT name FROM people ORDER BY id", + ]); + expect(outcomeOf("ROLLBACK")?.exceptions).toBeUndefined(); +}); + +integrationTest("recording a pragma run after a prepared statement", () => { + expect(runAppmapNode("pragmaAfterPrepare.js").status).toBe(0); + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "SELECT count(*) AS n FROM people", + "PRAGMA journal_mode", + "PRAGMA user_version", + ]); +}); + +integrationTest("leaving the shape of an iterator alone", () => { + expect(runAppmapNode("iteratorShape.js").status).toBe(0); + expect(recordedQueries()).toEqual([ + "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", + "INSERT INTO people (name) VALUES ('alice'), ('bob')", + "SELECT name FROM people ORDER BY id", + ]); +}); + +function recordedQueries(): string[] { + return queryEvents().map((event) => event.sql_query.sql); +} + +// The event that ends the given query, whether it returned or threw. +function outcomeOf(sql: string): AppMap.FunctionReturnEvent | undefined { + const query = queryEvents().find((event) => event.sql_query.sql === sql); + assert(query, `no query event for ${sql}`); + return (readAppmap().events ?? []).find( + (event): event is AppMap.FunctionReturnEvent => + "parent_id" in event && event.parent_id === query.id, + ); +} + +function queryEvents(): AppMap.SqlQueryEvent[] { + return (readAppmap().events ?? []).filter( + (event): event is AppMap.SqlQueryEvent => "sql_query" in event, + ); +} diff --git a/test/betterSqlite3/appmap.yml b/test/betterSqlite3/appmap.yml new file mode 100644 index 0000000..633aab3 --- /dev/null +++ b/test/betterSqlite3/appmap.yml @@ -0,0 +1,3 @@ +name: better-sqlite3-appmap-node-test +appmap_dir: tmp/appmap +language: javascript diff --git a/test/betterSqlite3/doubleRequire.js b/test/betterSqlite3/doubleRequire.js new file mode 100644 index 0000000..167978b --- /dev/null +++ b/test/betterSqlite3/doubleRequire.js @@ -0,0 +1,18 @@ +const Database = require("better-sqlite3"); + +// A second require is a cache hit, but the require hook still runs for it. +// Patching must not stack: each query below is one query, however many times +// the application asked for the module. +require("better-sqlite3"); + +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.pragma("journal_mode"); + db.prepare("INSERT INTO people (name) VALUES (?)").run("alice"); + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/index.js b/test/betterSqlite3/index.js new file mode 100644 index 0000000..f54d2a9 --- /dev/null +++ b/test/betterSqlite3/index.js @@ -0,0 +1,66 @@ +const Database = require("better-sqlite3"); + +// better-sqlite3 is synchronous: every call below returns before the next line +// runs, so each sql_query event should be followed directly by its return. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.pragma("journal_mode"); + + // The same prepared statement run twice should produce two sql events. + const insert = db.prepare("INSERT INTO people (name) VALUES (?)"); + insert.run("alice"); + insert.run("bob"); + + db.prepare("SELECT name FROM people WHERE id = ?").get(1); + db.prepare("SELECT id, name FROM people ORDER BY id").all(); + + // iterate() finishes when the iterator is exhausted... + for (const row of db.prepare("SELECT name FROM people ORDER BY id").iterate()) { + console.log("row:", row.name); + } + // ...or when the loop leaves early. + for (const row of db.prepare("SELECT name FROM people ORDER BY id").iterate()) { + console.log("first row only:", row.name); + break; + } + + // A row that fails to materialize makes next() throw; the query is recorded + // as an exception at that point, after the rows that did come through. + db.function("boom", (name) => { + if (name === "bob") throw new Error("boom"); + return name; + }); + try { + for (const row of db.prepare("SELECT boom(name) AS name FROM people ORDER BY id").iterate()) { + console.log("row before the failure:", row.name); + } + } catch (error) { + console.log("caught while iterating:", error.message); + } + + // iterate() itself throws when the statement returns no data. + try { + db.prepare("INSERT INTO people (name) VALUES ('never inserted')").iterate(); + } catch (error) { + console.log("caught from iterate():", error.message); + } + + // Statements run inside a transaction are recorded like any other. + const insertMany = db.transaction((names) => { + for (const name of names) insert.run(name); + }); + insertMany(["carol", "dave"]); + + // A failing statement is recorded as an exception and still thrown. + try { + db.prepare("INSERT INTO people (id, name) VALUES (1, 'duplicate')").run(); + } catch (error) { + console.log("caught:", error.code); + } + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/iterateBusyFailure.js b/test/betterSqlite3/iterateBusyFailure.js new file mode 100644 index 0000000..8c3a1ff --- /dev/null +++ b/test/betterSqlite3/iterateBusyFailure.js @@ -0,0 +1,38 @@ +const assert = require("node:assert"); + +const Database = require("better-sqlite3"); + +// better-sqlite3 refuses to touch an iterator while the connection is busy, +// and that check comes before it looks at the iterator at all: nothing is +// released, and the rows are still to come. A call that fails this way must +// not end the recording of the query -- neither as a failure nor as a +// success -- because the query is still running. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.exec("INSERT INTO people (name) VALUES ('alice'), ('bob'), ('carol')"); + + let iterator; + db.function("settle", () => { + iterator.return(); + return "unreachable"; + }); + + iterator = db.prepare("SELECT name FROM people ORDER BY id").iterate(); + assert.strictEqual(iterator.next().value.name, "alice"); + + // Reached from inside a query of its own, so the connection is busy. + assert.throws(() => db.prepare("SELECT settle() AS done").get(), /busy/); + + // The failed settle left the iteration running: the rest of the rows still + // come through, and only then is the query over. + assert.deepStrictEqual( + [...iterator].map((row) => row.name), + ["bob", "carol"], + ); + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/iteratorShape.js b/test/betterSqlite3/iteratorShape.js new file mode 100644 index 0000000..42f2def --- /dev/null +++ b/test/betterSqlite3/iteratorShape.js @@ -0,0 +1,35 @@ +const assert = require("node:assert"); + +const Database = require("better-sqlite3"); + +// Recording iterate() must not change what the iterator looks like to the +// application. better-sqlite3 hands back a StatementIterator carrying a frozen +// reference to the statement it came from, and anything reached through it +// still has to work. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.exec("INSERT INTO people (name) VALUES ('alice'), ('bob')"); + + const statement = db.prepare("SELECT name FROM people ORDER BY id"); + const iterator = statement.iterate(); + + assert.strictEqual(iterator.statement, statement); + assert.strictEqual(iterator.constructor.name, "StatementIterator"); + // for..of asks the iterator for itself, and must get something we record. + assert.strictEqual(iterator[Symbol.iterator](), iterator); + + const names = []; + for (const row of iterator) names.push(row.name); + assert.deepStrictEqual(names, ["alice", "bob"]); + + // Asking a spent iterator for more, or settling it again, is a no-op in + // better-sqlite3 -- and must not record the query a second time either. + assert.strictEqual(iterator.next().done, true); + assert.strictEqual(iterator.return().done, true); + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/package.json b/test/betterSqlite3/package.json new file mode 100644 index 0000000..f2cf157 --- /dev/null +++ b/test/betterSqlite3/package.json @@ -0,0 +1,8 @@ +{ + "name": "better-sqlite3-appmap-node-test", + "packageManager": "yarn@4.13.0", + "dependencies": { + "better-sqlite3": "^11.10.0" + }, + "private": true +} diff --git a/test/betterSqlite3/pragmaAfterPrepare.js b/test/betterSqlite3/pragmaAfterPrepare.js new file mode 100644 index 0000000..20b272c --- /dev/null +++ b/test/betterSqlite3/pragmaAfterPrepare.js @@ -0,0 +1,20 @@ +const Database = require("better-sqlite3"); + +// pragma() runs its query through a statement of its own. Once the Statement +// prototype has been patched -- which the prepare() below does -- that inner +// statement must not be recorded on top of the pragma itself. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + db.prepare("SELECT count(*) AS n FROM people").get(); + + db.pragma("journal_mode"); + // The simple form takes a different path inside better-sqlite3: pluck().get() + // rather than all(). + db.pragma("user_version", { simple: true }); + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/transactionFirst.js b/test/betterSqlite3/transactionFirst.js new file mode 100644 index 0000000..2bdb7b4 --- /dev/null +++ b/test/betterSqlite3/transactionFirst.js @@ -0,0 +1,20 @@ +const Database = require("better-sqlite3"); + +// The transaction controller compiles BEGIN, COMMIT and ROLLBACK on the native +// database handle, so they never pass through Database.prototype.prepare. When +// transaction() is the first thing an application does, BEGIN has already run +// by the time the callback prepares anything. It should be recorded anyway. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + + const insertMany = db.transaction((names) => { + for (const name of names) db.prepare("INSERT INTO people (name) VALUES (?)").run(name); + }); + insertMany(["alice"]); + + db.close(); +} + +main(); diff --git a/test/betterSqlite3/transactionOutcomes.js b/test/betterSqlite3/transactionOutcomes.js new file mode 100644 index 0000000..2b26f74 --- /dev/null +++ b/test/betterSqlite3/transactionOutcomes.js @@ -0,0 +1,40 @@ +const assert = require("node:assert"); + +const Database = require("better-sqlite3"); + +// The transaction controller compiles a statement for every way a transaction +// can end, not just BEGIN and COMMIT: a callback that throws rolls back, and a +// transaction nested inside another runs on a savepoint. None of them go +// through prepare(), so all of them depend on the Statement patch being in +// place before the controller is built. +function main() { + const db = new Database(":memory:"); + + db.exec("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); + const insert = db.prepare("INSERT INTO people (name) VALUES (?)"); + + const failing = db.transaction((name) => { + insert.run(name); + throw new Error("changed my mind"); + }); + assert.throws(() => failing("alice"), /changed my mind/); + + const inner = db.transaction((name) => insert.run(name)); + const outer = db.transaction((name) => { + insert.run(name); + inner(`${name} jr`); + }); + outer("bob"); + + assert.deepStrictEqual( + db + .prepare("SELECT name FROM people ORDER BY id") + .all() + .map((row) => row.name), + ["bob", "bob jr"], + ); + + db.close(); +} + +main(); diff --git a/yarn.lock b/yarn.lock index f2fccb4..937bd93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4087,6 +4087,25 @@ __metadata: languageName: node linkType: hard +"better-sqlite3-appmap-node-test@workspace:test/betterSqlite3": + version: 0.0.0-use.local + resolution: "better-sqlite3-appmap-node-test@workspace:test/betterSqlite3" + dependencies: + better-sqlite3: "npm:^11.10.0" + languageName: unknown + linkType: soft + +"better-sqlite3@npm:^11.10.0": + version: 11.10.0 + resolution: "better-sqlite3@npm:11.10.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10/5e4c7437c4fe6033335a79c82974d7ab29f33c51c36f48b73e87e087d21578468575de1c56a7badd4f76f17255e25abefddaeacf018e5eeb9e0cb8d6e3e4a5e1 + languageName: node + linkType: hard + "bignumber.js@npm:9.0.0": version: 9.0.0 resolution: "bignumber.js@npm:9.0.0"