606 lines
27 KiB
TypeScript
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 };
|
|
}
|