498 lines
21 KiB
TypeScript
498 lines
21 KiB
TypeScript
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 };
|
|
}
|