import type { ASTNode, FieldNode, FragmentDefinitionNode, SelectionSetNode, ValueNode } from "graphql"; import { valueType, type InterfaceRevisionId, type RelationshipInterfaceMember, type ValueType, } from "../capability-model/types.js"; import { QueryCompileError, type QueryArgument, type QueryDeclaration, type QuerySelection, type QueryUse, } from "./types.js"; import { aggregateType, queryKeyType, type AggregateOperator, type QueryAggregatePredicate, type QueryEffectRecorder, type QueryExpression, type QueryKey, type QueryPathStep, type QueryPredicate, type QueryReduction, type QueryRelationalPlan, type QueryRelationalTerminal, type QueryRow, } from "./relational.js"; import { objectRow } from "./relational-schema.js"; export function queryArgument(node: ValueNode): QueryArgument { if (node.kind === "Variable") return { kind: "variable", name: node.name.value }; if (node.kind === "ListValue") return { kind: "list", values: node.values.map(queryArgument) }; if (node.kind === "ObjectValue") return { kind: "object", fields: Object.fromEntries(node.fields.map((f) => [f.name.value, queryArgument(f.value)])), }; return { kind: "literal", value: node.kind === "NullValue" ? null : node.value }; } type Environment = ReturnType; type Availability = readonly QueryKey[] | undefined; function fail(code: string, message: string, node: ASTNode): never { const token = node.loc?.startToken; throw new QueryCompileError( code, message, token ? { file: node.loc!.source.name, line: token.line, column: token.column } : undefined, ); } const pairs = (node: ValueNode): readonly import("graphql").ObjectFieldNode[] => node.kind === "ObjectValue" ? node.fields : fail("QUERY_UNSUPPORTED_FEATURE", "Query structure must be a literal object", node); const ops = new Set(["eq", "in", "isNull", "lt", "lte", "gt", "gte"]); const prefix = (a: readonly string[], b: readonly string[]) => a.every((part, i) => part === b[i]); export function relationalCompiler( env: Environment, declaration: QueryDeclaration, mark: QueryEffectRecorder, fragments: Map, ) { const rowFields = env.relational.fields; const relationships = env.relational.relations; const needsRpc = (value: unknown): boolean => { if (!value || typeof value !== "object") return false; if ("leaf" in value) { const leaf = (value as QueryExpression).leaf; if (leaf.kind === "field") return ( env.contracts .get(leaf.interfaceRevisionId) ?.members.some( (m) => m.id === leaf.memberId && m.kind === "value" && m.queryRead?.execution === "rpc-permitted", ) ?? false ); } return Object.values(value).some(needsRpc); }; let stageCount = 0; const fields = (set: SelectionSetNode): FieldNode[] => set.selections.flatMap((n) => n.kind === "Field" ? [n] : n.kind === "FragmentSpread" ? fields(fragments.get(n.name.value)!.selectionSet) : [], ); const requireAvailable = (path: string[], available: Availability, node: ASTNode) => { if (!available) return; const ok = available.some((key) => { if (key.path.join(".") === path.join(".")) return true; const last = key.expression.leaf.kind; if (last !== "ref" && last !== "entry") return false; const base = key.path.slice(0, -2); if (!prefix(base, path)) return false; // Object identity does not determine which incoming membership carried it. return ( last === "entry" || !(path[base.length] === "_qx" && ["entry", "mapKey"].includes(path[base.length + 1] ?? "")) ); }); if (!ok) fail( "QUERY_DISTINCT_FIELD_UNAVAILABLE", `${path.join(".")} was dropped by distinct; retain its identity or key explicitly`, node, ); }; const walk = ( root: QueryRow, names: string[], uses: QueryUse[], node: ASTNode, ): { row: QueryRow; path: QueryPathStep[]; nullable: boolean } => { let row = root, nullable = false; const path: QueryPathStep[] = []; for (const name of names) { if (row.kind === "pair") { if (name !== "source" && name !== "target") return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown row binding ${name}`, node); path.push({ kind: name }); row = row[name]; continue; } const relation = relationships(row).find((m) => m.displayName === name); if (!relation) return fail("QUERY_FIELD_NOT_QUERYABLE", `Expected queryable relationship ${name}`, node); if (relation.cardinality === "many" || relation.cardinality === "many-unique") return fail( "QUERY_EXPANSION_REQUIRED", `${name} is to-many; explicitly expand it, quantify it or aggregate it`, node, ); for (const use of uses) mark(row.interfaceRevisionId, name, use, node); const target = env.target(relation), optional = relation.cardinality === "optional-one"; path.push({ kind: "relation", interfaceRevisionId: row.interfaceRevisionId, memberId: relation.id, targetInterfaceRevisionId: target, optional, }); nullable ||= optional; row = objectRow(target); } if (path.length > declaration.budgets.depth) fail("QUERY_DEPTH_LIMIT", "Expression path exceeds query depth", node); return { row, path, nullable }; }; const expression = ( root: QueryRow, names: string[], uses: QueryUse[], node: ASTNode, available?: Availability, ): QueryExpression => { requireAvailable(names, available, node); const meta = names.at(-2) === "_qx"; const { row, path, nullable } = walk(root, names.slice(0, meta ? -2 : -1), uses, node); const name = names.at(-1)!; let type: ValueType, leaf: QueryExpression["leaf"]; if (meta) { if (name === "ref" && row.kind === "object") { type = valueType.interfaceRef(row.interfaceRevisionId); leaf = { kind: "ref", interfaceRevisionId: row.interfaceRevisionId }; } else if (name === "entry" && (row.kind === "pair" || row.membership)) { type = valueType.string; leaf = { kind: "entry" }; } else if (name === "mapKey" && row.kind === "object" && row.membership?.keyType) { type = row.membership.keyType === "boolean" ? valueType.bool : row.membership.keyType === "int64" ? valueType.int64 : valueType.string; leaf = { kind: "mapKey" }; } else return fail("QUERY_KEY_INVALID", `Unavailable metadata ${names.join(".")}`, node); } else { if (row.kind !== "object") return fail("QUERY_FIELD_NOT_QUERYABLE", `Expected source or target, not ${name}`, node); const field = rowFields(row).get(name); if (!field || field.kind !== "leaf") { if (relationships(row).some((m) => m.displayName === name)) fail("QUERY_EXPANSION_REQUIRED", `${name} is not a scalar operand`, node); return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown queryable scalar ${name}`, node); } for (const use of uses) mark(row.interfaceRevisionId, name, use, node); const member = env.contracts.get(row.interfaceRevisionId)!.members.find((m) => m.displayName === name)!; type = field.type; leaf = { kind: "field", interfaceRevisionId: row.interfaceRevisionId, memberId: member.id }; } return { path, leaf, type: nullable && type.kind !== "optional" ? valueType.optional(type) : type }; }; const keyList = (row: QueryRow, node: ValueNode, use: "group" | "distinct", available?: Availability): QueryKey[] => { const result: QueryKey[] = []; const visit = (node: ValueNode, path: string[]) => { if (node.kind === "BooleanValue") { if (!node.value) fail("QUERY_KEY_INVALID", "Key selector leaves must be literal true", node); const expr = expression(row, path, [use], node, available); if (!queryKeyType(expr.type)) fail("QUERY_KEY_INVALID", `Unsupported key ${path.join(".")}`, node); result.push({ path, expression: expr }); return; } for (const field of pairs(node)) visit(field.value, [...path, field.name.value]); }; visit(node, []); if (!result.length) fail("QUERY_KEY_INVALID", "Key selectors must not be empty", node); return result.sort((a, b) => JSON.stringify(a.expression).localeCompare(JSON.stringify(b.expression))); }; const comparisons = ( node: ValueNode, make: ( operator: Extract["operator"], value: QueryArgument, ) => QueryPredicate | QueryAggregatePredicate, ) => pairs(node).map((field) => { if (!ops.has(field.name.value)) return fail("QUERY_PREDICATE_INVALID", `Unsupported comparison ${field.name.value}`, field); if (field.name.value === "in" && field.value.kind === "ListValue" && field.value.values.length > 1000) fail("QUERY_WORK_LIMIT", "in supports at most 1000 operands", field); return make( field.name.value as Extract["operator"], queryArgument(field.value), ); }); const reduction = ( row: QueryRow, op: AggregateOperator, path: string[], node: ASTNode, uses: QueryUse[], available?: Availability, ): QueryReduction => { if (op === "count") return { operator: op, type: aggregateType(op) }; const operand = expression(row, path, ["aggregate", ...uses], node, available); return { operator: op, operand, type: aggregateType(op, operand.type) }; }; const aggregatePredicate = ( row: QueryRow, node: ValueNode, keys: QueryKey[] = [], available?: Availability, ): QueryAggregatePredicate => { const children: QueryAggregatePredicate[] = []; for (const field of pairs(node)) { const name = field.name.value; if (name === "and" || name === "or") { if (field.value.kind !== "ListValue") fail("QUERY_UNSUPPORTED_FEATURE", "Boolean predicate structure is fixed in the document", field); children.push({ kind: name, children: field.value.values.map((v) => aggregatePredicate(row, v, keys, available)), }); } else if (name === "not") children.push({ kind: "not", child: aggregatePredicate(row, field.value, keys, available) }); else if (name === "count") children.push( ...(comparisons(field.value, (operator, value) => ({ kind: "compare", expression: reduction(row, "count", [], field, []), operator, value, })) as QueryAggregatePredicate[]), ); else { const visit = (n: ValueNode, path: string[]) => { const entries = pairs(n); if (entries.some((f) => ops.has(f.name.value))) { if (name === "group" && !keys.some((k) => k.path.join(".") === path.join("."))) fail("QUERY_GROUP_FIELD_UNAVAILABLE", `${path.join(".")} is not a grouping key`, n); const expr = name === "group" ? expression(row, path, ["predicate"], n, available) : reduction(row, name as AggregateOperator, path, n, ["predicate"], available); children.push( ...(comparisons(n, (operator, value) => ({ kind: "compare", expression: expr, operator, value, })) as QueryAggregatePredicate[]), ); } else for (const entry of entries) visit(entry.value, [...path, entry.name.value]); }; visit(field.value, []); } } return { kind: "and", children }; }; const predicate = (row: QueryRow, node: ValueNode, available?: Availability): QueryPredicate => { const children: QueryPredicate[] = []; const visit = (current: QueryRow, n: ValueNode, base: string[]) => { for (const field of pairs(n)) { const name = field.name.value; if (name === "and" || name === "or") { if (field.value.kind !== "ListValue") fail("QUERY_UNSUPPORTED_FEATURE", "Boolean predicate structure is fixed in the document", field); const branches = field.value.values.map((v) => { const before = children.length; visit(current, v, base); return { kind: "and", children: children.splice(before) } as QueryPredicate; }); children.push({ kind: name, children: branches }); } else if (name === "not") { const before = children.length; visit(current, field.value, base); children.push({ kind: "not", child: { kind: "and", children: children.splice(before) } }); } else if (current.kind === "pair" && (name === "source" || name === "target")) visit(current[name], field.value, [...base, name]); else if (name === "_qx") { for (const meta of pairs(field.value)) { if (meta.name.value !== "relations") { const expr = expression(row, [...base, "_qx", meta.name.value], ["predicate"], meta, available); children.push( ...(comparisons(meta.value, (operator, value) => ({ kind: "compare", expression: expr, operator, value, })) as QueryPredicate[]), ); continue; } for (const edge of pairs(meta.value)) { if (current.kind !== "object") fail("QUERY_CONTRACT", "A pair is not an object with graph relationships", edge); const member = relationships(current).find((m) => m.displayName === edge.name.value)!; requireAvailable([...base, member.displayName], available, edge); mark(current.interfaceRevisionId, member.displayName, "predicate", edge); const common = { path: walk(row, base, ["predicate"], edge).path, interfaceRevisionId: current.interfaceRevisionId, memberId: member.id, targetInterfaceRevisionId: env.target(member), }; for (const op of pairs(edge.value)) { if (op.name.value === "aggregate") { const config = pairs(op.value), where = config.find((f) => f.name.value === "where"), having = config.find((f) => f.name.value === "having")!; children.push({ kind: "reduce", ...common, where: where ? predicate(env.relational.relationRow(member), where.value) : undefined, having: aggregatePredicate(env.relational.relationRow(member), having.value), }); } else if (op.name.value === "isNull") children.push({ kind: "relation", ...common, operator: "isNull", value: queryArgument(op.value) }); else children.push({ kind: "relation", ...common, operator: op.name.value as "some" | "none" | "is", predicate: predicate(env.relational.relationRow(member), op.value), }); } } } } else { const expr = expression(row, [...base, name], ["predicate"], field, available); children.push( ...(comparisons(field.value, (operator, value) => ({ kind: "compare", expression: expr, operator, value, })) as QueryPredicate[]), ); } } }; visit(row, node, []); return { kind: "and", children }; }; const plan = ( owner: InterfaceRevisionId, member: RelationshipInterfaceMember, set: SelectionSetNode, selection: (set: SelectionSetNode, typeName: string) => QuerySelection[], ): QueryRelationalPlan => { const result: QueryRelationalPlan = { stages: [], terminals: [] }; const add = (row: QueryRow, operation: QueryRelationalPlan["stages"][number]["operation"], input?: number) => { if (++stageCount > 256) throw new QueryCompileError("QUERY_WORK_LIMIT", "Query exceeds 256 relational stages"); const id = result.stages.length; result.stages.push({ id, row, operation, input }); return id; }; const root = env.relational.relationRow(member); const initial = add(root, { kind: "source", interfaceRevisionId: owner, memberId: member.id, targetInterfaceRevisionId: env.target(member), }); mark(owner, member.displayName, "select", set); const bound = (node: FieldNode) => { const args = node.arguments ?? [], bounds = args.filter((a) => a.name.value === "first" || a.name.value === "all"); if (bounds.length !== 1) fail("QUERY_ROW_LIMIT", "Specify exactly one first or all for groups", node); if (bounds[0]!.name.value === "all" && args.some((a) => a.name.value === "after")) fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all cannot continue", node); for (const b of bounds) if ( b.value.kind !== "Variable" && (b.value.kind !== "IntValue" || Number(b.value.value) < 1 || Number(b.value.value) > declaration.budgets.rows) ) fail("QUERY_ROW_LIMIT", "Group page exceeds row budget", b); }; const reductions = ( row: QueryRow, set: SelectionSetNode, available: Availability, ): QueryRelationalTerminal["reductions"] => { const output: QueryRelationalTerminal["reductions"] = []; const visit = (set: SelectionSetNode, path: string[], response: string[], op: AggregateOperator) => { for (const n of fields(set)) { const key = n.alias?.value ?? n.name.value, next = [...path, n.name.value], out = [...response, key]; if (n.selectionSet) visit(n.selectionSet, next, out, op); else output.push({ path: out, reduction: reduction(row, op, next, n, [], available) }); } }; for (const n of fields(set)) { const op = n.name.value as AggregateOperator, key = n.alias?.value ?? n.name.value; if (op === "count") output.push({ path: [key], reduction: reduction(row, op, [], n, []) }); else if (n.selectionSet) visit(n.selectionSet, [], [key], op); } return output; }; const groupsProjection = (row: QueryRow, set: SelectionSetNode, keys: QueryKey[], available: Availability) => { let values: QueryRelationalTerminal["reductions"] = []; for (const entries of fields(set)) if (entries.name.value === "entries" && entries.selectionSet) for (const n of fields(entries.selectionSet)) { if (n.name.value === "aggregate" && n.selectionSet) values.push( ...reductions(row, n.selectionSet, available).map((v) => ({ ...v, path: [entries.alias?.value ?? entries.name.value, n.alias?.value ?? n.name.value, ...v.path], })), ); if (n.name.value === "group" && n.selectionSet) { const visit = (set: SelectionSetNode, path: string[]) => { for (const f of fields(set)) { const next = [...path, f.name.value]; if (f.selectionSet) visit(f.selectionSet, next); else if (!keys.some((k) => k.path.join(".") === next.join("."))) fail("QUERY_GROUP_FIELD_UNAVAILABLE", `${next.join(".")} is not a selected group key`, f); } }; visit(n.selectionSet, []); } } return values; }; const run = (row: QueryRow, stage: number, set: SelectionSetNode, path: string[], available?: Availability) => { for (const node of fields(set)) { if (!node.selectionSet) continue; if (node.directives?.length) fail("QUERY_UNSUPPORTED_FEATURE", "Relational stages cannot be conditionally selected", node); const name = node.name.value, outputPath = [...path, node.alias?.value ?? name]; const args = new Map((node.arguments ?? []).map((a) => [a.name.value, a.value])); if (name === "filter") run( row, add(row, { kind: "filter", predicate: predicate(row, args.get("where")!, available) }, stage), node.selectionSet, outputPath, available, ); else if (name === "distinct") { const keys = keyList(row, args.get("by")!, "distinct", available); run(row, add(row, { kind: "distinct", keys }, stage), node.selectionSet, outputPath, keys); } else if (name === "expand") { const expand = (current: QueryRow, set: SelectionSetNode, memberPath: string[], output: string[]) => { for (const n of fields(set)) { if (!n.selectionSet) continue; const member = relationships(current).find((m) => m.displayName === n.name.value); const next = [...memberPath, n.name.value], out = [...output, n.alias?.value ?? n.name.value]; if (member && (member.cardinality === "many" || member.cardinality === "many-unique")) { requireAvailable(next, available, n); if (current.kind !== "object") fail("QUERY_CONTRACT", "Expected object expansion source", n); const walked = walk(row, memberPath, ["select"], n); mark(current.interfaceRevisionId, member.displayName, "select", n); const target = env.relational.relationRow(member), pair: QueryRow = { kind: "pair", source: row, target }; const newAvailable = available ? [ ...available.map((k) => ({ ...k, path: ["source", ...k.path], expression: { ...k.expression, path: [{ kind: "source" as const }, ...k.expression.path] }, })), { path: ["target", "_qx", "entry"], expression: { path: [{ kind: "target" as const }], leaf: { kind: "entry" as const }, type: valueType.string, }, }, ] : undefined; run( pair, add( pair, { kind: "expand", path: walked.path, interfaceRevisionId: current.interfaceRevisionId, memberId: member.id, targetInterfaceRevisionId: env.target(member), }, stage, ), n.selectionSet, out, newAvailable, ); } else { const f = rowFields(current).get(n.name.value); if (f?.kind === "row") expand(f.row, n.selectionSet, next, out); else fail("QUERY_EXPANSION_REQUIRED", "Expected a declared expansion path", n); } } }; expand(row, node.selectionSet, [], outputPath); } else if (name === "aggregate" || name === "groups") { if (name === "groups") bound(node); const keys = name === "groups" ? keyList(row, args.get("by")!, "group", available) : []; const terminal: QueryRelationalTerminal = { stage, path: outputPath, kind: name, keys, reductions: name === "aggregate" ? reductions(row, node.selectionSet, available) : groupsProjection(row, node.selectionSet, keys, available), order: [], residual: false, where: args.has("where") ? predicate(row, args.get("where")!, available) : undefined, having: args.has("having") ? aggregatePredicate(row, args.get("having")!, keys, available) : undefined, first: args.has("first") ? queryArgument(args.get("first")!) : undefined, all: args.has("all") ? queryArgument(args.get("all")!) : undefined, after: args.has("after") ? queryArgument(args.get("after")!) : undefined, selection: selection( node.selectionSet, name === "aggregate" ? env.relational.rowset(row).getFields().aggregate!.type.toString().replace(/!$/u, "") : env.relational.rowset(row).getFields().groups!.type.toString().replace(/!$/u, ""), ), }; const order = args.get("orderBy"); if (order) { if (order.kind !== "ListValue") fail("QUERY_UNSUPPORTED_FEATURE", "Ordering is a fixed list of field directions", order); for (const item of order.values) { const start = terminal.order.length; const visit = (n: ValueNode, path: string[]) => { if (n.kind === "EnumValue") { const [op, ...operand] = path; let expr: QueryExpression | QueryReduction; if (op === "group") { if (!keys.some((k) => k.path.join(".") === operand.join("."))) fail("QUERY_GROUP_FIELD_UNAVAILABLE", "Sort key was not grouped", n); expr = expression(row, operand, ["order"], n, available); } else expr = reduction(row, op as AggregateOperator, operand, n, ["order"], available); terminal.order.push({ expression: expr, descending: n.value === "DESC" }); } else for (const f of pairs(n)) visit(f.value, [...path, f.name.value]); }; visit(item, []); if (terminal.order.length !== start + 1) fail("QUERY_ORDER_INVALID", "Each orderBy element must name exactly one field", item); } } result.terminals.push(terminal); } else fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported relational stage ${name}`, node); } }; run(root, initial, set, []); for (const terminal of result.terminals) { let stage: QueryRelationalPlan["stages"][number] | undefined = result.stages[terminal.stage]; let rpc = needsRpc(terminal); while (stage) { rpc ||= needsRpc(stage.operation); stage = stage.input === undefined ? undefined : result.stages[stage.input]; } terminal.residual = rpc; if (rpc && terminal.after) fail( "QUERY_UNSUPPORTED_FEATURE", "RPC relational inputs require a bounded first/all window without continuation", set, ); } return result; }; return { expression, predicate, aggregatePredicate, keyList, plan, needsRpc }; }