Implement checked aggregation plans, native SQL, and bounded RPC capture
This commit is contained in:
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { compileCapabilityResourceSource } from "../../src/capability-language/index.js";
|
||||
import type { InterfaceRevision } from "../../src/capability-model/types.js";
|
||||
import { compileQuery } from "../../src/query/compile.js";
|
||||
|
||||
const source = { repository: "https://example.test/aggregation.git", commit: "a".repeat(40) };
|
||||
const interfaces: InterfaceRevision[] = [];
|
||||
function resource(text: string) {
|
||||
const result = compileCapabilityResourceSource(`external atom TaskObject id "task";\n${text}`, {
|
||||
source,
|
||||
environment: {
|
||||
interfaces: new Map(interfaces.map((i) => [i.displayName, i])),
|
||||
interfaceClosure: interfaces,
|
||||
},
|
||||
});
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
if (result.resource.kind === "interface") interfaces.push(result.resource.revision);
|
||||
return result.resource;
|
||||
}
|
||||
resource(`interface Person id "person" revision "person@1" {
|
||||
queryable value name id "name" : string { get id "name:get"; }
|
||||
}`);
|
||||
resource(`interface Tag id "tag" revision "tag@1" {
|
||||
queryable value color id "color" : string { get id "color:get"; }
|
||||
queryable relation tasks id "tag-tasks" : many atom TaskObject { resolve id "tag-tasks:resolve"; }
|
||||
}`);
|
||||
resource(`import interface Tag; import interface Person;
|
||||
interface Task id "task" revision "task@1" {
|
||||
queryable value estimatedHours id "hours" : optional<double> { get id "hours:get"; }
|
||||
queryable value cost id "cost" : int64 { get id "cost:get"; }
|
||||
queryable rpc value score id "score" : optional<double> { get id "score:get"; }
|
||||
queryable relation tags id "tags" : many-unique interface Tag { resolve id "tags:resolve"; }
|
||||
queryable relation assignee id "assignee" : optional-one interface Person { resolve id "assignee:resolve"; }
|
||||
}`);
|
||||
resource(`import interface Task;
|
||||
interface Tasks id "tasks" revision "tasks@1" {
|
||||
queryable relation tasks id "tasks" : many-unique interface Task { resolve id "tasks:resolve"; }
|
||||
queryable relation copies id "copies" : many interface Task ordered { resolve id "copies:resolve"; }
|
||||
queryable relation slots id "slots" : many interface Task keyed "int64" { resolve id "slots:resolve"; }
|
||||
}`);
|
||||
const pkg = resource(`import interface Tasks; import interface Task;
|
||||
package Reports id "reports" revision "reports@1" {
|
||||
query Report id "report" root Tasks document "report.graphql" operation "Report" {
|
||||
max rows 50;
|
||||
view TaskObject as Task;
|
||||
allow Task.score aggregate "Bounded aggregation fixture";
|
||||
allow Task.score predicate "Bounded aggregation fixture";
|
||||
allow Task.score order "Bounded aggregation fixture";
|
||||
allow Task.score group "Bounded aggregation fixture";
|
||||
allow Task.score distinct "Bounded aggregation fixture";
|
||||
}
|
||||
}`);
|
||||
if (pkg.kind !== "package") throw new Error("package expected");
|
||||
const declaration = pkg.revision.queries![0]!;
|
||||
export const compileAggregationDocument = (document: string) =>
|
||||
compileQuery(declaration, interfaces, async () => document);
|
||||
export const aggregationBindingSchema = (checked: Awaited<ReturnType<typeof compileQuery>>) => ({
|
||||
format: "quixos-bindings" as const,
|
||||
version: 1 as const,
|
||||
interfaces,
|
||||
packages: [{ ...pkg.revision, checkedQueries: [checked] }],
|
||||
});
|
||||
export const compileAggregation = (body: string, variables = "") =>
|
||||
compileQuery(
|
||||
declaration,
|
||||
interfaces,
|
||||
async () => `query Report${variables} { root { _qx { relations { tasks { ${body} } } } } }`,
|
||||
);
|
||||
Vendored
+9
@@ -55,6 +55,13 @@ export async function queryWorkspaceFixture() {
|
||||
allow TaskFacts.score predicate "Bounded local collection";
|
||||
allow TaskFacts.score order "Bounded local collection";
|
||||
}
|
||||
query Totals id "totals" root Collection<interface TaskFacts> document "totals.graphql" operation "Totals" {
|
||||
max rows 30; watch;
|
||||
}
|
||||
query ScoreTotals id "score-totals" root Collection<interface TaskFacts> document "score-totals.graphql" operation "ScoreTotals" {
|
||||
max rows 30; max candidates 60;
|
||||
allow TaskFacts.score aggregate "Complete bounded collection report";
|
||||
}
|
||||
}`;
|
||||
sources.Queries = packageSource;
|
||||
const pkgResult = compileCapabilityResourceSource(packageSource, {
|
||||
@@ -70,6 +77,8 @@ export async function queryWorkspaceFixture() {
|
||||
"row.graphql": `fragment TaskRow on TaskFacts {title rank done assignee {name}}`,
|
||||
"enriched.graphql": `query Enriched {root {items(first: 3) {entries {key node {_qx {ref} title score}}}}}`,
|
||||
"ranked.graphql": `query Ranked {root {items(first: 3, where: {score: {gt: 0}}, orderBy: [{score: DESC}]) {entries {key node {_qx {ref} title}}}}}`,
|
||||
"totals.graphql": `query Totals {root {_qx {relations {items {aggregate {count sum {rank}} groups(by: {done: true}, first: 10) {entries {group {done} aggregate {count sum {rank}}}}}}}}}`,
|
||||
"score-totals.graphql": `query ScoreTotals {root {_qx {relations {items {aggregate {count sum {score}}}}}}}`,
|
||||
};
|
||||
pkg.checkedQueries = await Promise.all(
|
||||
pkg.queries!.map((query) => compileQuery(query, allInterfaces, async (name) => documents[name]!)),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { compileAggregation as compile, compileAggregationDocument } from "./fixtures/query-aggregation.js";
|
||||
import type { QuerySelection } from "../src/query/types.js";
|
||||
import type { QueryRelationalPlan } from "../src/query/relational.js";
|
||||
|
||||
function findPlan(selections: QuerySelection[]): QueryRelationalPlan | undefined {
|
||||
for (const selection of selections) {
|
||||
const plan = selection.relational ?? findPlan(selection.selection);
|
||||
if (plan) return plan;
|
||||
}
|
||||
}
|
||||
|
||||
test("aggregate output uses exact count and sum types and nullable reductions", async () => {
|
||||
const checked = await compile(
|
||||
`aggregate { count countPresent { estimatedHours } sum { estimatedHours cost } avg { cost } }`,
|
||||
);
|
||||
const json = JSON.stringify(checked.output);
|
||||
assert.match(json, /"count":\{"kind":"scalar","name":"uint64"\}/);
|
||||
assert.match(json, /"cost":\{"kind":"optional","value":\{"kind":"scalar","name":"int64"\}\}/);
|
||||
assert.ok(checked.effects.some((e) => e.memberId === "hours" && e.uses.includes("aggregate")));
|
||||
const plan = findPlan(checked.selection);
|
||||
assert.ok(plan);
|
||||
assert.equal(plan.terminals[0]!.reductions.length, 5);
|
||||
});
|
||||
|
||||
test("explicit expansion and identity distinct retain checked grouped operands", async () => {
|
||||
const checked = await compile(`expand { tags {
|
||||
distinct(by: {source: {_qx: {ref: true}}, target: {color: true}}) {
|
||||
groups(by: {target: {color: true}}, first: 50,
|
||||
having: {sum: {source: {estimatedHours: {gt: 0}}}},
|
||||
orderBy: [{sum: {source: {estimatedHours: DESC}}}]) {
|
||||
entries { group {target {color}} aggregate {sum {source {estimatedHours}}} }
|
||||
pageInfo {hasNextPage endCursor}
|
||||
}
|
||||
}
|
||||
} }`);
|
||||
const plan = findPlan(checked.selection)!;
|
||||
assert.deepEqual(
|
||||
plan.stages.map((s) => s.operation.kind),
|
||||
["source", "expand", "distinct"],
|
||||
);
|
||||
assert.equal(plan.terminals[0]!.kind, "groups");
|
||||
assert.ok(
|
||||
checked.effects.some(
|
||||
(e) => e.memberId === "hours" && ["aggregate", "predicate", "order"].every((u) => e.uses.includes(u as never)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("distinct and grouping reject fields not determined by their selected keys", async () => {
|
||||
await assert.rejects(compile(`distinct(by: {cost: true}) {aggregate {sum {estimatedHours}}}`), /dropped by distinct/);
|
||||
await assert.rejects(compile(`groups(by: {cost: true}, first: 10) {entries {group {estimatedHours}}}`), /group key/);
|
||||
});
|
||||
|
||||
test("relationship existence predicates compile to typed plans", async () => {
|
||||
const checked = await compile(
|
||||
`filter(where: {_qx: {relations: {tags: {some: {color: {in: ["red","blue"]}}}}}}) {aggregate {count}}`,
|
||||
);
|
||||
const plan = findPlan(checked.selection)!;
|
||||
assert.equal(plan.stages[1]!.operation.kind, "filter");
|
||||
assert.ok(checked.effects.some((e) => e.memberId === "color" && e.uses.includes("predicate")));
|
||||
});
|
||||
|
||||
test("group structure is literal, bounded and cannot project a representative field", async () => {
|
||||
await assert.rejects(compile(`groups(by: {}, first: 2) {entries {aggregate {count}}}`), /key|empty/i);
|
||||
await assert.rejects(compile(`groups(by: {cost: false}, first: 2) {entries {aggregate {count}}}`), /true/);
|
||||
await assert.rejects(compile(`groups(by: {cost: true}) {entries {aggregate {count}}}`), /first or all/);
|
||||
await assert.rejects(
|
||||
compile(
|
||||
`groups(by: {cost: true}, first: 2, orderBy: [{sum: {cost: ASC, estimatedHours: DESC}}]) {entries {aggregate {count}}}`,
|
||||
),
|
||||
/exactly one field/,
|
||||
);
|
||||
await assert.rejects(compile(`expand {tags {aggregate {sum {target {color}}}}}`), /QUERY_VALIDATION/);
|
||||
});
|
||||
|
||||
test("RPC continuation rejection is local to the affected membership boundary", async () => {
|
||||
await assert.rejects(
|
||||
compile(`groups(by: {cost: true}, first: 2, after: null) {entries {aggregate {sum {score}}}}`),
|
||||
/without continuation/,
|
||||
);
|
||||
await assert.rejects(
|
||||
compileAggregationDocument(
|
||||
`query Report {root {tasks(first: 2, after: null, where: {score: {gt: 0}}) {entries {node {cost}}}}}`,
|
||||
),
|
||||
/without continuation/,
|
||||
);
|
||||
const checked = await compileAggregationDocument(`query Report($after: Cursor) {root {
|
||||
tasks(first: 2, after: $after) {entries {node {cost}}}
|
||||
_qx {relations {tasks {aggregate {sum {score}}}}}
|
||||
}}`);
|
||||
assert.equal(findPlan(checked.selection)!.terminals[0]!.residual, true);
|
||||
});
|
||||
|
||||
test("typed reference operands and scalar membership tests reject unbounded literal lists", async () => {
|
||||
const checked = await compile(
|
||||
`filter(where: {_qx: {ref: {in: $tasks}}}) {aggregate {count}}`,
|
||||
"($tasks: [QxRef_Task!]!)",
|
||||
);
|
||||
assert.match(JSON.stringify(checked.variables), /object-ref/);
|
||||
await assert.rejects(
|
||||
compile(`filter(where: {_qx: {ref: {eq: "obj:made-up"}}}) {aggregate {count}}`),
|
||||
/typed variables/,
|
||||
);
|
||||
await assert.rejects(
|
||||
compile(`filter(where: {cost: {in: [${Array(1001).fill("1").join(",")}]}}) {aggregate {count}}`),
|
||||
/1000 operands/,
|
||||
);
|
||||
});
|
||||
@@ -17,4 +17,13 @@ test("query fixture links generic collections, both implementations, and native
|
||||
assert.equal(title.kind, "value");
|
||||
delete title.queryRead;
|
||||
assert.throws(() => linkQueries(broken), /changed/);
|
||||
const aggregateBroken = structuredClone(workspace);
|
||||
const rank = aggregateBroken.interfaceImports
|
||||
.find((i) => i.displayName === "TaskFacts")!
|
||||
.members.find((m) => m.id === "rank")!;
|
||||
assert.equal(rank.kind, "value");
|
||||
delete rank.queryRead;
|
||||
assert.throws(() => linkQueries(aggregateBroken), /changed/);
|
||||
const totals = workspace.linkedQueries.find((q) => q.id.endsWith(":totals"))!;
|
||||
assert.equal(totals.fields.filter((f) => f.memberId === "rank").length, 2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user