From 08e322e59f0c3f60876aed1fe647ded8d0c946c7 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 23:50:52 -0700 Subject: [PATCH] Reject unsupported query operators during execution planning --- src/execution-world/plan.ts | 34 ++++++++++++++ src/execution-world/query-operators.ts | 62 ++++++++++++++++++++++++++ test/execution-world.test.ts | 24 ++++++++++ test/query-aggregation.test.ts | 12 +++++ 4 files changed, 132 insertions(+) create mode 100644 src/execution-world/query-operators.ts diff --git a/src/execution-world/plan.ts b/src/execution-world/plan.ts index cc0a96b..c5f93a2 100644 --- a/src/execution-world/plan.ts +++ b/src/execution-world/plan.ts @@ -14,6 +14,7 @@ import { canonicalJson, contentDigest } from "../capability-model/evolution.js"; import { resolveForwardedBinding } from "../capability-model/forwarding.js"; import { linkQueries, type LinkedQuery } from "../query/link.js"; import { type BindingSchema } from "../bindings/index.js"; +import { unsupportedQueryOperator } from "./query-operators.js"; export class ExecutionPlanError extends Error { constructor( @@ -305,6 +306,35 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { admittedOperations: [], })); for (const query of plannedQueries) { + for (const field of query.query.fields) { + const operation = operationMap.get(publicOperationKey(field.atomId, field.interfaceRevisionId, field.getter))!; + const effect = query.query.checked.effects.find( + (effect) => effect.interfaceRevisionId === field.interfaceRevisionId && effect.memberId === field.memberId, + ); + if ( + operation.execution.kind === "remote" && + (query.query.checked.declaration.placement?.kind === "replica" || effect?.uses.some((use) => use !== "select")) + ) + throw new ExecutionPlanError( + "QUERY_REMOTE_MEMBERSHIP_UNSUPPORTED", + query.id, + "Remote getters may enrich selected values after native snapshot closure; they cannot determine membership, order or canonical-input admission. Expose an accepted canonical value or split the service result into a separate query.", + ); + const member = interfaces.get(field.interfaceRevisionId)?.members.find((member) => member.id === field.memberId); + if (member?.kind === "relationship" && operation.binding.kind !== "edge") + throw new ExecutionPlanError( + "QUERY_NON_NATIVE_RELATION", + query.id, + "This query runtime traverses native graph edges. Expose an edge relationship, or return a computed service value through a value getter.", + ); + } + const unsupported = unsupportedQueryOperator(query.query.checked); + if (unsupported) + throw new ExecutionPlanError( + "QUERY_OPERATOR_UNSUPPORTED", + query.id, + `This execution world does not support ${unsupported}. Use a bounded portable getter over admitted inputs, or a server service returning the computed value. Query placement will not change automatically.`, + ); const placement = query.query.checked.declaration.placement; if (placement?.kind !== "local") continue; const source = plannedQueries.find( @@ -324,6 +354,10 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { const paths = new Map(); const visit = (selections: LinkedQuery["checked"]["selection"], parents: string[]) => { for (const selection of selections) { + for (const stage of selection.relational?.stages ?? []) { + if (stage.operation.kind === "source") + paths.set(JSON.stringify([...parents, stage.operation.memberId]), false); + } const member = selection.interfaceRevisionId ? interfaces.get(selection.interfaceRevisionId)?.members.find((m) => m.id === selection.memberId) : undefined; diff --git a/src/execution-world/query-operators.ts b/src/execution-world/query-operators.ts new file mode 100644 index 0000000..16e0abf --- /dev/null +++ b/src/execution-world/query-operators.ts @@ -0,0 +1,62 @@ +import type { CheckedQuery, QuerySelection } from "../query/types.js"; +import type { QueryPredicate, QueryPathStep } from "../query/relational.js"; + +/** Supported by the fresh coordinator/replica interpreters. The language parser + * can describe more operators; that does not make them executable in this world. */ +export function unsupportedQueryOperator(query: CheckedQuery): string | undefined { + const path = (steps: QueryPathStep[]) => + steps.some((step) => step.kind !== "relation") ? "expanded source/target paths" : undefined; + const predicate = (value: QueryPredicate): string | undefined => { + if (value.kind === "reduce") return "aggregate predicates"; + if (value.kind === "and" || value.kind === "or") return value.children.map(predicate).find(Boolean); + if (value.kind === "not") return predicate(value.child); + if (value.kind === "compare") + return ( + path(value.expression.path) ?? + (value.expression.leaf.kind === "mapKey" ? "keyed relationship operands" : undefined) + ); + if (value.kind === "relation") return path(value.path) ?? (value.predicate && predicate(value.predicate)); + return undefined; + }; + const conditional = (selections: readonly QuerySelection[]): boolean => + selections.some((selection) => selection.conditions.length > 0 || conditional(selection.selection)); + const walk = (selections: readonly QuerySelection[]): string | undefined => { + for (const selection of selections) { + const after = selection.arguments.after; + if (after && !(after.kind === "literal" && after.value === null)) return "cursor continuation"; + if (selection.predicate) { + const reason = predicate(selection.predicate); + if (reason) return reason; + } + if (selection.relational) { + if (query.declaration.placement?.kind === "replica") return "aggregate selections in canonical-input scopes"; + const { stages, terminals } = selection.relational; + if (stages.length !== 1 || stages[0]!.operation.kind !== "source") + return "relational expansion, filtering or distinct stages"; + if (terminals.length !== 1) return "multiple aggregate terminals"; + const terminal = terminals[0]!; + if ( + terminal.kind !== "aggregate" || + terminal.stage !== stages[0]!.id || + terminal.keys.length || + terminal.order.length || + terminal.residual || + terminal.where || + terminal.having || + terminal.first || + terminal.all || + terminal.after || + terminal.reductions.some( + (reduction) => reduction.reduction.operator !== "count" || reduction.reduction.operand, + ) + ) + return "grouping or reductions other than a direct relationship count"; + if (conditional(selection.selection)) return "conditional aggregate projections"; + } + const reason = walk(selection.selection); + if (reason) return reason; + } + return undefined; + }; + return walk(query.selection); +} diff --git a/test/execution-world.test.ts b/test/execution-world.test.ts index c22dee2..af3704e 100644 --- a/test/execution-world.test.ts +++ b/test/execution-world.test.ts @@ -284,3 +284,27 @@ test("implementation selection is explicit, target checked and independent of ma "service", ); }); + +test("execution planning rejects unsupported grouping before installing the query", async () => { + const workspace = await loadReaderWorkspace((name, text) => + name === "queries/reader-screen.graphql" + ? text.replace("aggregate { count }", "groups(by: {color: true}, first: 10) { entries { aggregate { count } } }") + : text, + ); + assert.throws(() => compileExecutionPlan(workspace), /QUERY_OPERATOR_UNSUPPORTED.*grouping/); +}); + +test("a count-only local query still requires complete relationship admission", async () => { + for (const source of [ + "query ReplicaInputs { root { sourceRevision } }", + "query ReplicaInputs { root { highlights(first: 1) { entries { node { color } } } } }", + ]) { + const workspace = await loadReaderWorkspace((name, text) => { + if (name === "queries/replica-inputs.graphql") return source; + if (name === "queries/reader-screen.graphql") + return "query ReaderScreen { root { _qx { relations { highlights { aggregate { count } } } } } }"; + return text; + }); + assert.throws(() => compileExecutionPlan(workspace), /REPLICA_TRAVERSAL_MISSING|REPLICA_RELATION_INCOMPLETE/); + } +}); diff --git a/test/query-aggregation.test.ts b/test/query-aggregation.test.ts index 142f503..91bb7b7 100644 --- a/test/query-aggregation.test.ts +++ b/test/query-aggregation.test.ts @@ -108,3 +108,15 @@ test("typed reference operands and scalar membership tests reject unbounded lite /1000 operands/, ); }); + +test("new runtime operator matrix accepts direct count and rejects unsupported relational plans", async () => { + const { unsupportedQueryOperator } = await import("../src/execution-world/query-operators.js"); + assert.equal(unsupportedQueryOperator(await compile("aggregate { count }")), undefined); + for (const expression of [ + "aggregate { sum { estimatedHours } }", + "filter(where: {estimatedHours: {gt: 0}}) { aggregate { count } }", + "distinct(by: {estimatedHours: true}) { aggregate { count } }", + "aggregate { count @skip(if: true) }", + ]) + assert.ok(unsupportedQueryOperator(await compile(expression)), expression); +});