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
72 changes: 72 additions & 0 deletions src/reporters/github/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,3 +636,75 @@ describe("unset-baseline callout (Site #3297 / #3312)", () => {
expect(output).not.toContain("No comparison branch configured");
});
});

describe("schema change section", () => {
const addedTableOp = {
op: "add" as const,
path: "/tables/0",
value: { type: "table", oid: 1, schemaName: "public", tableName: "orders", columns: [] },
};
const droppedIndexOp = { op: "remove" as const, path: "/indexes/3" };

test("buildViewModel surfaces a non-rendering view when metadata has no schemaChange", () => {
const ctx = makeContext({ comparison: makeComparison(), runMetadata: makeMetadata() });
const vm = buildViewModel(ctx);
expect(vm.schemaChange.hasChanges).toBe(false);
});

test("buildViewModel ignores schemaChange when changed is false", () => {
const ctx = makeContext({
comparison: makeComparison(),
runMetadata: makeMetadata({ schemaChange: { changed: false, operations: [addedTableOp] } }),
});
const vm = buildViewModel(ctx);
expect(vm.schemaChange.hasChanges).toBe(false);
});

test("buildViewModel treats null schemaChange (degraded read) as no change", () => {
const ctx = makeContext({
comparison: makeComparison(),
runMetadata: makeMetadata({ schemaChange: null }),
});
const vm = buildViewModel(ctx);
expect(vm.schemaChange.hasChanges).toBe(false);
});

test("template renders schema changes vs the comparison branch", () => {
const ctx = makeContext({
comparison: makeComparison(),
comparisonBranch: "main",
runMetadata: makeMetadata({
schemaChange: { changed: true, operations: [addedTableOp, droppedIndexOp] },
}),
});
const output = renderTemplate(ctx);

expect(output).toContain("2 schema changes vs <code>main</code>");
expect(output).toContain("**Added**");
expect(output).toContain("table public.orders");
expect(output).toContain("**Removed**");
expect(output).toContain("index (removed)");
});

test("template renders no schema section when unchanged", () => {
const ctx = makeContext({
comparison: makeComparison(),
runMetadata: makeMetadata({ schemaChange: { changed: false, operations: [] } }),
});
const output = renderTemplate(ctx);
expect(output).not.toContain("schema change");
});

test("singular wording for a single schema change", () => {
const ctx = makeContext({
comparison: makeComparison(),
comparisonBranch: "main",
runMetadata: makeMetadata({
schemaChange: { changed: true, operations: [addedTableOp] },
}),
});
const output = renderTemplate(ctx);
expect(output).toContain("1 schema change vs <code>main</code>");
expect(output).not.toContain("1 schema changes");
});
});
27 changes: 27 additions & 0 deletions src/reporters/github/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import {
} from "../reporter.ts";

import type { CiQueryPayload, ImprovedQuery, RegressedQuery } from "../site-api.ts";
import {
buildSchemaChangeView,
schemaChangeHeading,
schemaChangeLabel,
type SchemaChangeView,
} from "./schema-change.ts";

n.configure({ autoescape: false, trimBlocks: true, lstripBlocks: true });

Expand Down Expand Up @@ -123,9 +129,24 @@ function buildQueryLinks(ctx: ReportContext): Record<string, string> {
return links;
}

/**
* Schema delta between this PR and the comparison baseline, sourced from the run
* metadata. Absent when the API predates the field or couldn't resolve the
* baseline schema (`null`), and empty when the schema is unchanged — all collapse
* to a non-rendering view so the template stays a single `if hasChanges` guard.
*/
function buildSchemaChange(ctx: ReportContext): SchemaChangeView {
const change = ctx.runMetadata?.schemaChange;
if (!change || !change.changed) {
return { hasChanges: false, total: 0, groups: [] };
}
return buildSchemaChangeView(change.operations);
}

