Add exported query fragments and scoped coverage observations
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { parse, print, visit, type DocumentNode, type FragmentDefinitionNode } from "graphql";
|
||||
import { QueryCompileError, type CheckedQuery, type QueryDeclaration, type QuerySelection } from "./types.js";
|
||||
|
||||
export type FragmentResolver = (packageRevisionId: string, fragmentId: string) => Promise<CheckedQuery>;
|
||||
/** Expand only explicitly imported exports. Each variable mapping is checked by
|
||||
* GraphQL against the consuming operation, rather than silently inheriting names. */
|
||||
export async function fragmentSources(
|
||||
declaration: QueryDeclaration,
|
||||
definitions: DocumentNode["definitions"][number][],
|
||||
resolve?: FragmentResolver,
|
||||
) {
|
||||
const markers = new Map<string, NonNullable<QuerySelection["fragmentSpread"]>>();
|
||||
for (const imported of declaration.importedFragments ?? []) {
|
||||
if (!resolve)
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_RESOLVER_REQUIRED",
|
||||
"Resolve imported fragments through package dependencies",
|
||||
);
|
||||
const checked = await resolve(imported.packageRevisionId, imported.fragmentId);
|
||||
const exported = checked.declaration.fragment;
|
||||
if (!exported || exported.admission) throw new QueryCompileError("NOT_EXPORTED_FRAGMENT", imported.fragmentId);
|
||||
const variables = checked.variables.kind === "record" ? Object.keys(checked.variables.fields) : [];
|
||||
if (JSON.stringify(Object.keys(imported.variables).sort()) !== JSON.stringify(variables.sort()))
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_VARIABLE_MAPPING",
|
||||
`Map every variable of ${checked.declaration.displayName} explicitly`,
|
||||
);
|
||||
const source = parse(checked.document);
|
||||
const sourceOperation = source.definitions.find(
|
||||
(node) => node.kind === "OperationDefinition" && node.name?.value === checked.declaration.operation,
|
||||
);
|
||||
const consumingOperation = definitions.find(
|
||||
(node) => node.kind === "OperationDefinition" && node.name?.value === declaration.operation,
|
||||
);
|
||||
for (const variable of sourceOperation?.kind === "OperationDefinition"
|
||||
? (sourceOperation.variableDefinitions ?? [])
|
||||
: []) {
|
||||
const name = imported.variables[variable.variable.name.value];
|
||||
const target =
|
||||
consumingOperation?.kind === "OperationDefinition"
|
||||
? consumingOperation.variableDefinitions?.find((v) => v.variable.name.value === name)
|
||||
: undefined;
|
||||
if (!target || print(target.type) !== print(variable.type))
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_VARIABLE_TYPE",
|
||||
`Mapped variable ${name} must have type ${print(variable.type)}`,
|
||||
);
|
||||
}
|
||||
const fragments = source.definitions.filter(
|
||||
(node): node is FragmentDefinitionNode => node.kind === "FragmentDefinition",
|
||||
);
|
||||
if (definitions.some((node) => "name" in node && node.name?.value === exported.name))
|
||||
throw new QueryCompileError("FRAGMENT_NAME_COLLISION", exported.name);
|
||||
const privateNames = new Map(
|
||||
fragments.map((node) => [
|
||||
node.name.value,
|
||||
node.name.value === exported.name ? exported.name : `${exported.name}__${node.name.value}`,
|
||||
]),
|
||||
);
|
||||
for (const fragment of fragments) {
|
||||
definitions.push(
|
||||
visit(fragment, {
|
||||
FragmentDefinition(node) {
|
||||
return { ...node, name: { ...node.name, value: privateNames.get(node.name.value)! } };
|
||||
},
|
||||
FragmentSpread(node) {
|
||||
return { ...node, name: { ...node.name, value: privateNames.get(node.name.value) ?? node.name.value } };
|
||||
},
|
||||
Variable(node) {
|
||||
return { ...node, name: { ...node.name, value: imported.variables[node.name.value] ?? node.name.value } };
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
markers.set(exported.name, {
|
||||
package: imported.packageRevisionId,
|
||||
fragment: imported.fragmentId,
|
||||
digest: checked.definitionDigest,
|
||||
root: checked.declaration.root.interface,
|
||||
variables: imported.variables,
|
||||
});
|
||||
for (const [name, contract] of Object.entries(checked.fragmentContracts ?? {})) {
|
||||
markers.set(privateNames.get(name) ?? name, {
|
||||
...contract,
|
||||
variables: Object.fromEntries(
|
||||
Object.entries(contract.variables).map(([inner, outer]) => [inner, imported.variables[outer] ?? outer]),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (declaration.fragment) {
|
||||
const exported = declaration.fragment;
|
||||
if (!definitions.some((node) => node.kind === "FragmentDefinition" && node.name.value === exported.name))
|
||||
throw new QueryCompileError("FRAGMENT_MISSING", exported.name);
|
||||
const operations = definitions.filter((node) => node.kind === "OperationDefinition");
|
||||
if (!exported.operation) {
|
||||
if (operations.length)
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_OPERATION_REQUIRED",
|
||||
"Name the standalone operation explicitly when a fragment document includes variables or an operation",
|
||||
);
|
||||
definitions.push(...parse(`query ${declaration.operation} { root { ...${exported.name} } }`).definitions);
|
||||
} else {
|
||||
const operation = operations.find(
|
||||
(node) => node.kind === "OperationDefinition" && node.name?.value === exported.operation,
|
||||
);
|
||||
if (!operation || operation.kind !== "OperationDefinition")
|
||||
throw new QueryCompileError("FRAGMENT_OPERATION_MISSING", exported.operation);
|
||||
// The standalone operation specifies variables only. Its result must be the
|
||||
// exact exported fragment, not additional fields or a different window.
|
||||
const expected = parse(`query ${declaration.operation} { root { ...${exported.name} } }`).definitions[0]!;
|
||||
if (expected.kind !== "OperationDefinition" || print(operation.selectionSet) !== print(expected.selectionSet))
|
||||
throw new QueryCompileError(
|
||||
"FRAGMENT_STANDALONE_SHAPE",
|
||||
"Standalone operation must select root { ...ExportedFragment }",
|
||||
);
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
Reference in New Issue
Block a user