121 lines
5.7 KiB
TypeScript
121 lines
5.7 KiB
TypeScript
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;
|
||
|
|
}
|