382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
import {
|
|||
|
|
parse,
|
||
|
|
Source,
|
||
|
|
validate,
|
||
|
|
specifiedRules,
|
||
|
|
print,
|
||
|
|
printSchema,
|
||
|
|
visit,
|
||
|
|
TypeInfo,
|
||
|
|
visitWithTypeInfo,
|
||
|
|
getNamedType,
|
||
|
|
isNonNullType,
|
||
|
|
isListType,
|
||
|
|
isObjectType,
|
||
|
|
isInputObjectType,
|
||
|
|
isScalarType,
|
||
|
|
isEnumType,
|
||
|
|
typeFromAST,
|
||
|
|
type GraphQLType,
|
||
|
|
type SelectionSetNode,
|
||
|
|
type FragmentDefinitionNode,
|
||
|
|
type ValueNode,
|
||
|
|
type ASTNode,
|
||
|
|
type DocumentNode,
|
||
|
|
type DirectiveNode,
|
||
|
|
} from "graphql";
|
||
|
|
import { createHash } from "node:crypto";
|
||
|
|
import { readRepositorySource } from "../capability-language/source-loader.js";
|
||
|
|
import {
|
||
|
|
valueType,
|
||
|
|
type InterfaceRevision,
|
||
|
|
type ValueType,
|
||
|
|
type InterfaceRevisionId,
|
||
|
|
} from "../capability-model/types.js";
|
||
|
|
import { querySchema } from "./schema.js";
|
||
|
|
import {
|
||
|
|
QueryCompileError,
|
||
|
|
type QueryDeclaration,
|
||
|
|
type CheckedQuery,
|
||
|
|
type QueryFieldEffect,
|
||
|
|
type QueryUse,
|
||
|
|
type QueryArgument,
|
||
|
|
type QuerySelection,
|
||
|
|
} from "./types.js";
|
||
|
|
|
||
|
|
const 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 shapeScalar = (name: string): ValueType => {
|
||
|
|
switch (name) {
|
||
|
|
case "Boolean":
|
||
|
|
return valueType.bool;
|
||
|
|
case "Int":
|
||
|
|
return valueType.int32;
|
||
|
|
case "Int64":
|
||
|
|
return valueType.int64;
|
||
|
|
case "UInt32":
|
||
|
|
return valueType.uint32;
|
||
|
|
case "UInt64":
|
||
|
|
return valueType.uint64;
|
||
|
|
case "Float":
|
||
|
|
return valueType.double;
|
||
|
|
case "Bytes":
|
||
|
|
return valueType.bytes;
|
||
|
|
default:
|
||
|
|
return valueType.string;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Only immutable repository sources call this. Runtime requests never compile documents. */
|
||
|
|
export async function compileQuery(
|
||
|
|
declaration: QueryDeclaration,
|
||
|
|
interfaces: readonly InterfaceRevision[],
|
||
|
|
read: (name: string) => Promise<string>,
|
||
|
|
): Promise<CheckedQuery> {
|
||
|
|
const paths = [declaration.document, ...declaration.fragments];
|
||
|
|
if (paths.length > 128 || new Set(paths).size !== paths.length)
|
||
|
|
fail("QUERY_SOURCE_LIMIT", "Query files must be distinct and bounded to 128");
|
||
|
|
const definitions: DocumentNode["definitions"][number][] = [];
|
||
|
|
let totalBytes = 0;
|
||
|
|
for (const path of paths) {
|
||
|
|
if (
|
||
|
|
!path.endsWith(".graphql") ||
|
||
|
|
path.startsWith("/") ||
|
||
|
|
path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")
|
||
|
|
)
|
||
|
|
fail("QUERY_SOURCE_PATH", `Invalid query source path ${path}`);
|
||
|
|
const text = await read(path);
|
||
|
|
totalBytes += Buffer.byteLength(text);
|
||
|
|
if (totalBytes > 262144) fail("QUERY_SOURCE_LIMIT", "Query sources exceed 256 KiB");
|
||
|
|
const document = parse(new Source(text, path), { maxTokens: 10000 });
|
||
|
|
definitions.push(...document.definitions);
|
||
|
|
}
|
||
|
|
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
|
||
|
|
const generated = querySchema(declaration, interfaces);
|
||
|
|
const operations = definitions.filter((entry) => entry.kind === "OperationDefinition");
|
||
|
|
if (
|
||
|
|
operations.length !== 1 ||
|
||
|
|
operations[0]!.operation !== "query" ||
|
||
|
|
operations[0]!.name?.value !== declaration.operation
|
||
|
|
)
|
||
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "Exactly one named query matching the declaration is required");
|
||
|
|
visit(document, {
|
||
|
|
Field(node) {
|
||
|
|
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
|
||
|
|
},
|
||
|
|
Directive(node) {
|
||
|
|
if (!["include", "skip"].includes(node.name.value))
|
||
|
|
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
|
||
|
|
},
|
||
|
|
InlineFragment(node) {
|
||
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "Use named fragments on the exact selected type", node);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const errors = validate(generated.schema, document, specifiedRules, { maxErrors: 20 });
|
||
|
|
if (errors.length) {
|
||
|
|
const error = errors[0]!;
|
||
|
|
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
|
||
|
|
}
|
||
|
|
const effects = new Map<string, QueryFieldEffect>();
|
||
|
|
const mark = (id: InterfaceRevisionId, name: string, use: QueryUse, node: ASTNode) => {
|
||
|
|
const contract = generated.contracts.get(id)!;
|
||
|
|
const member = contract.members.find((entry) => entry.displayName === name);
|
||
|
|
if (!member || member.kind === "operation" || !member.queryRead)
|
||
|
|
return fail("QUERY_FIELD_NOT_QUERYABLE", `${contract.displayName}.${name} is not queryable`, node);
|
||
|
|
const key = `${id}\0${member.id}`;
|
||
|
|
const effect = effects.get(key) ?? {
|
||
|
|
interfaceRevisionId: id,
|
||
|
|
memberId: member.id,
|
||
|
|
uses: [],
|
||
|
|
execution: member.queryRead.execution,
|
||
|
|
};
|
||
|
|
if (!effect.uses.includes(use)) effect.uses.push(use);
|
||
|
|
effects.set(key, effect);
|
||
|
|
if (
|
||
|
|
effect.execution === "rpc-permitted" &&
|
||
|
|
!declaration.allowances.some(
|
||
|
|
(allow) =>
|
||
|
|
allow.interfaceRevisionId === id &&
|
||
|
|
allow.memberId === member.id &&
|
||
|
|
allow.uses.includes(use) &&
|
||
|
|
allow.reason.trim(),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
fail("QUERY_RPC_CONSUMER_REASON", `${contract.displayName}.${name} needs an allowance for ${use}`, node);
|
||
|
|
if (
|
||
|
|
declaration.watch &&
|
||
|
|
effect.execution === "rpc-permitted" &&
|
||
|
|
!declaration.polling &&
|
||
|
|
!member.operations.some((op) => op.displayName === "watch-start")
|
||
|
|
)
|
||
|
|
fail(
|
||
|
|
"QUERY_WATCH_UNSUPPORTED",
|
||
|
|
`${contract.displayName}.${name} has no watch; explicitly acknowledge polling or use a one-shot query`,
|
||
|
|
node,
|
||
|
|
);
|
||
|
|
};
|
||
|
|
const inputEffects = (id: InterfaceRevisionId, value: ValueNode, use: "predicate" | "order") => {
|
||
|
|
if (value.kind === "Variable") {
|
||
|
|
// A dynamic filter may name any field in its declared input type.
|
||
|
|
for (const member of generated.contracts.get(id)!.members)
|
||
|
|
if (member.kind === "value" && member.queryRead) mark(id, member.displayName, use, value);
|
||
|
|
} else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use));
|
||
|
|
else if (value.kind === "ObjectValue")
|
||
|
|
for (const field of value.fields) {
|
||
|
|
if (use === "predicate" && ["and", "or", "not"].includes(field.name.value)) inputEffects(id, field.value, use);
|
||
|
|
else mark(id, field.name.value, use, field);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const info = new TypeInfo(generated.schema);
|
||
|
|
let hasContinuation = false;
|
||
|
|
visit(
|
||
|
|
document,
|
||
|
|
visitWithTypeInfo(info, {
|
||
|
|
Field(node) {
|
||
|
|
const parent = info.getParentType();
|
||
|
|
const contract = parent && generated.byName.get(parent.name);
|
||
|
|
if (!contract || node.name.value === "_qx") return;
|
||
|
|
mark(contract.revisionId, node.name.value, "select", node);
|
||
|
|
const member = contract.members.find((entry) => entry.displayName === node.name.value)!;
|
||
|
|
if (member.kind !== "relationship" || (member.cardinality !== "many" && member.cardinality !== "many-unique"))
|
||
|
|
return;
|
||
|
|
const target = generated.target(member);
|
||
|
|
for (const argument of node.arguments ?? []) {
|
||
|
|
if (argument.name.value === "where") inputEffects(target, argument.value, "predicate");
|
||
|
|
if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order");
|
||
|
|
if (argument.name.value === "after") hasContinuation = true;
|
||
|
|
if (
|
||
|
|
argument.name.value === "first" &&
|
||
|
|
argument.value.kind !== "Variable" &&
|
||
|
|
(argument.value.kind !== "IntValue" ||
|
||
|
|
Number(argument.value.value) < 1 ||
|
||
|
|
Number(argument.value.value) > declaration.budgets.rows)
|
||
|
|
)
|
||
|
|
fail("QUERY_ROW_LIMIT", `first must be between 1 and ${declaration.budgets.rows}`, argument);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
FragmentSpread(node) {
|
||
|
|
const fragment = definitions.find(
|
||
|
|
(entry) => entry.kind === "FragmentDefinition" && entry.name.value === node.name.value,
|
||
|
|
) as FragmentDefinitionNode;
|
||
|
|
if (fragment.typeCondition.name.value !== info.getParentType()?.name)
|
||
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "Fragments must select the exact current type", node);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
if (
|
||
|
|
hasContinuation &&
|
||
|
|
[...effects.values()].some(
|
||
|
|
(effect) => effect.execution === "rpc-permitted" && effect.uses.some((use) => use !== "select"),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "RPC predicates/order support bounded first windows, not continuation");
|
||
|
|
const fragments = new Map(
|
||
|
|
definitions
|
||
|
|
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
|
||
|
|
.map((entry) => [entry.name.value, entry]),
|
||
|
|
);
|
||
|
|
let expandedFields = 0;
|
||
|
|
const output = (type: GraphQLType, selections?: SelectionSetNode, depth = 0): ValueType => {
|
||
|
|
if (depth > declaration.budgets.depth * 4 + 4) fail("QUERY_DEPTH_LIMIT", "Expanded query exceeds its depth budget");
|
||
|
|
if (isNonNullType(type)) return required(type.ofType, selections, depth);
|
||
|
|
return valueType.optional(required(type, selections, depth));
|
||
|
|
};
|
||
|
|
const required = (type: GraphQLType, selections?: SelectionSetNode, depth = 0): ValueType => {
|
||
|
|
if (isListType(type)) return valueType.list(output(type.ofType, selections, depth));
|
||
|
|
if (isObjectType(type)) {
|
||
|
|
const fields: Record<string, ValueType> = {};
|
||
|
|
const add = (set: SelectionSetNode) => {
|
||
|
|
for (const selection of set.selections) {
|
||
|
|
if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection);
|
||
|
|
if (selection.kind === "FragmentSpread") {
|
||
|
|
add(fragments.get(selection.name.value)!.selectionSet);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (selection.kind !== "Field") continue;
|
||
|
|
const key = selection.alias?.value ?? selection.name.value;
|
||
|
|
const field = type.getFields()[selection.name.value]!;
|
||
|
|
fields[key] = output(field.type, selection.selectionSet, depth + 1);
|
||
|
|
if (selection.name.value === "_qx") {
|
||
|
|
const contract = generated.byName.get(type.name)!;
|
||
|
|
const metadataFields: Record<string, ValueType> = {};
|
||
|
|
for (const metadata of selection.selectionSet?.selections ?? []) {
|
||
|
|
if (metadata.kind !== "Field" || metadata.name.value !== "ref")
|
||
|
|
fail("QUERY_UNSUPPORTED_FEATURE", "Select _qx.ref directly", metadata);
|
||
|
|
else metadataFields[metadata.alias?.value ?? "ref"] = valueType.interfaceRef(contract.revisionId);
|
||
|
|
}
|
||
|
|
fields[key] = { kind: "record", fields: metadataFields };
|
||
|
|
}
|
||
|
|
if (selection.directives?.length && fields[key]!.kind !== "optional")
|
||
|
|
fields[key] = valueType.optional(fields[key]!);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
if (selections) add(selections);
|
||
|
|
return { kind: "record", fields };
|
||
|
|
}
|
||
|
|
return shapeScalar(getNamedType(type)!.name);
|
||
|
|
};
|
||
|
|
const variables: Record<string, ValueType> = {};
|
||
|
|
const inputShape = (type: GraphQLType, seen = new Set<string>()): ValueType => {
|
||
|
|
if (isNonNullType(type)) return inputRequired(type.ofType, seen);
|
||
|
|
return valueType.optional(inputRequired(type, seen));
|
||
|
|
};
|
||
|
|
const inputRequired = (type: GraphQLType, seen: Set<string>): ValueType => {
|
||
|
|
if (isListType(type)) return valueType.list(inputShape(type.ofType, seen));
|
||
|
|
if (isInputObjectType(type)) {
|
||
|
|
// Recursive boolean filter inputs need generated recursive TS shapes; never degrade to any.
|
||
|
|
if (seen.has(type.name))
|
||
|
|
fail(
|
||
|
|
"QUERY_UNSUPPORTED_FEATURE",
|
||
|
|
"Pass scalar variables inside fixed filter expressions instead of a whole recursive filter",
|
||
|
|
);
|
||
|
|
return {
|
||
|
|
kind: "record",
|
||
|
|
fields: Object.fromEntries(
|
||
|
|
Object.entries(type.getFields()).map(([key, field]) => [
|
||
|
|
key,
|
||
|
|
inputShape(field.type, new Set([...seen, type.name])),
|
||
|
|
]),
|
||
|
|
),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (isScalarType(type) || isEnumType(type)) return shapeScalar(type.name);
|
||
|
|
return fail("QUERY_VARIABLE_TYPE", "Unsupported variable type");
|
||
|
|
};
|
||
|
|
for (const variable of operations[0]!.variableDefinitions ?? [])
|
||
|
|
variables[variable.variable.name.value] = inputShape(typeFromAST(generated.schema, variable.type)!);
|
||
|
|
const result = required(generated.schema.getQueryType()!, operations[0]!.selectionSet);
|
||
|
|
const argument = (node: ValueNode): QueryArgument => {
|
||
|
|
switch (node.kind) {
|
||
|
|
case "Variable":
|
||
|
|
return { kind: "variable", name: node.name.value };
|
||
|
|
case "ListValue":
|
||
|
|
return { kind: "list", values: node.values.map(argument) };
|
||
|
|
case "ObjectValue":
|
||
|
|
return {
|
||
|
|
kind: "object",
|
||
|
|
fields: Object.fromEntries(node.fields.map((field) => [field.name.value, argument(field.value)])),
|
||
|
|
};
|
||
|
|
case "NullValue":
|
||
|
|
return { kind: "literal", value: null };
|
||
|
|
case "BooleanValue":
|
||
|
|
return { kind: "literal", value: node.value };
|
||
|
|
// Int literals stay decimal until their checked field codec interprets them.
|
||
|
|
default:
|
||
|
|
return { kind: "literal", value: node.value };
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const conditions = (directives: readonly DirectiveNode[] = []) =>
|
||
|
|
directives.map((directive) => ({
|
||
|
|
include: directive.name.value === "include",
|
||
|
|
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
|
||
|
|
}));
|
||
|
|
const selection = (
|
||
|
|
set: SelectionSetNode,
|
||
|
|
parent: import("graphql").GraphQLObjectType,
|
||
|
|
inherited: QuerySelection["conditions"] = [],
|
||
|
|
): QuerySelection[] =>
|
||
|
|
set.selections.flatMap((node): QuerySelection[] => {
|
||
|
|
if (node.kind === "FragmentSpread")
|
||
|
|
return selection(fragments.get(node.name.value)!.selectionSet, parent, [
|
||
|
|
...inherited,
|
||
|
|
...conditions(node.directives),
|
||
|
|
]);
|
||
|
|
if (node.kind !== "Field") return [];
|
||
|
|
const contract = generated.byName.get(parent.name);
|
||
|
|
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
|
||
|
|
const type = getNamedType(parent.getFields()[node.name.value]!.type);
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
name: node.name.value,
|
||
|
|
key: node.alias?.value ?? node.name.value,
|
||
|
|
...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}),
|
||
|
|
...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}),
|
||
|
|
conditions: [...inherited, ...conditions(node.directives)],
|
||
|
|
arguments: Object.fromEntries(
|
||
|
|
(node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]),
|
||
|
|
),
|
||
|
|
selection: node.selectionSet && isObjectType(type) ? selection(node.selectionSet, type) : [],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
});
|
||
|
|
const normalized = print(document),
|
||
|
|
schema = printSchema(generated.schema);
|
||
|
|
const definitionDigest = createHash("sha256")
|
||
|
|
.update(JSON.stringify({ semantics: 1, declaration, normalized, interfaces }))
|
||
|
|
.digest("hex");
|
||
|
|
return {
|
||
|
|
declaration,
|
||
|
|
definitionDigest,
|
||
|
|
document: normalized,
|
||
|
|
schema,
|
||
|
|
variables: { kind: "record", fields: variables },
|
||
|
|
output: result,
|
||
|
|
effects: [...effects.values()],
|
||
|
|
selection: selection(operations[0]!.selectionSet, generated.schema.getQueryType()!),
|
||
|
|
variableDefaults: Object.fromEntries(
|
||
|
|
(operations[0]!.variableDefinitions ?? [])
|
||
|
|
.filter((entry) => entry.defaultValue)
|
||
|
|
.map((entry) => [entry.variable.name.value, argument(entry.defaultValue!)]),
|
||
|
|
),
|
||
|
|
sourceFiles: paths,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export const compileRepositoryQuery = (
|
||
|
|
root: string,
|
||
|
|
declaration: QueryDeclaration,
|
||
|
|
interfaces: readonly InterfaceRevision[],
|
||
|
|
) => compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name));
|