Implement checked aggregation plans, native SQL, and bounded RPC capture
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
import {
|
||||
GraphQLBoolean,
|
||||
GraphQLString,
|
||||
GraphQLInt,
|
||||
GraphQLScalarType,
|
||||
GraphQLObjectType,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
type GraphQLInputType,
|
||||
type GraphQLOutputType,
|
||||
type GraphQLInputFieldConfigMap,
|
||||
type GraphQLFieldConfigMap,
|
||||
type GraphQLEnumType,
|
||||
type DocumentNode,
|
||||
type SelectionSetNode,
|
||||
type FragmentDefinitionNode,
|
||||
} from "graphql";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
valueType,
|
||||
type ValueType,
|
||||
type InterfaceRevision,
|
||||
type InterfaceRevisionId,
|
||||
type RelationshipInterfaceMember,
|
||||
} from "../capability-model/types.js";
|
||||
import {
|
||||
aggregateType,
|
||||
aggregateOperators,
|
||||
queryKeyType,
|
||||
type QueryRow,
|
||||
type AggregateOperator,
|
||||
} from "./relational.js";
|
||||
import { QueryCompileError } from "./types.js";
|
||||
|
||||
export const rowKey = (row: QueryRow): string => JSON.stringify(row);
|
||||
export const objectRow = (
|
||||
interfaceRevisionId: InterfaceRevisionId,
|
||||
membership?: { keyType?: "string" | "boolean" | "int64" },
|
||||
): QueryRow => ({ kind: "object", interfaceRevisionId, ...(membership ? { membership } : {}) });
|
||||
export type SchemaRowField = { kind: "leaf"; type: ValueType } | { kind: "row"; row: QueryRow; optional: boolean };
|
||||
export interface RelationalSchemaEnvironment {
|
||||
contracts: Map<InterfaceRevisionId, InterfaceRevision>;
|
||||
target(member: RelationshipInterfaceMember): InterfaceRevisionId;
|
||||
name(id: InterfaceRevisionId): string;
|
||||
scalar(type: ValueType): GraphQLInputType & GraphQLOutputType;
|
||||
comparison(type: ValueType): GraphQLInputObjectType;
|
||||
scalarTypes: Map<string, ValueType>;
|
||||
direction: GraphQLEnumType;
|
||||
cursor: GraphQLScalarType;
|
||||
pageInfo: GraphQLObjectType;
|
||||
}
|
||||
|
||||
export function relationalSchema(env: RelationalSchemaEnvironment, document?: DocumentNode) {
|
||||
const outputCache = new Map<string, GraphQLObjectType>();
|
||||
const inputCache = new Map<string, GraphQLInputObjectType>();
|
||||
const referenceCache = new Map<string, GraphQLScalarType>();
|
||||
const rowsets = new Map<string, QueryRow>();
|
||||
const expansionPaths = new Map<string, Map<string, string[]>>();
|
||||
const roles = new Map<
|
||||
string,
|
||||
{ kind: "rowset" | "aggregate" | "groups" | "keys" | "expand" | "relations"; row: QueryRow }
|
||||
>();
|
||||
const many = (member: RelationshipInterfaceMember) =>
|
||||
member.cardinality === "many" || member.cardinality === "many-unique";
|
||||
const relationRow = (member: RelationshipInterfaceMember) =>
|
||||
objectRow(env.target(member), { keyType: member.keyType });
|
||||
const relations = (row: QueryRow) =>
|
||||
row.kind === "object"
|
||||
? env.contracts
|
||||
.get(row.interfaceRevisionId)!
|
||||
.members.filter(
|
||||
(member): member is RelationshipInterfaceMember => member.kind === "relationship" && !!member.queryRead,
|
||||
)
|
||||
: [];
|
||||
const fields = (row: QueryRow): Map<string, SchemaRowField> => {
|
||||
if (row.kind === "pair")
|
||||
return new Map([
|
||||
["source", { kind: "row", row: row.source, optional: false }],
|
||||
["target", { kind: "row", row: row.target, optional: false }],
|
||||
]);
|
||||
return new Map(
|
||||
env.contracts.get(row.interfaceRevisionId)!.members.flatMap((member): [string, SchemaRowField][] => {
|
||||
if (member.kind === "operation" || !member.queryRead) return [];
|
||||
if (member.kind === "value") return [[member.displayName, { kind: "leaf", type: member.valueType }]];
|
||||
if (many(member)) return [];
|
||||
return [
|
||||
[
|
||||
member.displayName,
|
||||
{ kind: "row", row: objectRow(env.target(member)), optional: member.cardinality === "optional-one" },
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
};
|
||||
const refs = (id: InterfaceRevisionId) => {
|
||||
let result = referenceCache.get(id);
|
||||
if (!result) {
|
||||
result = new GraphQLScalarType({
|
||||
name: `QxRef_${env.name(id)}`,
|
||||
parseValue: (value) => value,
|
||||
parseLiteral() {
|
||||
throw new Error("Managed references must be supplied as typed variables");
|
||||
},
|
||||
});
|
||||
referenceCache.set(id, result);
|
||||
env.scalarTypes.set(result.name, valueType.interfaceRef(id));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const scalar = (type: ValueType): GraphQLInputType & GraphQLOutputType => {
|
||||
if (type.kind === "optional") return scalar(type.value);
|
||||
return type.kind === "object-ref" && type.expectation.kind === "interface"
|
||||
? refs(type.expectation.interfaceRevisionId)
|
||||
: env.scalar(type);
|
||||
};
|
||||
const leafFields = (row: QueryRow) => {
|
||||
const result = new Map<string, ValueType>();
|
||||
if (row.kind === "object") result.set("ref", valueType.interfaceRef(row.interfaceRevisionId));
|
||||
if (row.kind === "pair" || row.membership) result.set("entry", valueType.string);
|
||||
if (row.kind === "object" && row.membership?.keyType)
|
||||
result.set(
|
||||
"mapKey",
|
||||
row.membership.keyType === "boolean"
|
||||
? valueType.bool
|
||||
: row.membership.keyType === "int64"
|
||||
? valueType.int64
|
||||
: valueType.string,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
const typeName = (role: string, row: QueryRow, suffix = "") =>
|
||||
`Qx${role}_${createHash("sha256")
|
||||
.update(rowKey(row) + suffix)
|
||||
.digest("hex")
|
||||
.slice(0, 16)}`;
|
||||
const out = (role: string, row: QueryRow, make: () => GraphQLFieldConfigMap<unknown, unknown>, suffix = "") => {
|
||||
const key = typeName(role, row, suffix);
|
||||
let result = outputCache.get(key);
|
||||
if (!result) {
|
||||
result = new GraphQLObjectType({ name: key, fields: make });
|
||||
outputCache.set(key, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const inp = (role: string, row: QueryRow, make: () => GraphQLInputFieldConfigMap, suffix = "") => {
|
||||
const key = typeName(role, row, suffix);
|
||||
let result = inputCache.get(key);
|
||||
if (!result) {
|
||||
result = new GraphQLInputObjectType({ name: key, fields: make });
|
||||
inputCache.set(key, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const compare = (type: ValueType) => {
|
||||
const base = type.kind === "optional" ? type.value : type;
|
||||
if (base.kind !== "object-ref") return env.comparison(type);
|
||||
return inp(
|
||||
"RefCompare",
|
||||
objectRow(
|
||||
base.expectation.kind === "interface"
|
||||
? base.expectation.interfaceRevisionId
|
||||
: (() => {
|
||||
throw new Error("Expected interface reference");
|
||||
})(),
|
||||
),
|
||||
() => ({ eq: { type: scalar(type) }, in: { type: new GraphQLList(new GraphQLNonNull(scalar(type))) } }),
|
||||
);
|
||||
};
|
||||
// Recursive interface graphs are interned. Synthetic pair graphs are finite and document-driven.
|
||||
const columns = (
|
||||
row: QueryRow,
|
||||
operator?: Exclude<AggregateOperator, "count">,
|
||||
nullable = false,
|
||||
): GraphQLObjectType =>
|
||||
out(
|
||||
"Columns",
|
||||
row,
|
||||
() => {
|
||||
const result: GraphQLFieldConfigMap<unknown, unknown> = {};
|
||||
for (const [name, field] of fields(row)) {
|
||||
if (field.kind === "row")
|
||||
result[name] = { type: new GraphQLNonNull(columns(field.row, operator, nullable || field.optional)) };
|
||||
else {
|
||||
let type = field.type;
|
||||
if (operator) {
|
||||
try {
|
||||
type = aggregateType(operator, type);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else if (!queryKeyType(type)) continue;
|
||||
result[name] = {
|
||||
type:
|
||||
type.kind === "optional" || (!operator && nullable) ? scalar(type) : new GraphQLNonNull(scalar(type)),
|
||||
};
|
||||
}
|
||||
}
|
||||
const metadata: GraphQLFieldConfigMap<unknown, unknown> = {};
|
||||
for (const [name, type] of leafFields(row)) {
|
||||
if (operator && operator !== "countPresent") continue;
|
||||
const t = scalar(operator ? aggregateType(operator, type) : type);
|
||||
metadata[name] = { type: !operator && nullable ? t : new GraphQLNonNull(t) };
|
||||
}
|
||||
if (Object.keys(metadata).length)
|
||||
result._qx = { type: new GraphQLNonNull(out("ColumnsMeta", row, () => metadata, `${operator}:${nullable}`)) };
|
||||
// A type may have no matching scalar at this node; a private schema sentinel
|
||||
// keeps it valid while compiler validation forbids selecting that sentinel.
|
||||
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
|
||||
return result;
|
||||
},
|
||||
`${operator}:${nullable}`,
|
||||
);
|
||||
const aggregate = (row: QueryRow): GraphQLObjectType => {
|
||||
const result = out("Aggregate", row, () =>
|
||||
Object.fromEntries(
|
||||
aggregateOperators.map((op) => [
|
||||
op,
|
||||
{ type: new GraphQLNonNull(op === "count" ? env.scalar(valueType.uint64) : columns(row, op)) },
|
||||
]),
|
||||
),
|
||||
);
|
||||
roles.set(result.name, { kind: "aggregate", row });
|
||||
return result;
|
||||
};
|
||||
const keys = (row: QueryRow): GraphQLInputObjectType =>
|
||||
inp("Keys", row, () => {
|
||||
const result: GraphQLInputFieldConfigMap = {};
|
||||
for (const [name, field] of fields(row)) {
|
||||
if (field.kind === "row") result[name] = { type: keys(field.row) };
|
||||
else if (queryKeyType(field.type)) result[name] = { type: GraphQLBoolean };
|
||||
}
|
||||
const meta = Object.fromEntries([...leafFields(row)].map(([name]) => [name, { type: GraphQLBoolean }]));
|
||||
if (Object.keys(meta).length) result._qx = { type: inp("KeyMeta", row, () => meta) };
|
||||
return result;
|
||||
});
|
||||
const comparisons = (
|
||||
row: QueryRow,
|
||||
operator?: Exclude<AggregateOperator, "count">,
|
||||
ordering = false,
|
||||
): GraphQLInputObjectType =>
|
||||
inp(
|
||||
"Expressions",
|
||||
row,
|
||||
() => {
|
||||
const result: GraphQLInputFieldConfigMap = {};
|
||||
for (const [name, field] of fields(row)) {
|
||||
if (field.kind === "row") result[name] = { type: comparisons(field.row, operator, ordering) };
|
||||
else {
|
||||
let type = field.type;
|
||||
if (operator) {
|
||||
try {
|
||||
type = aggregateType(operator, type);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result[name] = { type: ordering ? env.direction : compare(type) };
|
||||
}
|
||||
}
|
||||
const meta: GraphQLInputFieldConfigMap = {};
|
||||
for (const [name, type] of leafFields(row))
|
||||
if (!operator || operator === "countPresent")
|
||||
meta[name] = { type: ordering ? env.direction : compare(operator ? aggregateType(operator, type) : type) };
|
||||
if (Object.keys(meta).length)
|
||||
result._qx = { type: inp("ExpressionMeta", row, () => meta, `${operator}:${ordering}`) };
|
||||
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
|
||||
return result;
|
||||
},
|
||||
`${operator}:${ordering}`,
|
||||
);
|
||||
const having = (row: QueryRow, ordering = false): GraphQLInputObjectType =>
|
||||
inp(
|
||||
"Having",
|
||||
row,
|
||||
() => {
|
||||
const result: GraphQLInputFieldConfigMap = { group: { type: comparisons(row, undefined, ordering) } };
|
||||
for (const op of aggregateOperators)
|
||||
result[op] = {
|
||||
type:
|
||||
op === "count" ? (ordering ? env.direction : compare(valueType.uint64)) : comparisons(row, op, ordering),
|
||||
};
|
||||
if (!ordering)
|
||||
Object.assign(result, {
|
||||
and: { type: new GraphQLList(new GraphQLNonNull(having(row))) },
|
||||
or: { type: new GraphQLList(new GraphQLNonNull(having(row))) },
|
||||
not: { type: having(row) },
|
||||
});
|
||||
return result;
|
||||
},
|
||||
String(ordering),
|
||||
);
|
||||
const where = (row: QueryRow): GraphQLInputObjectType =>
|
||||
inp("Where", row, () => {
|
||||
const result: GraphQLInputFieldConfigMap = {
|
||||
and: { type: new GraphQLList(new GraphQLNonNull(where(row))) },
|
||||
or: { type: new GraphQLList(new GraphQLNonNull(where(row))) },
|
||||
not: { type: where(row) },
|
||||
};
|
||||
for (const [name, field] of fields(row)) {
|
||||
if (row.kind === "object" && field.kind === "row") continue;
|
||||
if (name in result) throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${name}`);
|
||||
result[name] = { type: field.kind === "row" ? where(field.row) : compare(field.type) };
|
||||
}
|
||||
result._qx = {
|
||||
type: inp("WhereMeta", row, () => {
|
||||
const meta: GraphQLInputFieldConfigMap = {};
|
||||
for (const [name, type] of leafFields(row)) meta[name] = { type: compare(type) };
|
||||
const edges = relations(row);
|
||||
if (edges.length)
|
||||
meta.relations = {
|
||||
type: inp("WhereRelations", row, () =>
|
||||
Object.fromEntries(
|
||||
edges.map((member) => {
|
||||
const target = relationRow(member);
|
||||
return [
|
||||
member.displayName,
|
||||
{
|
||||
type: inp(
|
||||
"RelationPredicate",
|
||||
target,
|
||||
(): GraphQLInputFieldConfigMap =>
|
||||
many(member)
|
||||
? {
|
||||
some: { type: where(target) },
|
||||
none: { type: where(target) },
|
||||
aggregate: {
|
||||
type: inp("ReductionPredicate", target, () => ({
|
||||
where: { type: where(target) },
|
||||
having: { type: new GraphQLNonNull(having(target)) },
|
||||
})),
|
||||
},
|
||||
}
|
||||
: { is: { type: where(target) }, isNull: { type: GraphQLBoolean } },
|
||||
rowKey(row) + member.id,
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
return meta;
|
||||
}),
|
||||
};
|
||||
return result;
|
||||
});
|
||||
|
||||
const fragments = new Map(
|
||||
(document?.definitions ?? [])
|
||||
.filter((d): d is FragmentDefinitionNode => d.kind === "FragmentDefinition")
|
||||
.map((d) => [d.name.value, d]),
|
||||
);
|
||||
let visited = 0;
|
||||
const selections = (set: SelectionSetNode, active: ReadonlySet<string> = new Set()): import("graphql").FieldNode[] =>
|
||||
set.selections.flatMap((node) => {
|
||||
if (++visited > 10000)
|
||||
throw new QueryCompileError("QUERY_WORK_LIMIT", "Query schema discovery exceeds 10000 nodes");
|
||||
if (node.kind === "Field") return [node];
|
||||
if (node.kind === "FragmentSpread") {
|
||||
if (active.has(node.name.value) || active.size >= 128)
|
||||
throw new QueryCompileError("QUERY_VALIDATION", "Cyclic or excessively nested fragments");
|
||||
const fragment = fragments.get(node.name.value);
|
||||
return fragment ? selections(fragment.selectionSet, new Set([...active, node.name.value])) : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const discoverExpansion = (root: QueryRow, current: QueryRow, set: SelectionSetNode, path: string[] = []) => {
|
||||
for (const node of selections(set)) {
|
||||
if (!node.selectionSet) continue;
|
||||
const member = relations(current).find((m) => m.displayName === node.name.value);
|
||||
const next = [...path, node.name.value];
|
||||
if (member && many(member)) {
|
||||
const map = expansionPaths.get(rowKey(root)) ?? new Map();
|
||||
map.set(next.join("."), next);
|
||||
expansionPaths.set(rowKey(root), map);
|
||||
discoverRows({ kind: "pair", source: root, target: relationRow(member) }, node.selectionSet);
|
||||
} else {
|
||||
const field = fields(current).get(node.name.value);
|
||||
if (field?.kind === "row") discoverExpansion(root, field.row, node.selectionSet, next);
|
||||
}
|
||||
}
|
||||
};
|
||||
const discoverRows = (row: QueryRow, set: SelectionSetNode) => {
|
||||
rowsets.set(rowKey(row), row);
|
||||
if (rowsets.size > 256) throw new QueryCompileError("QUERY_WORK_LIMIT", "Query exceeds 256 row shapes");
|
||||
for (const node of selections(set))
|
||||
if (node.selectionSet) {
|
||||
if (node.name.value === "expand") discoverExpansion(row, row, node.selectionSet);
|
||||
else if (node.name.value === "filter" || node.name.value === "distinct") discoverRows(row, node.selectionSet);
|
||||
}
|
||||
};
|
||||
const discoverObject = (row: QueryRow, set: SelectionSetNode) => {
|
||||
for (const node of selections(set))
|
||||
if (node.selectionSet) {
|
||||
if (node.name.value === "_qx") {
|
||||
for (const meta of selections(node.selectionSet))
|
||||
if (meta.name.value === "relations" && meta.selectionSet)
|
||||
for (const edge of selections(meta.selectionSet)) {
|
||||
const member = relations(row).find((m) => m.displayName === edge.name.value);
|
||||
if (member && many(member) && edge.selectionSet) discoverRows(relationRow(member), edge.selectionSet);
|
||||
}
|
||||
} else {
|
||||
const member = relations(row).find((m) => m.displayName === node.name.value);
|
||||
if (member) {
|
||||
if (!many(member)) discoverObject(objectRow(env.target(member)), node.selectionSet);
|
||||
else
|
||||
for (const entries of selections(node.selectionSet))
|
||||
if (entries.name.value === "entries" && entries.selectionSet)
|
||||
for (const child of selections(entries.selectionSet))
|
||||
if (child.name.value === "node" && child.selectionSet)
|
||||
discoverObject(objectRow(env.target(member)), child.selectionSet);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const discover = (root: InterfaceRevisionId) => {
|
||||
for (const operation of document?.definitions ?? [])
|
||||
if (operation.kind === "OperationDefinition")
|
||||
for (const node of selections(operation.selectionSet))
|
||||
if (node.name.value === "root" && node.selectionSet) discoverObject(objectRow(root), node.selectionSet);
|
||||
};
|
||||
const expand = (root: QueryRow, current: QueryRow, path: string[] = []): GraphQLObjectType =>
|
||||
out(
|
||||
"Expand",
|
||||
current,
|
||||
() => {
|
||||
const result: GraphQLFieldConfigMap<unknown, unknown> = {};
|
||||
const paths = [...(expansionPaths.get(rowKey(root))?.values() ?? [])].filter((p) =>
|
||||
path.every((part, i) => p[i] === part),
|
||||
);
|
||||
for (const name of new Set(paths.map((p) => p[path.length]).filter((v): v is string => !!v))) {
|
||||
const member = relations(current).find((m) => m.displayName === name);
|
||||
if (member && many(member))
|
||||
result[name] = {
|
||||
type: new GraphQLNonNull(rowset({ kind: "pair", source: root, target: relationRow(member) })),
|
||||
};
|
||||
else {
|
||||
const field = fields(current).get(name);
|
||||
if (field?.kind === "row")
|
||||
result[name] = { type: new GraphQLNonNull(expand(root, field.row, [...path, name])) };
|
||||
}
|
||||
}
|
||||
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
|
||||
return result;
|
||||
},
|
||||
rowKey(root) + JSON.stringify(path),
|
||||
);
|
||||
const rowset = (row: QueryRow): GraphQLObjectType => {
|
||||
const result = out("Rows", row, () => {
|
||||
const entry = out("GroupEntry", row, () => ({
|
||||
key: { type: new GraphQLNonNull(GraphQLString) },
|
||||
cursor: { type: env.cursor },
|
||||
group: { type: new GraphQLNonNull(columns(row)) },
|
||||
aggregate: { type: new GraphQLNonNull(aggregate(row)) },
|
||||
}));
|
||||
const connection = out("Groups", row, () => ({
|
||||
entries: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(entry))) },
|
||||
pageInfo: { type: new GraphQLNonNull(env.pageInfo) },
|
||||
}));
|
||||
roles.set(connection.name, { kind: "groups", row });
|
||||
return {
|
||||
filter: { type: new GraphQLNonNull(rowset(row)), args: { where: { type: new GraphQLNonNull(where(row)) } } },
|
||||
distinct: { type: new GraphQLNonNull(rowset(row)), args: { by: { type: new GraphQLNonNull(keys(row)) } } },
|
||||
expand: { type: new GraphQLNonNull(expand(row, row)) },
|
||||
aggregate: { type: new GraphQLNonNull(aggregate(row)), args: { where: { type: where(row) } } },
|
||||
groups: {
|
||||
type: new GraphQLNonNull(connection),
|
||||
args: {
|
||||
by: { type: new GraphQLNonNull(keys(row)) },
|
||||
where: { type: where(row) },
|
||||
having: { type: having(row) },
|
||||
orderBy: { type: new GraphQLList(new GraphQLNonNull(having(row, true))) },
|
||||
first: { type: GraphQLInt },
|
||||
all: { type: GraphQLInt },
|
||||
after: { type: env.cursor },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
roles.set(result.name, { kind: "rowset", row });
|
||||
return result;
|
||||
};
|
||||
const helpers = (id: InterfaceRevisionId): GraphQLObjectType | undefined => {
|
||||
const row = objectRow(id),
|
||||
edges = relations(row).filter(many);
|
||||
if (!edges.length) return undefined;
|
||||
const result = out("Relations", row, () =>
|
||||
Object.fromEntries(
|
||||
edges.map((member) => [member.displayName, { type: new GraphQLNonNull(rowset(relationRow(member))) }]),
|
||||
),
|
||||
);
|
||||
roles.set(result.name, { kind: "relations", row });
|
||||
return result;
|
||||
};
|
||||
return { refs, fields, relations, relationRow, where, rowset, helpers, roles, discover, scalar, compare };
|
||||
}
|
||||
Reference in New Issue
Block a user