Skip to content

Commit df02867

Browse files
veksenclaude
andcommitted
feat(drizzle): tag queries issued inside transactions
`patchDrizzle` patched the top-level db, but `db.transaction(cb)` hands `cb` a fresh `tx` object whose query methods were never patched — so every query built inside a transaction lost its `file`/`func_name` tags. Only `db_driver` (added in the session-level `prepareQuery` patch) survived, which is why transaction writes showed up in Query Doctor with no source provenance. Wrap `transaction` (recursively, for nested savepoint transactions) so the `tx` handed to the callback gets the same per-query caller capture. `file` is now always present inside a transaction; `func_name` resolves when the callback (or its enclosing function) is named — anonymous `async (tx) => …` arrows still get `file` alone, as expected. Verified against pglite: all query types, nested savepoints, concurrency, and — crucially — commit/rollback semantics are preserved (the wrapper only augments the callback, it doesn't change transaction control flow). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8b1a30a commit df02867

2 files changed

Lines changed: 206 additions & 42 deletions

File tree

nodejs/sqlcommenter-nodejs/packages/sqlcommenter-drizzle/src/index.ts

Lines changed: 86 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,91 @@ function patchImmediateMethod(target: Function, thisArg: unknown, args: any[]) {
190190

191191
const DRIZZLE_ORM_MODE_METHODS = ["findFirst", "findMany"] as const;
192192

193+
const CRUD_METHODS = [
194+
"select",
195+
"selectDistinct",
196+
"selectDistinctOn",
197+
"insert",
198+
"update",
199+
"delete",
200+
] as const;
201+
202+
// Marks a db/transaction object whose query methods we've already wrapped, so re-patching
203+
// (e.g. a double `patchDrizzle` call) is a no-op.
204+
const PATCHED_METHODS = Symbol("sqlcommenter-drizzle.patched-methods");
205+
206+
type QueryMethodHost = {
207+
execute?: unknown;
208+
transaction?: unknown;
209+
query?: Record<string, Record<string, unknown>>;
210+
[key: string]: unknown;
211+
};
212+
213+
/**
214+
* Wraps the query-building methods on a drizzle db — or a transaction handle — so the caller is
215+
* captured for every query built through it.
216+
*
217+
* `db.transaction(cb)` hands `cb` a fresh `tx` object whose methods are NOT the ones patched on
218+
* the top-level db, so queries built inside a transaction would otherwise lose their
219+
* `file`/`func_name` tags (only `db_driver`, added in `prepareQuery`, would survive). We wrap the
220+
* transaction callback and recursively patch the `tx` — including any nested savepoint `tx` — the
221+
* same way.
222+
*/
223+
function patchQueryMethods(target: QueryMethodHost) {
224+
const guard = target as unknown as Record<symbol, boolean>;
225+
if (!target || typeof target !== "object" || guard[PATCHED_METHODS]) {
226+
return;
227+
}
228+
guard[PATCHED_METHODS] = true;
229+
230+
if (typeof target.execute === "function") {
231+
target.execute = new Proxy(target.execute, {
232+
apply: (fn, thisArg, args) => patchImmediateMethod(fn, thisArg, args),
233+
});
234+
}
235+
if (target.query) {
236+
for (const key in target.query) {
237+
const schema = target.query[key];
238+
for (const func of DRIZZLE_ORM_MODE_METHODS) {
239+
if (!schema || typeof schema[func] !== "function") {
240+
continue;
241+
}
242+
schema[func] = new Proxy(schema[func] as Function, {
243+
apply: (fn, thisArg, args) => patchBuilderMethod(fn, thisArg, args),
244+
});
245+
}
246+
}
247+
}
248+
for (const method of CRUD_METHODS) {
249+
// not all drivers have all these calls so better be safe
250+
if (typeof target[method] !== "function") {
251+
continue;
252+
}
253+
// Patching the CRUD entrypoints. The caller is captured here, when the query is built,
254+
// because the build-time stack is the only place the user's call site is still visible —
255+
// by the time the query executes (a microtask later) it's gone. `patchBuilderMethod` tags
256+
// the built query so the caller is reattached for its own synchronous `prepareQuery` window.
257+
target[method] = new Proxy(target[method] as Function, {
258+
apply: (fn, thisArg, args) => patchBuilderMethod(fn, thisArg, args),
259+
});
260+
}
261+
if (typeof target.transaction === "function") {
262+
target.transaction = new Proxy(target.transaction, {
263+
apply(fn, thisArg, args) {
264+
const [callback, ...rest] = args as [unknown, ...unknown[]];
265+
if (typeof callback !== "function") {
266+
return Reflect.apply(fn, thisArg, args);
267+
}
268+
const wrapped = function (this: unknown, tx: QueryMethodHost, ...cbArgs: unknown[]) {
269+
patchQueryMethods(tx);
270+
return (callback as Function).apply(this, [tx, ...cbArgs]);
271+
};
272+
return Reflect.apply(fn, thisArg, [wrapped, ...rest]);
273+
},
274+
});
275+
}
276+
}
277+
193278
export function patchDrizzle<T>(
194279
drizzle: T & {
195280
// is this nullable?
@@ -213,48 +298,7 @@ export function patchDrizzle<T>(
213298
} catch (e) {
214299
console.error("Error patching driver", e);
215300
}
216-
const methods = [
217-
"select",
218-
"selectDistinct",
219-
"selectDistinctOn",
220-
"insert",
221-
"update",
222-
"delete",
223-
] as const;
224-
if (typeof drizzle.execute === "function") {
225-
drizzle.execute = new Proxy(drizzle.execute, {
226-
apply: (target, thisArg, args) =>
227-
patchImmediateMethod(target, thisArg, args),
228-
});
229-
}
230-
if (drizzle && "query" in drizzle && drizzle.query) {
231-
for (const key in drizzle.query) {
232-
for (const func of DRIZZLE_ORM_MODE_METHODS) {
233-
const schema = drizzle.query[key as keyof typeof drizzle.query];
234-
if (!schema[func] || typeof schema[func] !== "function") {
235-
continue;
236-
}
237-
schema[func] = new Proxy(schema[func], {
238-
apply: (target, thisArg, args) =>
239-
patchBuilderMethod(target, thisArg, args),
240-
});
241-
}
242-
}
243-
}
244-
for (const method of methods) {
245-
// not all drivers have all these calls so better be safe
246-
if (!drizzle[method] || typeof drizzle[method] !== "function") {
247-
continue;
248-
}
249-
// Patching the CRUD entrypoints. The caller is captured here, when the query is built,
250-
// because the build-time stack is the only place the user's call site is still visible —
251-
// by the time the query executes (a microtask later) it's gone. `patchBuilderMethod` tags
252-
// the built query so the caller is reattached for its own synchronous `prepareQuery` window.
253-
drizzle[method] = new Proxy(drizzle[method], {
254-
apply: (target, thisArg, args) =>
255-
patchBuilderMethod(target, thisArg, args),
256-
});
257-
}
301+
patchQueryMethods(drizzle as QueryMethodHost);
258302
return drizzle;
259303
}
260304

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert";
3+
import { pgTable, serial, text } from "drizzle-orm/pg-core";
4+
import { eq } from "drizzle-orm";
5+
import { drizzle } from "drizzle-orm/pglite";
6+
import { patchDrizzle } from "../src/index.js";
7+
8+
const t = pgTable("t", {
9+
id: serial("id").primaryKey(),
10+
name: text("name"),
11+
});
12+
const u = pgTable("u", {
13+
id: serial("id").primaryKey(),
14+
name: text("name"),
15+
});
16+
17+
function tag(sql: string, key: string): string | undefined {
18+
const match = sql.match(new RegExp(`${key}='([^']*)'`));
19+
return match ? decodeURIComponent(match[1]) : undefined;
20+
}
21+
22+
async function setupLoggedDb() {
23+
const logged: string[] = [];
24+
const db = patchDrizzle(
25+
drizzle({
26+
schema: { t, u },
27+
logger: { logQuery: (query) => logged.push(query) },
28+
}),
29+
);
30+
await db.$client.exec(
31+
"CREATE TABLE t (id serial primary key, name text); CREATE TABLE u (id serial primary key, name text);",
32+
);
33+
return { db, logged };
34+
}
35+
36+
// `patchDrizzle` patches the top-level db, but `db.transaction(cb)` hands `cb` a fresh `tx` whose
37+
// methods are unpatched — so without wrapping the transaction, queries built inside it lose their
38+
// `file` tag (only `db_driver`, added in prepareQuery, would survive).
39+
test("queries inside a transaction still get a file tag", async () => {
40+
const { db, logged } = await setupLoggedDb();
41+
await db.transaction(async (tx) => {
42+
await tx.insert(t).values({ name: "a" });
43+
});
44+
45+
const sql = logged.find((q) => q.includes('into "t"'))!;
46+
assert.match(
47+
tag(sql, "file") ?? "",
48+
/:\d+:\d+$/,
49+
"file must be captured inside a transaction",
50+
);
51+
// The direct caller here is an anonymous transaction arrow, so there is no symbol.
52+
assert.strictEqual(tag(sql, "func_name"), undefined);
53+
});
54+
55+
test("a named transaction callback carries its func_name", async () => {
56+
const { db, logged } = await setupLoggedDb();
57+
async function persistThing(tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) {
58+
await tx.insert(t).values({ name: "a" });
59+
}
60+
await db.transaction(persistThing);
61+
62+
const sql = logged.find((q) => q.includes('into "t"'))!;
63+
assert.ok(tag(sql, "file"), "file is always captured");
64+
assert.strictEqual(tag(sql, "func_name"), "persistThing");
65+
});
66+
67+
test("nested (savepoint) transactions are tagged too", async () => {
68+
const { db, logged } = await setupLoggedDb();
69+
await db.transaction(async (tx) => {
70+
await tx.transaction(async (tx2) => {
71+
await tx2.insert(u).values({ name: "n" });
72+
});
73+
});
74+
75+
const sql = logged.find((q) => q.includes('into "u"'))!;
76+
assert.ok(
77+
/:\d+:\d+$/.test(tag(sql, "file") ?? ""),
78+
"file must be captured inside a nested transaction",
79+
);
80+
});
81+
82+
test("wrapping the transaction preserves commit semantics", async () => {
83+
const { db } = await setupLoggedDb();
84+
await db.transaction(async (tx) => {
85+
await tx.insert(t).values({ name: "committed" });
86+
});
87+
const rows = await db.select().from(t).where(eq(t.name, "committed"));
88+
assert.strictEqual(rows.length, 1);
89+
});
90+
91+
test("wrapping the transaction preserves rollback semantics", async () => {
92+
const { db } = await setupLoggedDb();
93+
await assert.rejects(
94+
db.transaction(async (tx) => {
95+
await tx.insert(t).values({ name: "rolledback" });
96+
throw new Error("boom");
97+
}),
98+
/boom/,
99+
);
100+
const rows = await db.select().from(t).where(eq(t.name, "rolledback"));
101+
assert.strictEqual(rows.length, 0, "the errored transaction must roll back");
102+
});
103+
104+
// Named callbacks passed straight to `transaction` give each concurrent tx a distinct symbol,
105+
// so this asserts the per-query caller isn't clobbered across concurrent transactions.
106+
test("concurrent transactions each keep their own caller", async () => {
107+
const { db, logged } = await setupLoggedDb();
108+
async function txIntoT(tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) {
109+
await tx.insert(t).values({ name: "A" });
110+
}
111+
async function txIntoU(tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) {
112+
await tx.insert(u).values({ name: "B" });
113+
}
114+
await Promise.all([db.transaction(txIntoT), db.transaction(txIntoU)]);
115+
116+
const tSql = logged.find((q) => q.includes('into "t"'))!;
117+
const uSql = logged.find((q) => q.includes('into "u"'))!;
118+
assert.strictEqual(tag(tSql, "func_name"), "txIntoT");
119+
assert.strictEqual(tag(uSql, "func_name"), "txIntoU");
120+
});

0 commit comments

Comments
 (0)