Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Emits the following fields into the query:
| ------------- | -------------------- | --------------------------------------------------------------- |
| db_driver | Yes | The driver used to connect to the database. (Drizzle) |
| file | Yes | The file that the query was executed in. |
| func_name | Yes, if named | The function/method that built the query. Omitted if anonymous. |
| route | No | The route that the query was executed in. |
| method | No | The http method for the request that the query was executed in. |
| anything else | No | Any other information that the user wants to add to the query. |
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@query-doctor/sqlcommenter-drizzle",
"version": "0.4.0",
"version": "0.5.0",
"description": "SQLCommenter patch for drizzle-orm",
"main": "dist/cjs/index.js",
"type": "module",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type DriverSession = { prepareQuery: (query: unknown) => unknown };
// `prepareQuery` (`then`/`execute`/`prepare` -> `_prepare` -> `session.prepareQuery` runs with
// no `await` in between). Because the window is synchronous, concurrent queries can never
// interleave inside it, so each query reads exactly its own caller.
let currentCaller: string | undefined;
let currentCaller: CallerInfo | undefined;

// Marks a query object whose `then`/`execute`/`prepare` have already been wrapped, so chained
// rebuilds returning the same object aren't wrapped twice.
Expand All @@ -48,7 +48,37 @@ function isValidCaller(line: string): boolean {
// but its hard to parse that manually. Let future me deal with it
const filepathRegex = /([^ (]*?:\d+:\d+)\)?$/;

export function traceCaller(): string | undefined {
/** The provenance captured from a single V8 stack frame. */
export type CallerInfo = {
// The source location, `path:line:col`, resolved to the real project root.
file: string;
// The enclosing function/method symbol as V8 reports it (e.g. `UserService.list`,
// `reactionsRepo.findFavorites`). Absent for anonymous/top-level frames. Unlike `file`,
// a symbol is edit-stable — it doesn't drift when lines above the call site change.
symbol?: string;
};

/**
* Pulls the enclosing function/method symbol out of a V8 stack frame. Named frames look like
* ` at <symbol> (<location>)`; anonymous/top-level frames are ` at <location>` with no
* symbol to capture, so we return nothing and the caller falls back to `file` alone.
*/
function extractSymbol(frame: string): string | undefined {
const match = frame.match(/^\s*at (.+) \(/);
if (!match) {
return;
}
// V8 prefixes async frames with `async ` in some versions; the symbol is what follows.
const symbol = match[1].replace(/^async /, "");
// Anonymous functions — including arrow-assigned methods that surface as
// `Object.<anonymous>` in bundled/minified builds — carry no stable name.
if (!symbol || symbol.includes("<anonymous>")) {
return;
}
return symbol;
}

export function traceCaller(): CallerInfo | undefined {
// we're not using the Error.capturaStackTrace because it doesn't play nicely
// with stack traces that aren't full paths to a specific file.
// eg: webpack:// or relative paths will produce no result at all so that's not usable
Expand All @@ -66,15 +96,16 @@ export function traceCaller(): string | undefined {
}
const match = methodCaller.match(filepathRegex);
if (match) {
return resolveFilePath(match[1]);
// The symbol comes from the same frame we already selected — no new frame-selection logic.
return { file: resolveFilePath(match[1]), symbol: extractSymbol(methodCaller) };
}
}

/**
* Wraps `then`/`execute`/`prepare` on a built query so that, while the query synchronously
* reaches `prepareQuery`, its own build-time caller is the one published in `currentCaller`.
*/
function tagExecutable(executable: any, caller: string) {
function tagExecutable(executable: any, caller: CallerInfo) {
if (!executable || typeof executable !== "object" || executable[TAGGED]) {
return;
}
Expand Down Expand Up @@ -102,7 +133,7 @@ function tagExecutable(executable: any, caller: string) {
* Proxy so that whatever its methods return is run back through `handleResult` and the eventual
* executable gets tagged with the caller captured at build time.
*/
function wrapBuilder(builder: any, caller: string): unknown {
function wrapBuilder(builder: any, caller: CallerInfo): unknown {
return new Proxy(builder, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
Expand All @@ -116,7 +147,7 @@ function wrapBuilder(builder: any, caller: string): unknown {
});
}

function handleResult(result: any, caller: string): unknown {
function handleResult(result: any, caller: CallerInfo): unknown {
if (!result || typeof result !== "object") {
return result;
}
Expand Down Expand Up @@ -230,6 +261,7 @@ export function patchDrizzle<T>(
const WellKnownFields = {
dbDriver: "db_driver",
file: "file",
funcName: "func_name",
route: "route",
} as const;

Expand All @@ -256,8 +288,12 @@ function patchSession(session: DriverSession) {
const tags: [string, string][] = [[WellKnownFields.dbDriver, "drizzle"]];
// adding traceparent and tracestate
pushW3CTraceContext(tags);
if (caller) {
tags.push([WellKnownFields.file, caller]);
if (caller?.file) {
tags.push([WellKnownFields.file, caller.file]);
}
// The enclosing symbol is edit-stable provenance; absent for anonymous frames.
if (caller?.symbol) {
tags.push([WellKnownFields.funcName, caller.symbol]);
}
if (args[0]) {
const query = args[0];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { test } from "node:test";
import assert from "node:assert";
import { pgTable, serial, text } from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/pglite";
import { patchDrizzle, traceCaller } from "../src/index.js";

// --- Unit: symbol capture straight from the stack frame ---------------------
// Test files are whitelisted by `isValidCaller`, so `traceCaller()` selects the
// frame of whatever test-file function calls it.

function namedFunction() {
return traceCaller();
}
const arrowAssigned = () => traceCaller();
const repo = {
findFavorites() {
return traceCaller();
},
};

test("captures the enclosing named function as symbol, with file", () => {
const caller = namedFunction();
assert.ok(caller?.file, "file must always be captured");
assert.strictEqual(caller?.symbol, "namedFunction");
});

test("captures an object method as Object.<method>", () => {
assert.strictEqual(repo.findFavorites()?.symbol, "Object.findFavorites");
});

test("captures an arrow assigned to a const by its inferred name", () => {
assert.strictEqual(arrowAssigned()?.symbol, "arrowAssigned");
});

test("omits the symbol for an anonymous frame but keeps file", () => {
// An immediately-invoked anonymous arrow has no stable name in V8.
const caller = ((): ReturnType<typeof traceCaller> => traceCaller())();
assert.ok(caller?.file, "file is still captured as the fallback");
assert.strictEqual(caller?.symbol, undefined);
});

// --- Integration: the tag actually lands in the emitted comment -------------

const watches = pgTable("watches", {
id: serial("id").primaryKey(),
name: text("name"),
});
const notifs = pgTable("notifs", {
id: serial("id").primaryKey(),
name: text("name"),
});

function tag(sql: string, key: string): string | undefined {
const match = sql.match(new RegExp(`${key}='([^']*)'`));
return match ? decodeURIComponent(match[1]) : undefined;
}

async function setupLoggedDb() {
const logged: string[] = [];
const db = patchDrizzle(
drizzle({
schema: { watches, notifs },
logger: { logQuery: (query) => logged.push(query) },
}),
);
await db.$client.exec(
"CREATE TABLE watches (id serial primary key, name text); CREATE TABLE notifs (id serial primary key, name text);",
);
return { db, logged };
}

test("emits func_name alongside file for a named caller", async () => {
const { db, logged } = await setupLoggedDb();
async function loadWatches() {
return db.select().from(watches);
}
await loadWatches();

const sql = logged.find((q) => q.includes('from "watches"'))!;
assert.strictEqual(tag(sql, "func_name"), "loadWatches");
assert.ok(tag(sql, "file"), "file is still emitted");
// serializeTags sorts keys alphabetically: db_driver, file, func_name.
assert.ok(
sql.indexOf("file=") < sql.indexOf("func_name="),
"file precedes func_name in the sorted comment",
);
});

test("omits func_name (but keeps file) when the caller is anonymous", async () => {
const { db, logged } = await setupLoggedDb();
// The query is built directly in this anonymous async arrow.
await (async () => {
await db.select().from(watches);
})();

const sql = logged.find((q) => q.includes('from "watches"'))!;
assert.strictEqual(tag(sql, "func_name"), undefined);
assert.ok(tag(sql, "file"), "file is still emitted as the fallback");
});

test("concurrent queries each keep their own func_name", async () => {
const { db, logged } = await setupLoggedDb();
async function queryWatches() {
return db.select().from(watches);
}
async function queryNotifs() {
return db.select().from(notifs);
}
await Promise.all([queryWatches(), queryNotifs()]);

const watchesSql = logged.find((q) => q.includes('from "watches"'))!;
const notifsSql = logged.find((q) => q.includes('from "notifs"'))!;
assert.strictEqual(tag(watchesSql, "func_name"), "queryWatches");
assert.strictEqual(tag(notifsSql, "func_name"), "queryNotifs");
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Emits the following fields into the query:
| ------------- | -------------------- | --------------------------------------------------------------- |
| db_driver | Yes | The driver used to connect to the database. (MikroORM) |
| file | Yes | The file that the query was executed in. |
| func_name | Yes, if named | The function/method that built the query. Omitted if anonymous. |
| route | No | The route that the query was executed in. |
| method | No | The http method for the request that the query was executed in. |
| anything else | No | Any other information that the user wants to add to the query. |
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@query-doctor/sqlcommenter-mikroorm",
"version": "0.3.0",
"version": "0.4.0",
"description": "SQLCommenter patch for MikroORM",
"main": "dist/cjs/index.js",
"type": "module",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,37 @@ function isValidCaller(line: string): boolean {
// (file.ts:12:12) or file.ts:12:12
const filepathRegex = /([^ (]*?:\d+:\d+)\)?$/;

export function traceCaller(): string | undefined {
/** The provenance captured from a single V8 stack frame. */
export type CallerInfo = {
// The source location, `path:line:col`, resolved to the real project root.
file: string;
// The enclosing function/method symbol as V8 reports it (e.g. `UserService.list`). Absent for
// anonymous/top-level frames. Unlike `file`, it's edit-stable — a method name doesn't drift
// when lines above the call site change.
symbol?: string;
};

/**
* Pulls the enclosing function/method symbol out of a V8 stack frame. Named frames look like
* ` at <symbol> (<location>)`; anonymous/top-level frames are ` at <location>` with no
* symbol to capture, so we return nothing and the caller falls back to `file` alone.
*/
function extractSymbol(frame: string): string | undefined {
const match = frame.match(/^\s*at (.+) \(/);
if (!match) {
return;
}
// V8 prefixes async frames with `async ` in some versions; the symbol is what follows.
const symbol = match[1].replace(/^async /, "");
// Anonymous functions — including arrow-assigned methods that surface as
// `Object.<anonymous>` in bundled/minified builds — carry no stable name.
if (!symbol || symbol.includes("<anonymous>")) {
return;
}
return symbol;
}

export function traceCaller(): CallerInfo | undefined {
const stack = new Error().stack;
if (!stack) {
return;
Expand All @@ -43,13 +73,14 @@ export function traceCaller(): string | undefined {
}
const match = methodCaller.match(filepathRegex);
if (match) {
return resolveFilePath(match[1]);
return { file: resolveFilePath(match[1]), symbol: extractSymbol(methodCaller) };
}
}

const WellKnownFields = {
dbDriver: "db_driver",
file: "file",
funcName: "func_name",
route: "route",
} as const;

Expand All @@ -71,8 +102,12 @@ function buildOnQuery(
[WellKnownFields.dbDriver, "mikroorm"],
];
pushW3CTraceContext(tags);
if (caller) {
tags.push([WellKnownFields.file, caller]);
if (caller?.file) {
tags.push([WellKnownFields.file, caller.file]);
}
// The enclosing symbol is edit-stable provenance; absent for anonymous frames.
if (caller?.symbol) {
tags.push([WellKnownFields.funcName, caller.symbol]);
}
if (requestContext) {
for (const key in requestContext) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,14 @@ test("patchMikroORM preserves original SQL content", () => {
test("traceCaller returns a file path with line and column", () => {
const caller = traceCaller();
assert.ok(caller, "traceCaller should return a value");
assert.match(caller!, /:\d+:\d+$/, "Should end with :line:column");
assert.match(caller!.file, /:\d+:\d+$/, "file should end with :line:column");
});

test("traceCaller captures the enclosing function symbol when present", () => {
function loadRow() {
return traceCaller();
}
const caller = loadRow();
assert.ok(caller?.file, "file is always captured");
assert.strictEqual(caller?.symbol, "loadRow");
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Emits the following fields into the query:
| ------------- | -------------------- | --------------------------------------------------------------- |
| db_driver | Yes | The driver used to connect to the database. (TypeORM) |
| file | Yes | The file that the query was executed in. |
| func_name | Yes, if named | The function/method that built the query. Omitted if anonymous. |
| route | No | The route that the query was executed in. |
| method | No | The http method for the request that the query was executed in. |
| anything else | No | Any other information that the user wants to add to the query. |
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading