Implement implicit object references and capability query foundations
This commit is contained in:
@@ -196,6 +196,10 @@ message QueryReductionPredicate {
|
|||||||
QueryPredicate where = 3;
|
QueryPredicate where = 3;
|
||||||
QueryPredicate having = 4;
|
QueryPredicate having = 4;
|
||||||
}
|
}
|
||||||
|
message QueryInterfacePredicate {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
QueryPredicate predicate = 2;
|
||||||
|
}
|
||||||
message QueryPredicate {
|
message QueryPredicate {
|
||||||
oneof predicate {
|
oneof predicate {
|
||||||
QueryPredicateList and = 1;
|
QueryPredicateList and = 1;
|
||||||
@@ -204,6 +208,7 @@ message QueryPredicate {
|
|||||||
QueryComparison compare = 4;
|
QueryComparison compare = 4;
|
||||||
QueryRelationPredicate relation = 5;
|
QueryRelationPredicate relation = 5;
|
||||||
QueryReductionPredicate reduce = 6;
|
QueryReductionPredicate reduce = 6;
|
||||||
|
QueryInterfacePredicate as = 7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message QueryRow {
|
message QueryRow {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function unsupportedQueryOperator(query: CheckedQuery): string | undefine
|
|||||||
if (value.kind === "reduce") return "aggregate predicates";
|
if (value.kind === "reduce") return "aggregate predicates";
|
||||||
if (value.kind === "and" || value.kind === "or") return value.children.map(predicate).find(Boolean);
|
if (value.kind === "and" || value.kind === "or") return value.children.map(predicate).find(Boolean);
|
||||||
if (value.kind === "not") return predicate(value.child);
|
if (value.kind === "not") return predicate(value.child);
|
||||||
|
if (value.kind === "as") return predicate(value.predicate);
|
||||||
if (value.kind === "compare") return path(value.expression.path);
|
if (value.kind === "compare") return path(value.expression.path);
|
||||||
if (value.kind === "relation") return path(value.path) ?? (value.predicate && predicate(value.predicate));
|
if (value.kind === "relation") return path(value.path) ?? (value.predicate && predicate(value.predicate));
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
+46
-18
File diff suppressed because one or more lines are too long
+147
-14
@@ -3,6 +3,7 @@ import {
|
|||||||
Source,
|
Source,
|
||||||
validate,
|
validate,
|
||||||
specifiedRules,
|
specifiedRules,
|
||||||
|
PossibleFragmentSpreadsRule,
|
||||||
print,
|
print,
|
||||||
printSchema,
|
printSchema,
|
||||||
visit,
|
visit,
|
||||||
@@ -89,7 +90,7 @@ export async function compileQuery(
|
|||||||
definitions.push(...document.definitions);
|
definitions.push(...document.definitions);
|
||||||
}
|
}
|
||||||
const fragmentContracts = await fragmentSources(declaration, definitions, resolveFragment);
|
const fragmentContracts = await fragmentSources(declaration, definitions, resolveFragment);
|
||||||
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
|
let document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
|
||||||
if (definitions.length > 256 || Buffer.byteLength(print(document)) > 262144)
|
if (definitions.length > 256 || Buffer.byteLength(print(document)) > 262144)
|
||||||
fail("QUERY_SOURCE_LIMIT", "Expanded fragment sources exceed the query source budget");
|
fail("QUERY_SOURCE_LIMIT", "Expanded fragment sources exceed the query source budget");
|
||||||
visit(document, {
|
visit(document, {
|
||||||
@@ -103,7 +104,44 @@ export async function compileQuery(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const generated = querySchema(declaration, interfaces, document);
|
const generated = querySchema(declaration, interfaces, document);
|
||||||
const operations = definitions.filter((entry) => entry.kind === "OperationDefinition");
|
// Identity belongs to the checked object result, including fragment results.
|
||||||
|
// Schema membership deliberately excludes structured values and query wrappers.
|
||||||
|
const identity = parse("query Identity { _camino { ref } }", { noLocation: true }).definitions[0]!;
|
||||||
|
if (identity.kind !== "OperationDefinition") throw new Error("Invalid identity selection");
|
||||||
|
const identitySelection = identity.selectionSet.selections[0]!;
|
||||||
|
const identityTypes = new TypeInfo(generated.schema);
|
||||||
|
document = visit(
|
||||||
|
document,
|
||||||
|
visitWithTypeInfo(identityTypes, {
|
||||||
|
SelectionSet: {
|
||||||
|
leave(node, _key, parentNode) {
|
||||||
|
// An inline projection is part of its containing object. Its identity
|
||||||
|
// remains that object's base interface, not the projected interface.
|
||||||
|
if (parentNode && !Array.isArray(parentNode) && "kind" in parentNode && parentNode.kind === "InlineFragment")
|
||||||
|
return;
|
||||||
|
const parent = identityTypes.getParentType();
|
||||||
|
const contract = parent && generated.byName.get(parent.name);
|
||||||
|
if (!contract || contract.revisionId === workspaceQueryRootId) return;
|
||||||
|
const hasIdentity = node.selections.some(
|
||||||
|
(field) =>
|
||||||
|
field.kind === "Field" &&
|
||||||
|
field.name.value === "_camino" &&
|
||||||
|
!field.alias &&
|
||||||
|
!field.directives?.length &&
|
||||||
|
field.selectionSet?.selections.some(
|
||||||
|
(child) =>
|
||||||
|
child.kind === "Field" && child.name.value === "ref" && !child.alias && !child.directives?.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// GraphQL validation caches comparisons by AST identity. Each nominal
|
||||||
|
// object must get its own metadata subtree, even though the spelling is
|
||||||
|
// identical (its reference scalar can be different).
|
||||||
|
if (!hasIdentity) return { ...node, selections: [...node.selections, structuredClone(identitySelection)] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const operations = document.definitions.filter((entry) => entry.kind === "OperationDefinition");
|
||||||
if (
|
if (
|
||||||
operations.length !== 1 ||
|
operations.length !== 1 ||
|
||||||
operations[0]!.operation !== "query" ||
|
operations[0]!.operation !== "query" ||
|
||||||
@@ -120,18 +158,56 @@ export async function compileQuery(
|
|||||||
if (!["include", "skip"].includes(node.name.value))
|
if (!["include", "skip"].includes(node.name.value))
|
||||||
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
|
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 });
|
// Capability interfaces are nominal views of one graph object, not mutually
|
||||||
|
// exclusive GraphQL object variants. Check overlap using declared requirements.
|
||||||
|
const projectionTypes = new TypeInfo(generated.schema);
|
||||||
|
const requires = (from: InterfaceRevisionId, to: InterfaceRevisionId, seen = new Set<string>()): boolean => {
|
||||||
|
if (from === to) return true;
|
||||||
|
if (seen.has(from)) return false;
|
||||||
|
seen.add(from);
|
||||||
|
return generated.contracts.get(from)?.requiredInterfaces?.some((id) => requires(id, to, seen)) ?? false;
|
||||||
|
};
|
||||||
|
visit(
|
||||||
|
document,
|
||||||
|
visitWithTypeInfo(projectionTypes, {
|
||||||
|
InlineFragment(node) {
|
||||||
|
if (!node.typeCondition) return;
|
||||||
|
const from = projectionTypes.getParentType();
|
||||||
|
const a = from && generated.byName.get(from.name);
|
||||||
|
const b = generated.byName.get(node.typeCondition.name.value);
|
||||||
|
if (!a || !b || !requires(a.revisionId, b.revisionId))
|
||||||
|
fail(
|
||||||
|
"QUERY_UNSUPPORTED_FEATURE",
|
||||||
|
"Inline interface projections currently require a declared interface requirement",
|
||||||
|
node,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
FragmentSpread(node) {
|
||||||
|
const fragment = document.definitions.find(
|
||||||
|
(entry) => entry.kind === "FragmentDefinition" && entry.name.value === node.name.value,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
fragment?.kind === "FragmentDefinition" &&
|
||||||
|
fragment.typeCondition.name.value !== projectionTypes.getParentType()?.name
|
||||||
|
)
|
||||||
|
fail("QUERY_UNSUPPORTED_FEATURE", "Named fragments currently require the exact nominal root interface", node);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const errors = validate(
|
||||||
|
generated.schema,
|
||||||
|
document,
|
||||||
|
specifiedRules.filter((rule) => rule !== PossibleFragmentSpreadsRule),
|
||||||
|
{ maxErrors: 20 },
|
||||||
|
);
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
const error = errors[0]!;
|
const error = errors[0]!;
|
||||||
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
|
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
|
||||||
}
|
}
|
||||||
const effects = new Map<string, QueryFieldEffect>();
|
const effects = new Map<string, QueryFieldEffect>();
|
||||||
const fragments = new Map(
|
const fragments = new Map(
|
||||||
definitions
|
document.definitions
|
||||||
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
|
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
|
||||||
.map((entry) => [entry.name.value, entry]),
|
.map((entry) => [entry.name.value, entry]),
|
||||||
);
|
);
|
||||||
@@ -313,16 +389,28 @@ export async function compileQuery(
|
|||||||
if (isListType(type)) return valueType.list(output(type.ofType, selections, depth, conditional));
|
if (isListType(type)) return valueType.list(output(type.ofType, selections, depth, conditional));
|
||||||
if (isObjectType(type)) {
|
if (isObjectType(type)) {
|
||||||
const fields: Record<string, ValueType> = {};
|
const fields: Record<string, ValueType> = {};
|
||||||
const add = (set: SelectionSetNode, conditional = false) => {
|
const add = (set: SelectionSetNode, conditional = false, selectedType = type) => {
|
||||||
for (const selection of set.selections) {
|
for (const selection of set.selections) {
|
||||||
if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection);
|
if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection);
|
||||||
if (selection.kind === "FragmentSpread") {
|
if (selection.kind === "FragmentSpread") {
|
||||||
add(fragments.get(selection.name.value)!.selectionSet, conditional || !!selection.directives?.length);
|
add(
|
||||||
|
fragments.get(selection.name.value)!.selectionSet,
|
||||||
|
conditional || !!selection.directives?.length,
|
||||||
|
selectedType,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (selection.kind === "InlineFragment") {
|
||||||
|
const projected = selection.typeCondition
|
||||||
|
? generated.schema.getType(selection.typeCondition.name.value)
|
||||||
|
: selectedType;
|
||||||
|
if (!isObjectType(projected)) return fail("QUERY_TYPE", "Expected an object projection", selection);
|
||||||
|
add(selection.selectionSet, conditional || !!selection.directives?.length, projected);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (selection.kind !== "Field") continue;
|
if (selection.kind !== "Field") continue;
|
||||||
const key = selection.alias?.value ?? selection.name.value;
|
const key = selection.alias?.value ?? selection.name.value;
|
||||||
const field = type.getFields()[selection.name.value]!;
|
const field = selectedType.getFields()[selection.name.value]!;
|
||||||
const previous = fields[key];
|
const previous = fields[key];
|
||||||
fields[key] = output(
|
fields[key] = output(
|
||||||
field.type,
|
field.type,
|
||||||
@@ -338,7 +426,9 @@ export async function compileQuery(
|
|||||||
if (previous) fields[key] = mergeOutput(previous, fields[key]!);
|
if (previous) fields[key] = mergeOutput(previous, fields[key]!);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (selections) add(selections, conditional);
|
// A condition on this object makes the object optional, not every child
|
||||||
|
// of the object when it is present. Fragment conditions are handled by add.
|
||||||
|
if (selections) add(selections);
|
||||||
return { kind: "record", fields };
|
return { kind: "record", fields };
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -409,8 +499,13 @@ export async function compileQuery(
|
|||||||
set: SelectionSetNode,
|
set: SelectionSetNode,
|
||||||
parent: import("graphql").GraphQLObjectType,
|
parent: import("graphql").GraphQLObjectType,
|
||||||
inherited: QuerySelection["conditions"] = [],
|
inherited: QuerySelection["conditions"] = [],
|
||||||
): QuerySelection[] =>
|
): QuerySelection[] => {
|
||||||
set.selections.flatMap((node): QuerySelection[] => {
|
const selections = set.selections.flatMap((node): QuerySelection[] => {
|
||||||
|
if (node.kind === "InlineFragment") {
|
||||||
|
const projected = node.typeCondition ? generated.schema.getType(node.typeCondition.name.value) : parent;
|
||||||
|
if (!isObjectType(projected)) return fail("QUERY_TYPE", "Expected an object projection", node);
|
||||||
|
return selection(node.selectionSet, projected, [...inherited, ...conditions(node.directives)]);
|
||||||
|
}
|
||||||
if (node.kind === "FragmentSpread") {
|
if (node.kind === "FragmentSpread") {
|
||||||
const inheritedConditions = [...inherited, ...conditions(node.directives)];
|
const inheritedConditions = [...inherited, ...conditions(node.directives)];
|
||||||
const contract = fragmentContracts.get(node.name.value);
|
const contract = fragmentContracts.get(node.name.value);
|
||||||
@@ -436,6 +531,7 @@ export async function compileQuery(
|
|||||||
const contract = generated.byName.get(parent.name);
|
const contract = generated.byName.get(parent.name);
|
||||||
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
|
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
|
||||||
const type = getNamedType(parent.getFields()[node.name.value]!.type);
|
const type = getNamedType(parent.getFields()[node.name.value]!.type);
|
||||||
|
const referenceType = type && generated.scalarTypes.get(type.name);
|
||||||
const role = generated.relational.roles.get(parent.name);
|
const role = generated.relational.roles.get(parent.name);
|
||||||
const relationship =
|
const relationship =
|
||||||
role?.kind === "relations"
|
role?.kind === "relations"
|
||||||
@@ -455,6 +551,11 @@ export async function compileQuery(
|
|||||||
key: node.alias?.value ?? node.name.value,
|
key: node.alias?.value ?? node.name.value,
|
||||||
...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}),
|
...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}),
|
||||||
...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}),
|
...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}),
|
||||||
|
...(node.name.value === "ref" &&
|
||||||
|
referenceType?.kind === "object-ref" &&
|
||||||
|
referenceType.expectation.kind === "interface"
|
||||||
|
? { targetInterfaceRevisionId: referenceType.expectation.interfaceRevisionId }
|
||||||
|
: {}),
|
||||||
conditions: [...inherited, ...conditions(node.directives)],
|
conditions: [...inherited, ...conditions(node.directives)],
|
||||||
arguments: Object.fromEntries(
|
arguments: Object.fromEntries(
|
||||||
(node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]),
|
(node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]),
|
||||||
@@ -465,13 +566,45 @@ export async function compileQuery(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
const contract = generated.byName.get(parent.name);
|
||||||
|
if (!contract || contract.revisionId === workspaceQueryRootId) return selections;
|
||||||
|
// Fragment expansion may repeat metadata. Combine its children so a later
|
||||||
|
// implicit reference cannot overwrite an explicitly selected aggregate.
|
||||||
|
const metadata = selections.filter((field) => field.key === "_camino" && field.name === "_camino");
|
||||||
|
const responseFields = new Map<string, QuerySelection>();
|
||||||
|
for (const field of selections.filter((field) => field.memberId)) {
|
||||||
|
const previous = responseFields.get(field.key);
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
(previous.interfaceRevisionId !== field.interfaceRevisionId || previous.memberId !== field.memberId)
|
||||||
|
)
|
||||||
|
fail("QUERY_PROJECTION_CONFLICT", `Alias distinct interface fields selected as ${field.key}`);
|
||||||
|
responseFields.set(field.key, field);
|
||||||
|
}
|
||||||
|
if (!metadata.length) return selections;
|
||||||
|
const children = metadata.flatMap((field) =>
|
||||||
|
field.selection.map((child) => ({
|
||||||
|
...child,
|
||||||
|
conditions: [...field.conditions, ...child.conditions],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ref = children.find((child) => child.key === "ref" && child.name === "ref")!;
|
||||||
|
return [
|
||||||
|
...selections.filter((field) => !metadata.includes(field)),
|
||||||
|
{
|
||||||
|
...metadata[0]!,
|
||||||
|
conditions: [],
|
||||||
|
selection: [...children.filter((child) => child.key !== "ref"), { ...ref, conditions: [] }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
const normalized = print(document),
|
const normalized = print(document),
|
||||||
schema = printSchema(generated.schema);
|
schema = printSchema(generated.schema);
|
||||||
const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!);
|
const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!);
|
||||||
const definitionDigest = createHash("sha256")
|
const definitionDigest = createHash("sha256")
|
||||||
.update(
|
.update(
|
||||||
canonicalJson({
|
canonicalJson({
|
||||||
semantics: 3,
|
semantics: 4,
|
||||||
declaration,
|
declaration,
|
||||||
normalized,
|
normalized,
|
||||||
// Source provenance belongs to the checked installation. Identical public
|
// Source provenance belongs to the checked installation. Identical public
|
||||||
|
|||||||
@@ -147,10 +147,22 @@ export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
|
|||||||
(entry) => entry.interfaceRevisionId === declaration.root.interface,
|
(entry) => entry.interfaceRevisionId === declaration.root.interface,
|
||||||
);
|
);
|
||||||
const rootAtoms = [...new Set(rootConformances.map((entry) => entry.atomId))].sort();
|
const rootAtoms = [...new Set(rootConformances.map((entry) => entry.atomId))].sort();
|
||||||
|
const qualifiedInterfaces = new Set<string>();
|
||||||
|
const qualifications = (node: unknown): void => {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
if ("kind" in node && node.kind === "as" && "interfaceRevisionId" in node)
|
||||||
|
qualifiedInterfaces.add(String(node.interfaceRevisionId));
|
||||||
|
for (const child of Object.values(node)) qualifications(child);
|
||||||
|
};
|
||||||
|
qualifications(checked.selection);
|
||||||
const bindingDigest = createHash("sha256")
|
const bindingDigest = createHash("sha256")
|
||||||
.update(
|
.update(
|
||||||
canonicalJson({
|
canonicalJson({
|
||||||
definition: checked.definitionDigest,
|
definition: checked.definitionDigest,
|
||||||
|
qualifiedConformances: workspace.conformances
|
||||||
|
.filter((entry) => qualifiedInterfaces.has(entry.interfaceRevisionId))
|
||||||
|
.map((entry) => ({ atomId: entry.atomId, id: entry.id, interfaceRevisionId: entry.interfaceRevisionId }))
|
||||||
|
.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
|
||||||
rootConformances: rootConformances
|
rootConformances: rootConformances
|
||||||
.map((entry) => ({ atomId: entry.atomId, id: entry.id, interfaceRevisionId: entry.interfaceRevisionId }))
|
.map((entry) => ({ atomId: entry.atomId, id: entry.id, interfaceRevisionId: entry.interfaceRevisionId }))
|
||||||
.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
|
.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
|
||||||
|
|||||||
@@ -337,6 +337,23 @@ export function relationalCompiler(
|
|||||||
visit(current[name], field.value, [...base, name]);
|
visit(current[name], field.value, [...base, name]);
|
||||||
else if (name === "_camino") {
|
else if (name === "_camino") {
|
||||||
for (const meta of pairs(field.value)) {
|
for (const meta of pairs(field.value)) {
|
||||||
|
if (meta.name.value === "as") {
|
||||||
|
if (current.kind !== "object" || base.length || available)
|
||||||
|
fail(
|
||||||
|
"QUERY_UNSUPPORTED_FEATURE",
|
||||||
|
"Interface predicates require an object row before distinct/grouping",
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
for (const branch of pairs(meta.value)) {
|
||||||
|
const contract = env.byName.get(branch.name.value)!;
|
||||||
|
children.push({
|
||||||
|
kind: "as",
|
||||||
|
interfaceRevisionId: contract.revisionId,
|
||||||
|
predicate: predicate(objectRow(contract.revisionId), branch.value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (meta.name.value !== "relations") {
|
if (meta.name.value !== "relations") {
|
||||||
const expr = expression(row, [...base, "_camino", meta.name.value], ["predicate"], meta, available);
|
const expr = expression(row, [...base, "_camino", meta.name.value], ["predicate"], meta, available);
|
||||||
children.push(
|
children.push(
|
||||||
@@ -385,6 +402,19 @@ export function relationalCompiler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const member = current.kind === "object" && relationships(current).find((m) => m.displayName === name);
|
||||||
|
if (member) {
|
||||||
|
const expr = expression(row, [...base, name, "_camino", "ref"], ["predicate"], field, available);
|
||||||
|
children.push(
|
||||||
|
...(comparisons(field.value, (operator, value) => ({
|
||||||
|
kind: "compare",
|
||||||
|
expression: expr,
|
||||||
|
operator,
|
||||||
|
value,
|
||||||
|
})) as QueryPredicate[]),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const valuePredicate = (names: string[], value: ValueNode) => {
|
const valuePredicate = (names: string[], value: ValueNode) => {
|
||||||
const expr = expression(row, names, ["predicate"], field, available);
|
const expr = expression(row, names, ["predicate"], field, available);
|
||||||
const type = expr.type.kind === "optional" ? expr.type.value : expr.type;
|
const type = expr.type.kind === "optional" ? expr.type.value : expr.type;
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ export function relationalWire(
|
|||||||
});
|
});
|
||||||
const predicate = (p: QueryPredicate | QueryAggregatePredicate): wire.QueryPredicate => {
|
const predicate = (p: QueryPredicate | QueryAggregatePredicate): wire.QueryPredicate => {
|
||||||
switch (p.kind) {
|
switch (p.kind) {
|
||||||
|
case "as":
|
||||||
|
return create(wire.QueryPredicateSchema, {
|
||||||
|
predicate: {
|
||||||
|
case: "as",
|
||||||
|
value: { interfaceRevisionId: p.interfaceRevisionId, predicate: predicate(p.predicate) },
|
||||||
|
},
|
||||||
|
});
|
||||||
case "and":
|
case "and":
|
||||||
case "or":
|
case "or":
|
||||||
return create(wire.QueryPredicateSchema, {
|
return create(wire.QueryPredicateSchema, {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GraphQLInputObjectType,
|
GraphQLInputObjectType,
|
||||||
GraphQLList,
|
GraphQLList,
|
||||||
GraphQLNonNull,
|
GraphQLNonNull,
|
||||||
|
visit,
|
||||||
type GraphQLInputType,
|
type GraphQLInputType,
|
||||||
type GraphQLOutputType,
|
type GraphQLOutputType,
|
||||||
type GraphQLInputFieldConfigMap,
|
type GraphQLInputFieldConfigMap,
|
||||||
@@ -54,6 +55,16 @@ export interface RelationalSchemaEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function relationalSchema(env: RelationalSchemaEnvironment, document?: DocumentNode) {
|
export function relationalSchema(env: RelationalSchemaEnvironment, document?: DocumentNode) {
|
||||||
|
const requestedInterfaces = new Set<string>();
|
||||||
|
if (document)
|
||||||
|
visit(document, {
|
||||||
|
ObjectField(node) {
|
||||||
|
if (node.name.value !== "_camino" || node.value.kind !== "ObjectValue") return;
|
||||||
|
const qualified = node.value.fields.find((field) => field.name.value === "as");
|
||||||
|
if (qualified?.value.kind === "ObjectValue")
|
||||||
|
for (const field of qualified.value.fields) requestedInterfaces.add(field.name.value);
|
||||||
|
},
|
||||||
|
});
|
||||||
const outputCache = new Map<string, GraphQLObjectType>();
|
const outputCache = new Map<string, GraphQLObjectType>();
|
||||||
const inputCache = new Map<string, GraphQLInputObjectType>();
|
const inputCache = new Map<string, GraphQLInputObjectType>();
|
||||||
const referenceCache = new Map<string, GraphQLScalarType>();
|
const referenceCache = new Map<string, GraphQLScalarType>();
|
||||||
@@ -303,14 +314,40 @@ export function relationalSchema(env: RelationalSchemaEnvironment, document?: Do
|
|||||||
not: { type: where(row) },
|
not: { type: where(row) },
|
||||||
};
|
};
|
||||||
for (const [name, field] of fields(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}`);
|
if (name in result) throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${name}`);
|
||||||
|
if (row.kind === "object" && field.kind === "row") {
|
||||||
|
result[name] = {
|
||||||
|
type: compare(
|
||||||
|
valueType.interfaceRef(
|
||||||
|
field.row.kind === "object"
|
||||||
|
? field.row.interfaceRevisionId
|
||||||
|
: (() => {
|
||||||
|
throw new Error("Expected relationship object");
|
||||||
|
})(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (field.kind === "leaf" && !comparableValue(field.type)) continue;
|
if (field.kind === "leaf" && !comparableValue(field.type)) continue;
|
||||||
result[name] = { type: field.kind === "row" ? where(field.row) : compare(field.type) };
|
result[name] = { type: field.kind === "row" ? where(field.row) : compare(field.type) };
|
||||||
}
|
}
|
||||||
result._camino = {
|
result._camino = {
|
||||||
type: inp("WhereMeta", row, () => {
|
type: inp("WhereMeta", row, () => {
|
||||||
const meta: GraphQLInputFieldConfigMap = {};
|
const meta: GraphQLInputFieldConfigMap = {};
|
||||||
|
if (row.kind === "object" && requestedInterfaces.size)
|
||||||
|
meta.as = {
|
||||||
|
type: inp("Interfaces", row, () =>
|
||||||
|
Object.fromEntries(
|
||||||
|
[...env.contracts.values()]
|
||||||
|
.filter((contract) => !contract.template && requestedInterfaces.has(env.name(contract.revisionId)))
|
||||||
|
.map((contract) => [
|
||||||
|
env.name(contract.revisionId),
|
||||||
|
{ type: where(objectRow(contract.revisionId)) },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
for (const [name, type] of leafFields(row)) meta[name] = { type: compare(type) };
|
for (const [name, type] of leafFields(row)) meta[name] = { type: compare(type) };
|
||||||
const edges = relations(row);
|
const edges = relations(row);
|
||||||
if (edges.length)
|
if (edges.length)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface QueryReduction {
|
|||||||
type: ValueType;
|
type: ValueType;
|
||||||
}
|
}
|
||||||
export type QueryPredicate =
|
export type QueryPredicate =
|
||||||
|
| { kind: "as"; interfaceRevisionId: InterfaceRevisionId; predicate: QueryPredicate }
|
||||||
| { kind: "and" | "or"; children: QueryPredicate[] }
|
| { kind: "and" | "or"; children: QueryPredicate[] }
|
||||||
| { kind: "not"; child: QueryPredicate }
|
| { kind: "not"; child: QueryPredicate }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
GraphQLNonNull,
|
GraphQLNonNull,
|
||||||
GraphQLSchema,
|
GraphQLSchema,
|
||||||
specifiedDirectives,
|
specifiedDirectives,
|
||||||
|
visit,
|
||||||
type GraphQLOutputType,
|
type GraphQLOutputType,
|
||||||
type GraphQLInputType,
|
type GraphQLInputType,
|
||||||
type GraphQLFieldConfigMap,
|
type GraphQLFieldConfigMap,
|
||||||
@@ -261,6 +262,19 @@ export function querySchema(
|
|||||||
};
|
};
|
||||||
const schema = new GraphQLSchema({
|
const schema = new GraphQLSchema({
|
||||||
directives: specifiedDirectives,
|
directives: specifiedDirectives,
|
||||||
|
// Nominal projections can mention an interface not reached by a relation.
|
||||||
|
types: (() => {
|
||||||
|
const requested = new Set<string>();
|
||||||
|
if (document)
|
||||||
|
visit(document, {
|
||||||
|
NamedType(node) {
|
||||||
|
requested.add(node.name.value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return [...contracts.values()]
|
||||||
|
.filter((contract) => !contract.template && requested.has(name(contract.revisionId)))
|
||||||
|
.map((contract) => object(contract.revisionId));
|
||||||
|
})(),
|
||||||
query: new GraphQLObjectType({
|
query: new GraphQLObjectType({
|
||||||
name: "QxQuery",
|
name: "QxQuery",
|
||||||
fields: { root: { type: new GraphQLNonNull(object(root.interface)) } },
|
fields: { root: { type: new GraphQLNonNull(object(root.interface)) } },
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ test("controller discovery requires complete bounded accepted-state membership",
|
|||||||
.length,
|
.length,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
assert.equal((await check("query Enabled {root {objects(all:64) {entries {node {enabled}}}}}")).length, 1);
|
||||||
for (const document of [
|
for (const document of [
|
||||||
"query Enabled {root {objects(first:64) {entries {node {_camino {ref}}}}}}",
|
"query Enabled {root {objects(first:64) {entries {node {_camino {ref}}}}}}",
|
||||||
"query Enabled {root {objects(all:65) {entries {node {_camino {ref}}}}}}",
|
"query Enabled {root {objects(all:65) {entries {node {_camino {ref}}}}}}",
|
||||||
"query Enabled {root {devices:objects(all:64) {entries {node {_camino {ref}}}}}}",
|
"query Enabled {root {devices:objects(all:64) {entries {node {_camino {ref}}}}}}",
|
||||||
"query Enabled {root {objects(all:64) {entries {node {enabled}}}}}",
|
|
||||||
"query Enabled($enabled:Boolean!) {root {objects(all:64,where:{enabled:{eq:$enabled}}) {entries {node {_camino {ref}}}}}}",
|
"query Enabled($enabled:Boolean!) {root {objects(all:64,where:{enabled:{eq:$enabled}}) {entries {node {_camino {ref}}}}}}",
|
||||||
])
|
])
|
||||||
await assert.rejects(check(document), /registry|Controller/);
|
await assert.rejects(check(document), /registry|Controller/);
|
||||||
|
|||||||
@@ -182,11 +182,36 @@ test("ordinary selections preserve aliases, fragment conditions and wire identit
|
|||||||
const node = checked.selection[0]!.selection[0]!.selection[0]!.selection[0]!;
|
const node = checked.selection[0]!.selection[0]!.selection[0]!.selection[0]!;
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
node.selection.map((s) => s.key),
|
node.selection.map((s) => s.key),
|
||||||
["plain", "label", "due"],
|
["plain", "label", "due", "_camino"],
|
||||||
);
|
);
|
||||||
assert.equal(node.selection[1]!.conditions.length, 1);
|
assert.equal(node.selection[1]!.conditions.length, 1);
|
||||||
assert.ok(JSON.stringify(checked.selection.map(querySelectionToWire)).includes("label"));
|
assert.ok(JSON.stringify(checked.selection.map(querySelectionToWire)).includes("label"));
|
||||||
});
|
});
|
||||||
|
test("object references are unconditional, canonical and idempotent through fragments", async () => {
|
||||||
|
const checked = await compile(
|
||||||
|
`query Upcoming($show: Boolean!) {root {items(first: 3) {entries {node {...Row @include(if: $show)}}}}}`,
|
||||||
|
`fragment Row on TaskFacts {label: title identity: _camino {alias: ref} _camino @include(if: $show) {ref}}`,
|
||||||
|
);
|
||||||
|
const root = checked.selection[0]!;
|
||||||
|
const node = root.selection.find((s) => s.name === "items")!.selection[0]!.selection[0]!;
|
||||||
|
for (const object of [root, node]) {
|
||||||
|
const metadata = object.selection.filter((s) => s.key === "_camino");
|
||||||
|
assert.equal(metadata.length, 1);
|
||||||
|
assert.deepEqual(metadata[0]!.conditions, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
metadata[0]!.selection.filter((s) => s.key === "ref").map((s) => s.conditions),
|
||||||
|
[[]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(node.selection.find((s) => s.key === "identity")!.conditions.length, 1);
|
||||||
|
const recompiled = await compileQuery(
|
||||||
|
{ ...checked.declaration, fragments: [] },
|
||||||
|
[collection, facts],
|
||||||
|
async () => checked.document,
|
||||||
|
);
|
||||||
|
assert.deepEqual(recompiled.selection, checked.selection);
|
||||||
|
assert.deepEqual(recompiled.output, checked.output);
|
||||||
|
});
|
||||||
test("retired live syntax fails with migration guidance", async () => {
|
test("retired live syntax fails with migration guidance", async () => {
|
||||||
for (const selected of ["score @live", "_camino @live {ref}", "title @live title", "title @live(unchecked: true)"])
|
for (const selected of ["score @live", "_camino @live {ref}", "title @live title", "title @live(unchecked: true)"])
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
@@ -199,6 +224,68 @@ test("retired live syntax fails with migration guidance", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("implicit references reject aliases which occupy the canonical metadata path", async () => {
|
||||||
|
for (const invalid of ["_camino: title", "_camino {ref: __typename}"])
|
||||||
|
await assert.rejects(compile(`query Upcoming {root {items(first: 3) {entries {node {...Row}}}}}`, `fragment Row on TaskFacts {${invalid}}`), /QUERY_VALIDATION|Introspection/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("implicit reference merges with conditional aggregate metadata", async () => {
|
||||||
|
const checked = await compileQuery({ ...fixture(""), fragments: [] }, [collection, facts], async () =>
|
||||||
|
`query Upcoming($show: Boolean!) {root {_camino @include(if: $show) {relations {items {aggregate {count}}}}}}`);
|
||||||
|
const metadata = checked.selection[0]!.selection.filter((field) => field.key === "_camino");
|
||||||
|
assert.equal(metadata.length, 1);
|
||||||
|
assert.deepEqual(metadata[0]!.conditions, []);
|
||||||
|
assert.equal(metadata[0]!.selection.find((field) => field.name === "relations")!.conditions.length, 1);
|
||||||
|
assert.deepEqual(metadata[0]!.selection.find((field) => field.name === "ref")!.conditions, []);
|
||||||
|
assert.equal(metadata[0]!.selection.find((field) => field.name === "relations")!.selection[0]!.selection[0]!.selection.some((field) => field.name === "_camino"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("qualified interface predicates and guaranteed inline projections retain nominal reads", async () => {
|
||||||
|
const owner = iface(
|
||||||
|
`interface Owner id "owner" revision "owner@1" { queryable value name id "name" : string {get id "name:get";} }`,
|
||||||
|
);
|
||||||
|
const owned = iface(
|
||||||
|
`import interface Owner;
|
||||||
|
interface Owned id "owned" revision "owned@1" {
|
||||||
|
queryable relation owner id "owner" : optional-one interface Owner {resolve id "owner:resolve";}
|
||||||
|
queryable value label id "owned:label" : string {get id "owned:label:get";}
|
||||||
|
}`,
|
||||||
|
[owner],
|
||||||
|
);
|
||||||
|
const item = iface(
|
||||||
|
`import interface Owned;
|
||||||
|
interface Item id "item" revision "item@1" requires Owned {
|
||||||
|
queryable value label id "item:label" : string {get id "item:label:get";}
|
||||||
|
}`,
|
||||||
|
[owned, owner],
|
||||||
|
);
|
||||||
|
const declaration = {
|
||||||
|
...fixture(""),
|
||||||
|
root: { kind: "workspace-interface" as const, interface: item.revisionId },
|
||||||
|
fragments: [],
|
||||||
|
};
|
||||||
|
const document = `query Upcoming($owner: QxRef_Owner!) {root {objects(all: 10, where: {_camino: {as: {Owned: {owner: {eq: $owner}}}}}) {entries {node {label ... on Owned {owner {name} ownerLabel: label}}}}}}`;
|
||||||
|
const checked = await compileQuery(declaration, [item, owned, owner], async () => document);
|
||||||
|
assert.ok(
|
||||||
|
checked.effects.some(
|
||||||
|
(effect) =>
|
||||||
|
effect.interfaceRevisionId === owned.revisionId &&
|
||||||
|
effect.memberId === "owner" &&
|
||||||
|
effect.uses.includes("predicate"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const objects = checked.selection[0]!.selection[0]!;
|
||||||
|
assert.equal(objects.predicate?.kind, "and");
|
||||||
|
assert.ok(JSON.stringify(objects.predicate).includes('"kind":"as"'));
|
||||||
|
const node = objects.selection[0]!.selection[0]!;
|
||||||
|
assert.equal(node.selection.find((s) => s.key === "ownerLabel")!.interfaceRevisionId, owned.revisionId);
|
||||||
|
assert.equal(node.selection.filter((s) => s.key === "_camino").length, 1);
|
||||||
|
await assert.rejects(
|
||||||
|
compileQuery(declaration, [item, owned, owner], async () => document.replace("ownerLabel: label", "label")),
|
||||||
|
/QUERY_PROJECTION_CONFLICT|QUERY_VALIDATION/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("query dependency ports resolve exact exports without declaration ordering constraints", () => {
|
test("query dependency ports resolve exact exports without declaration ordering constraints", () => {
|
||||||
const result = compileCapabilityResourceSource(
|
const result = compileCapabilityResourceSource(
|
||||||
`import interface Tasks;
|
`import interface Tasks;
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ test("record/list query values preserve nested projections and leaf predicates",
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const raw = JSON.stringify(checked.output);
|
const raw = JSON.stringify(checked.output);
|
||||||
|
const node = extent.selection[0]!.selection[0]!;
|
||||||
|
assert.equal(node.selection.filter((field) => field.key === "_camino").length, 1);
|
||||||
|
const assertValuesOnly = (fields: typeof node.selection): void => {
|
||||||
|
for (const field of fields) {
|
||||||
|
assert.notEqual(field.name, "_camino", "Structured values do not acquire graph identity");
|
||||||
|
assertValuesOnly(field.selection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assertValuesOnly(node.selection.filter((field) => field.key !== "_camino"));
|
||||||
|
assert.equal(checked.selection[0]!.selection.some((field) => field.key === "_camino"), false);
|
||||||
|
assert.equal(extent.selection.some((field) => field.key === "_camino"), false);
|
||||||
assert.match(raw, /"begin"/);
|
assert.match(raw, /"begin"/);
|
||||||
assert.match(raw, /"tags":\{"kind":"list"/);
|
assert.match(raw, /"tags":\{"kind":"list"/);
|
||||||
assert.equal(checked.effects.filter((e) => e.memberId === "range").length, 1);
|
assert.equal(checked.effects.filter((e) => e.memberId === "range").length, 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user