export function buildViewModel(ctx: ReportContext) {
const hasComparison = !!ctx.comparison;
const queryLinks = buildQueryLinks(ctx);
const schemaChange = buildSchemaChange(ctx);

if (!hasComparison) {
return {
Expand All @@ -138,6 +159,9 @@ export function buildViewModel(ctx: ReportContext) {
newQueryCount: 0,
hasComparison: false,
queryLinks,
schemaChange,
schemaChangeHeading,
schemaChangeLabel,
};
}

Expand Down Expand Up @@ -177,6 +201,9 @@ export function buildViewModel(ctx: ReportContext) {
newQueryCount: ctx.comparison!.newQueries.length,
hasComparison: true,
queryLinks,
schemaChange,
schemaChangeHeading,
schemaChangeLabel,
};
}

Expand Down
117 changes: 117 additions & 0 deletions src/reporters/github/schema-change.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { test, expect, describe } from "vitest";
import type { Op } from "jsondiffpatch/formatters/jsonpatch";
import {
buildSchemaChangeView,
schemaChangeLabel,
type SchemaChangeKind,
} from "./schema-change.ts";

function entriesFor(view: ReturnType<typeof buildSchemaChangeView>, kind: SchemaChangeKind) {
return view.groups.find((g) => g.kind === kind)?.entries ?? [];
}

describe("buildSchemaChangeView", () => {
test("empty operations produce no changes", () => {
const view = buildSchemaChangeView([]);
expect(view.hasChanges).toBe(false);
expect(view.total).toBe(0);
expect(view.groups).toHaveLength(0);
});

test("added table is grouped under 'added' with a qualified name", () => {
const ops: Op[] = [
{
op: "add",
path: "/tables/0",
value: { type: "table", oid: 1, schemaName: "public", tableName: "users", columns: [] },
},
];
const view = buildSchemaChangeView(ops);
expect(view.hasChanges).toBe(true);
const added = entriesFor(view, "added");
expect(added).toEqual([{ kind: "added", object: "table", name: "public.users" }]);
});

test("added index names itself as table.index", () => {
const ops: Op[] = [
{
op: "add",
path: "/indexes/0",
value: {
type: "index",
oid: 42,
schemaName: "public",
tableName: "users",
indexName: "users_email_idx",
},
},
];
const added = entriesFor(buildSchemaChangeView(ops), "added");
expect(added[0]).toEqual({ kind: "added", object: "index", name: "users.users_email_idx" });
});

test("removed object is grouped under 'removed' (no value to name it)", () => {
const ops: Op[] = [{ op: "remove", path: "/constraints/2" }];
const view = buildSchemaChangeView(ops);
const removed = entriesFor(view, "removed");
expect(removed).toEqual([{ kind: "removed", object: "constraint", name: "(removed)" }]);
});

test("property-level replace is a 'changed' entry carrying the sub-path", () => {
const ops: Op[] = [{ op: "replace", path: "/indexes/0/isUnique", value: true }];
const changed = entriesFor(buildSchemaChangeView(ops), "changed");
expect(changed).toEqual([
{ kind: "changed", object: "index", name: "", detail: "isUnique" },
]);
});

test("extension uses extensionName and is unqualified", () => {
const ops: Op[] = [
{
op: "add",
path: "/extensions/0",
value: { extensionName: "pg_trgm", version: "1.0", schemaName: "public" },
},
];
const added = entriesFor(buildSchemaChangeView(ops), "added");
expect(added[0]).toEqual({ kind: "added", object: "extension", name: "pg_trgm" });
});

test("move ops and unknown collections are ignored", () => {
const ops: Op[] = [
{ op: "move", from: "/tables/0", path: "/tables/1" },
{ op: "add", path: "/unknownCollection/0", value: { name: "x" } },
];
const view = buildSchemaChangeView(ops);
expect(view.hasChanges).toBe(false);
});

test("mixed ops total and order by added → removed → changed", () => {
const ops: Op[] = [
{ op: "replace", path: "/tables/0/tablespace", value: "fast_ssd" },
{ op: "remove", path: "/indexes/3" },
{
op: "add",
path: "/tables/1",
value: { type: "table", oid: 5, schemaName: "public", tableName: "orders", columns: [] },
},
];
const view = buildSchemaChangeView(ops);
expect(view.total).toBe(3);
expect(view.groups.map((g) => g.kind)).toEqual(["added", "removed", "changed"]);
});
});

describe("schemaChangeLabel", () => {
test("named entry", () => {
expect(
schemaChangeLabel({ kind: "added", object: "table", name: "public.users" }),
).toBe("table public.users");
});

test("changed entry with detail and no name", () => {
expect(
schemaChangeLabel({ kind: "changed", object: "index", name: "", detail: "isUnique" }),
).toBe("index · isUnique");
});
});
Loading
Loading