Files
quixos-protocol/src/query/relational-compile.ts
T

606 lines
27 KiB
TypeScript

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<typeof import("./schema.js").querySchema>;
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<string, FragmentDefinitionNode>,
) {
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<QueryPredicate, { kind: "compare" }>["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<QueryPredicate, { kind: "compare" }>["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 };
}