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
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ async function runInCI(
}
}
if (previousRun) {
reportContext.comparison = compareRuns(
reportContext.comparison = await compareRuns(
queries,
previousRun,
config.regressionThreshold,
Expand Down
63 changes: 63 additions & 0 deletions src/reporters/query-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from "vitest";
import { originFile, originsCompatible, shapeKey } from "./query-shape.ts";

describe("shapeKey", () => {
test("a column added to the SELECT list does not change the key", async () => {
const base = await shapeKey(
"SELECT id, user_id FROM renders WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2",
);
const wider = await shapeKey(
"SELECT id, user_id, session_id, source_payload FROM renders WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2",
);
expect(base).not.toBeNull();
expect(wider).toBe(base);
});

test("a different WHERE changes the key", async () => {
const a = await shapeKey("SELECT id FROM renders WHERE user_id = $1");
const b = await shapeKey("SELECT id FROM renders WHERE share_slug = $1");
expect(a).not.toBe(b);
});

test("a different table changes the key", async () => {
const a = await shapeKey("SELECT id FROM renders WHERE id = $1");
const b = await shapeKey("SELECT id FROM widgets WHERE id = $1");
expect(a).not.toBe(b);
});

test("returns null for an unparseable query rather than throwing", async () => {
expect(await shapeKey("this is not sql")).toBeNull();
});
});

describe("originFile", () => {
test("reads the first entry of the file tag", () => {
expect(
originFile([{ key: "file", value: "src/db/postgres.ts;src/service.ts" }]),
).toBe("src/db/postgres.ts");
});

test("is null when there is no file tag", () => {
expect(originFile([{ key: "route", value: "/api/x" }])).toBeNull();
expect(originFile(undefined)).toBeNull();
});
});

describe("originsCompatible", () => {
test("both tagged and equal → compatible", () => {
const a = [{ key: "file", value: "src/db/postgres.ts" }];
const b = [{ key: "file", value: "src/db/postgres.ts" }];
expect(originsCompatible(a, b)).toBe(true);
});

test("both tagged and different → incompatible", () => {
const a = [{ key: "file", value: "src/db/postgres.ts" }];
const b = [{ key: "file", value: "src/db/other.ts" }];
expect(originsCompatible(a, b)).toBe(false);
});

test("either untagged → compatible", () => {
expect(originsCompatible([{ key: "file", value: "src/db/postgres.ts" }], [])).toBe(true);
expect(originsCompatible(undefined, undefined)).toBe(true);
});
});
58 changes: 58 additions & 0 deletions src/reporters/query-shape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { normalizedFingerprint, type SQLCommenterTag } from "@query-doctor/core";
import { fingerprint, parse } from "@libpg-query/parser";

/**
* A query's shape key: core's normalized fingerprint, which strips bare column
* references from every SELECT list (top-level, CTEs, subqueries, set-op arms)
* so that adding a column to a SELECT does not change the key — while a change
* to the FROM / WHERE / ORDER / LIMIT / aggregates still does.
*
* This is how we recognise "the same query, columns added" across a raw-hash
* change (#3367): a column added to a SELECT changes the exact hash, so without
* a shape key the same call site reads as one `removed` + one `new` query
* instead of one changed query. Returns null when the query can't be parsed —
* it then simply can't be shape-matched and falls back to new/removed.
*/
export async function shapeKey(query: string): Promise<string | null> {
try {
return await normalizedFingerprint(await parse(query), fingerprint);
} catch {
return null;
}
}

/**
* The query's own call site: the first entry of the sqlcommenter `file` tag
* (packed most-specific-first, `;`-separated), read the same way test-origin
* detection reads origin. Used to keep shape-matching from merging
* identically-shaped queries issued from different call sites. Null when the
* query carries no `file` tag.
*/
export function originFile(
tags: readonly SQLCommenterTag[] | undefined,
): string | null {
if (!tags) return null;
for (const tag of tags) {
if (tag.key !== "file") continue;
const first = tag.value.split(";")[0]?.trim();
if (first) return first;
}
return null;
}

/**
* Whether two queries' origins are compatible for shape-matching. When both
* carry a `file` tag they must name the same call site; when either is untagged
* we allow the match, since the shape key (same table + WHERE/ORDER/LIMIT,
* differing only in bare columns) is already specific enough that a collision is
* the same access pattern.
*/
export function originsCompatible(
a: readonly SQLCommenterTag[] | undefined,
b: readonly SQLCommenterTag[] | undefined,
): boolean {
const fa = originFile(a);
const fb = originFile(b);
if (fa && fb) return fa === fb;
return true;
}
Loading
Loading