@apollo/federation-internals
Version:
Apollo Federation internal utilities
1,282 lines (1,124 loc) • 167 kB
text/typescript
import {
ArgumentNode,
ASTNode,
DefinitionNode,
DirectiveNode,
DocumentNode,
FieldNode,
FragmentDefinitionNode,
FragmentSpreadNode,
InlineFragmentNode,
Kind,
OperationDefinitionNode,
parse,
SelectionNode,
SelectionSetNode,
OperationTypeNode,
NameNode,
} from "graphql";
import {
baseType,
Directive,
DirectiveTargetElement,
FieldDefinition,
isCompositeType,
isInterfaceType,
isNullableType,
runtimeTypesIntersects,
Schema,
SchemaRootKind,
VariableCollector,
VariableDefinitions,
variableDefinitionsFromAST,
CompositeType,
typenameFieldName,
sameDirectiveApplications,
isConditionalDirective,
isDirectiveApplicationsSubset,
isAbstractType,
DeferDirectiveArgs,
Variable,
possibleRuntimeTypes,
Type,
sameDirectiveApplication,
isLeafType,
Variables,
isObjectType,
NamedType,
isUnionType,
directivesToString,
directivesToDirectiveNodes,
} from "./definitions";
import { federationMetadata, isFederationDirectiveDefinedInSchema, isInterfaceObjectType } from "./federation";
import { ERRORS } from "./error";
import { isSubtype, sameType, typesCanBeMerged } from "./types";
import { assert, mapKeys, mapValues, MapWithCachedArrays, MultiMap, SetMultiMap } from "./utils";
import { argumentsEquals, argumentsFromAST, isValidValue, valueToAST, valueToString } from "./values";
import { v1 as uuidv1 } from 'uuid';
export const DEFAULT_MIN_USAGES_TO_OPTIMIZE = 2;
function validate(condition: any, message: () => string, sourceAST?: ASTNode): asserts condition {
if (!condition) {
throw ERRORS.INVALID_GRAPHQL.err(message(), { nodes: sourceAST });
}
}
function haveSameDirectives<TElement extends OperationElement>(op1: TElement, op2: TElement): boolean {
return sameDirectiveApplications(op1.appliedDirectives, op2.appliedDirectives);
}
abstract class AbstractOperationElement<T extends AbstractOperationElement<T>> extends DirectiveTargetElement<T> {
private attachments?: Map<string, string>;
constructor(
schema: Schema,
directives?: readonly Directive<any>[],
) {
super(schema, directives);
}
collectVariables(collector: VariableCollector) {
this.collectVariablesInElement(collector);
this.collectVariablesInAppliedDirectives(collector);
}
abstract key(): string;
abstract asPathElement(): string | undefined;
abstract rebaseOn(args: { parentType: CompositeType, errorIfCannotRebase: boolean }): T | undefined;
rebaseOnOrError(parentType: CompositeType): T {
return this.rebaseOn({ parentType, errorIfCannotRebase: true })!;
}
abstract withUpdatedDirectives(newDirectives: readonly Directive<any>[]): T;
protected abstract collectVariablesInElement(collector: VariableCollector): void;
addAttachment(key: string, value: string) {
if (!this.attachments) {
this.attachments = new Map();
}
this.attachments.set(key, value);
}
getAttachment(key: string): string | undefined {
return this.attachments?.get(key);
}
protected copyAttachmentsTo(elt: AbstractOperationElement<any>) {
if (this.attachments) {
for (const [k, v] of this.attachments.entries()) {
elt.addAttachment(k, v);
}
}
}
protected keyForDirectives(): string {
return this.appliedDirectives.map((d) => keyForDirective(d)).join(' ');
}
}
export class Field<TArgs extends {[key: string]: any} = {[key: string]: any}> extends AbstractOperationElement<Field<TArgs>> {
readonly kind = 'Field' as const;
constructor(
readonly definition: FieldDefinition<CompositeType>,
readonly args?: TArgs,
directives?: readonly Directive<any>[],
readonly alias?: string,
) {
super(definition.schema(), directives);
}
protected collectVariablesInElement(collector: VariableCollector): void {
if (this.args) {
collector.collectInArguments(this.args);
}
}
get name(): string {
return this.definition.name;
}
argumentValue(name: string): any {
return this.args ? this.args[name] : undefined;
}
responseName(): string {
return this.alias ? this.alias : this.name;
}
key(): string {
return this.responseName() + this.keyForDirectives();
}
asPathElement(): string {
return this.responseName();
}
get parentType(): CompositeType {
return this.definition.parent;
}
isLeafField(): boolean {
return isLeafType(this.baseType());
}
baseType(): NamedType {
return baseType(this.definition.type!);
}
copy(): Field<TArgs> {
const newField = new Field<TArgs>(
this.definition,
this.args,
this.appliedDirectives,
this.alias,
);
this.copyAttachmentsTo(newField);
return newField;
}
withUpdatedArguments(newArgs: TArgs): Field<TArgs> {
const newField = new Field<TArgs>(
this.definition,
{ ...this.args, ...newArgs },
this.appliedDirectives,
this.alias,
);
this.copyAttachmentsTo(newField);
return newField;
}
withUpdatedDefinition(newDefinition: FieldDefinition<any>): Field<TArgs> {
const newField = new Field<TArgs>(
newDefinition,
this.args,
this.appliedDirectives,
this.alias,
);
this.copyAttachmentsTo(newField);
return newField;
}
withUpdatedAlias(newAlias: string | undefined): Field<TArgs> {
const newField = new Field<TArgs>(
this.definition,
this.args,
this.appliedDirectives,
newAlias,
);
this.copyAttachmentsTo(newField);
return newField;
}
withUpdatedDirectives(newDirectives: readonly Directive<any>[]): Field<TArgs> {
const newField = new Field<TArgs>(
this.definition,
this.args,
newDirectives,
this.alias,
);
this.copyAttachmentsTo(newField);
return newField;
}
argumentsToNodes(): ArgumentNode[] | undefined {
if (!this.args) {
return undefined;
}
const entries = Object.entries(this.args);
if (entries.length === 0) {
return undefined;
}
return entries.map(([n, v]) => {
return {
kind: Kind.ARGUMENT,
name: { kind: Kind.NAME, value: n },
value: valueToAST(v, this.definition.argument(n)!.type!)!,
};
});
}
selects(
definition: FieldDefinition<any>,
assumeValid: boolean = false,
variableDefinitions?: VariableDefinitions,
contextualArguments?: string[],
): boolean {
assert(assumeValid || variableDefinitions, 'Must provide variable definitions if validation is needed');
// We've already validated that the field selects the definition on which it was built.
if (definition === this.definition) {
return true;
}
// This code largely mirrors validate, so we could generalize that and return false on exception, but this
// method is called fairly often and that has been shown to impact performance quite a lot. So a little
// bit of code duplication is ok.
if (this.name !== definition.name) {
return false;
}
// We need to make sure the field has valid values for every non-optional argument.
for (const argDef of definition.arguments()) {
const appliedValue = this.argumentValue(argDef.name);
if (appliedValue === undefined) {
if (argDef.defaultValue === undefined && !isNullableType(argDef.type!) && (!contextualArguments || !contextualArguments?.includes(argDef.name))) {
return false;
}
} else {
if (!assumeValid && !isValidValue(appliedValue, argDef, variableDefinitions!)) {
return false;
}
}
}
// We also make sure the field application does not have non-null values for field that are not part of the definition.
if (!assumeValid && this.args) {
for (const [name, value] of Object.entries(this.args)) {
if (value !== null && definition.argument(name) === undefined) {
return false
}
}
}
return true;
}
validate(variableDefinitions: VariableDefinitions, validateContextualArgs: boolean) {
validate(this.name === this.definition.name, () => `Field name "${this.name}" cannot select field "${this.definition.coordinate}: name mismatch"`);
// We need to make sure the field has valid values for every non-optional argument.
for (const argDef of this.definition.arguments()) {
const appliedValue = this.argumentValue(argDef.name);
let isContextualArg = false;
const schema = this.definition.schema();
const fromContextDirective = federationMetadata(schema)?.fromContextDirective();
if (fromContextDirective && isFederationDirectiveDefinedInSchema(fromContextDirective)) {
isContextualArg = argDef.appliedDirectivesOf(fromContextDirective).length > 0;
}
if (appliedValue === undefined) {
validate(
(isContextualArg && !validateContextualArgs) || argDef.defaultValue !== undefined || isNullableType(argDef.type!),
() => `Missing mandatory value for argument "${argDef.name}" of field "${this.definition.coordinate}" in selection "${this}"`);
} else {
validate(
(isContextualArg && !validateContextualArgs) || isValidValue(appliedValue, argDef, variableDefinitions),
() => `Invalid value ${valueToString(appliedValue)} for argument "${argDef.coordinate}" of type ${argDef.type}`)
}
}
// We also make sure the field application does not have non-null values for field that are not part of the definition.
if (this.args) {
for (const [name, value] of Object.entries(this.args)) {
validate(
value === null || this.definition.argument(name) !== undefined,
() => `Unknown argument "${name}" in field application of "${this.name}"`);
}
}
}
rebaseOn({ parentType, errorIfCannotRebase }: { parentType: CompositeType, errorIfCannotRebase: boolean }): Field<TArgs> | undefined {
const fieldParent = this.definition.parent;
if (parentType === fieldParent) {
return this;
}
if (this.name === typenameFieldName) {
if (possibleRuntimeTypes(parentType).some((runtimeType) => isInterfaceObjectType(runtimeType))) {
validate(
!errorIfCannotRebase,
() => `Cannot add selection of field "${this.definition.coordinate}" to selection set of parent type "${parentType}" that is potentially an interface object type at runtime`
);
return undefined;
} else {
return this.withUpdatedDefinition(parentType.typenameField()!);
}
}
const fieldDef = parentType.field(this.name);
const canRebase = this.canRebaseOn(parentType) && fieldDef;
if (!canRebase) {
validate(
!errorIfCannotRebase,
() => `Cannot add selection of field "${this.definition.coordinate}" to selection set of parent type "${parentType}"`
);
return undefined;
}
return this.withUpdatedDefinition(fieldDef);
}
private canRebaseOn(parentType: CompositeType) {
const fieldParentType = this.definition.parent
// There is 2 valid cases we want to allow:
// 1. either `selectionParent` and `fieldParent` are the same underlying type (same name) but from different underlying schema. Typically,
// happens when we're building subgraph queries but using selections from the original query which is against the supergraph API schema.
// 2. or they are not the same underlying type, but the field parent type is from an interface (or an interface object, which is the same
// here), in which case we may be rebasing an interface field on one of the implementation type, which is ok. Note that we don't verify
// that `parentType` is indeed an implementation of `fieldParentType` because it's possible that this implementation relationship exists
// in the supergraph, but not in any of the subgraph schema involved here. So we just let it be. Not that `rebaseOn` will complain anyway
// if the field name simply does not exists in `parentType`.
return parentType.name === fieldParentType.name
|| isInterfaceType(fieldParentType)
|| isInterfaceObjectType(fieldParentType);
}
typeIfAddedTo(parentType: CompositeType): Type | undefined {
const fieldParentType = this.definition.parent;
if (parentType == fieldParentType) {
return this.definition.type;
}
if (this.name === typenameFieldName) {
return parentType.typenameField()?.type;
}
const returnType = this.canRebaseOn(parentType)
? parentType.field(this.name)?.type
: undefined;
// If the field has an argument with fromContextDirective on it. We should not rebase it.
const fromContextDirective = federationMetadata(parentType.schema())?.fromContextDirective();
if (fromContextDirective && isFederationDirectiveDefinedInSchema(fromContextDirective)) {
const fieldInParent = parentType.field(this.name);
if (fieldInParent && fieldInParent.arguments()
.some(arg => arg.appliedDirectivesOf(fromContextDirective).length > 0 && (!this.args || this.args[arg.name] === undefined))
) {
return undefined;
}
}
return returnType;
}
hasDefer(): boolean {
// @defer cannot be on field at the moment
return false;
}
deferDirectiveArgs(): undefined {
// @defer cannot be on field at the moment (but exists so we can call this method on any `OperationElement` conveniently)
return undefined;
}
withoutDefer(): Field<TArgs> {
// @defer cannot be on field at the moment
return this;
}
equals(that: OperationElement): boolean {
if (this === that) {
return true;
}
return that.kind === 'Field'
&& this.name === that.name
&& this.alias === that.alias
&& (this.args ? that.args && argumentsEquals(this.args, that.args) : !that.args)
&& haveSameDirectives(this, that);
}
toString(): string {
const alias = this.alias ? this.alias + ': ' : '';
const entries = this.args ? Object.entries(this.args) : [];
const args = entries.length === 0
? ''
: '(' + entries.map(([n, v]) => `${n}: ${valueToString(v, this.definition.argument(n)?.type)}`).join(', ') + ')';
return alias + this.name + args + this.appliedDirectivesToString();
}
}
/**
* Computes a string key representing a directive application, so that if 2 directive applications have the same key, then they
* represent the same application.
*
* Note that this is mostly just the `toString` representation of the directive, but for 2 subtlety:
* 1. for a handful of directives (really just `@defer` for now), we never want to consider directive applications the same, no
* matter that the arguments of the directive match, and this for the same reason as documented on the `sameDirectiveApplications`
* method in `definitions.ts`.
* 2. we sort the argument (by their name) before converting them to string, since argument order does not matter in graphQL.
*/
function keyForDirective(
directive: Directive<AbstractOperationElement<any>>,
directivesNeverEqualToThemselves: string[] = [ 'defer' ],
): string {
if (directivesNeverEqualToThemselves.includes(directive.name)) {
return uuidv1();
}
const entries = Object.entries(directive.arguments()).filter(([_, v]) => v !== undefined);
entries.sort(([n1], [n2]) => n1.localeCompare(n2));
const args = entries.length == 0 ? '' : '(' + entries.map(([n, v]) => `${n}: ${valueToString(v, directive.argumentType(n))}`).join(', ') + ')';
return `@${directive.name}${args}`;
}
export class FragmentElement extends AbstractOperationElement<FragmentElement> {
readonly kind = 'FragmentElement' as const;
readonly typeCondition?: CompositeType;
private computedKey: string | undefined;
constructor(
private readonly sourceType: CompositeType,
typeCondition?: string | CompositeType,
directives?: readonly Directive<any>[],
) {
// TODO: we should do some validation here (remove the ! with proper error, and ensure we have some intersection between
// the source type and the type condition)
super(sourceType.schema(), directives);
this.typeCondition = typeCondition !== undefined && typeof typeCondition === 'string'
? this.schema().type(typeCondition)! as CompositeType
: typeCondition;
}
protected collectVariablesInElement(_: VariableCollector): void {
// Cannot have variables in fragments
}
get parentType(): CompositeType {
return this.sourceType;
}
key(): string {
if (!this.computedKey) {
// The key is such that 2 fragments with the same key within a selection set gets merged together. So the type-condition
// is include, but so are the directives.
this.computedKey = '...' + (this.typeCondition ? ' on ' + this.typeCondition.name : '') + this.keyForDirectives();
}
return this.computedKey;
}
castedType(): CompositeType {
return this.typeCondition ? this.typeCondition : this.sourceType;
}
asPathElement(): string | undefined {
const condition = this.typeCondition;
return condition ? `... on ${condition}` : undefined;
}
withUpdatedSourceType(newSourceType: CompositeType): FragmentElement {
return this.withUpdatedTypes(newSourceType, this.typeCondition);
}
withUpdatedCondition(newCondition: CompositeType | undefined): FragmentElement {
return this.withUpdatedTypes(this.sourceType, newCondition);
}
withUpdatedTypes(newSourceType: CompositeType, newCondition: CompositeType | undefined): FragmentElement {
// Note that we pass the type-condition name instead of the type itself, to ensure that if `newSourceType` was from a different
// schema (typically, the supergraph) than `this.sourceType` (typically, a subgraph), then the new condition uses the
// definition of the proper schema (the supergraph in such cases, instead of the subgraph).
const newFragment = new FragmentElement(newSourceType, newCondition?.name, this.appliedDirectives);
this.copyAttachmentsTo(newFragment);
return newFragment;
}
withUpdatedDirectives(newDirectives: Directive<OperationElement>[]): FragmentElement {
const newFragment = new FragmentElement(this.sourceType, this.typeCondition, newDirectives);
this.copyAttachmentsTo(newFragment);
return newFragment;
}
rebaseOn({ parentType, errorIfCannotRebase }: { parentType: CompositeType, errorIfCannotRebase: boolean }): FragmentElement | undefined {
const fragmentParent = this.parentType;
const typeCondition = this.typeCondition;
if (parentType === fragmentParent) {
return this;
}
// This usually imply that the fragment is not from the same sugraph than then selection. So we need
// to update the source type of the fragment, but also "rebase" the condition to the selection set
// schema.
const { canRebase, rebasedCondition } = this.canRebaseOn(parentType);
if (!canRebase) {
validate(
!errorIfCannotRebase,
() => `Cannot add fragment of condition "${typeCondition}" (runtimes: [${possibleRuntimeTypes(typeCondition!)}]) to parent type "${parentType}" (runtimes: ${possibleRuntimeTypes(parentType)})`
);
return undefined;
}
return this.withUpdatedTypes(parentType, rebasedCondition);
}
private canRebaseOn(parentType: CompositeType): { canRebase: boolean, rebasedCondition?: CompositeType } {
if (!this.typeCondition) {
return { canRebase: true, rebasedCondition: undefined };
}
const rebasedCondition = parentType.schema().type(this.typeCondition.name);
if (!rebasedCondition || !isCompositeType(rebasedCondition) || !runtimeTypesIntersects(parentType, rebasedCondition)) {
return { canRebase: false };
}
return { canRebase: true, rebasedCondition };
}
castedTypeIfAddedTo(parentType: CompositeType): CompositeType | undefined {
if (parentType == this.parentType) {
return this.castedType();
}
const { canRebase, rebasedCondition } = this.canRebaseOn(parentType);
return canRebase ? (rebasedCondition ? rebasedCondition : parentType) : undefined;
}
hasDefer(): boolean {
return this.hasAppliedDirective('defer');
}
hasStream(): boolean {
return this.hasAppliedDirective('stream');
}
deferDirectiveArgs(): DeferDirectiveArgs | undefined {
// Note: @defer is not repeatable, so the return array below is either empty, or has a single value.
return this.appliedDirectivesOf(this.schema().deferDirective())[0]?.arguments();
}
/**
* Returns this fragment element but with any @defer directive on it removed.
*
* This method will return `undefined` if, upon removing @defer, the fragment has no conditions nor
* any remaining applied directives (meaning that it carries no information whatsoever and can be
* ignored).
*/
withoutDefer(): FragmentElement | undefined {
const deferName = this.schema().deferDirective().name;
const updatedDirectives = this.appliedDirectives.filter((d) => d.name !== deferName);
if (!this.typeCondition && updatedDirectives.length === 0) {
return undefined;
}
if (updatedDirectives.length === this.appliedDirectives.length) {
return this;
}
const updated = new FragmentElement(this.sourceType, this.typeCondition, updatedDirectives);
this.copyAttachmentsTo(updated);
return updated;
}
/**
* Returns this fragment element, but it is has a @defer directive, the element is returned with
* the @defer "normalized".
*
* See `Operation.withNormalizedDefer` for details on our so-called @defer normalization.
*/
withNormalizedDefer(normalizer: DeferNormalizer): FragmentElement | undefined {
const deferArgs = this.deferDirectiveArgs();
if (!deferArgs) {
return this;
}
let newDeferArgs: DeferDirectiveArgs | undefined = undefined;
let conditionVariable: Variable | undefined = undefined;
if (deferArgs.if !== undefined) {
if (typeof deferArgs.if === 'boolean') {
if (deferArgs.if) {
// Harcoded `if: true`, remove the `if`
newDeferArgs = {
...deferArgs,
if: undefined,
}
} else {
// Harcoded `if: false`, remove the @defer altogether
return this.withoutDefer();
}
} else {
// `if` on a variable
conditionVariable = deferArgs.if;
}
}
let label = deferArgs.label;
if (!label) {
label = normalizer.newLabel();
if (newDeferArgs) {
newDeferArgs.label = label;
} else {
newDeferArgs = {
...deferArgs,
label,
}
}
}
// Now that we are sure to have a label, if we had a (non-trivial) condition,
// associate it to that label.
if (conditionVariable) {
normalizer.registerCondition(label, conditionVariable);
}
if (!newDeferArgs) {
return this;
}
const deferDirective = this.schema().deferDirective();
const updatedDirectives = this.appliedDirectives
.filter((d) => d.name !== deferDirective.name)
.concat(new Directive<FragmentElement>(deferDirective.name, newDeferArgs));
const updated = new FragmentElement(this.sourceType, this.typeCondition, updatedDirectives);
this.copyAttachmentsTo(updated);
return updated;
}
equals(that: OperationElement): boolean {
if (this === that) {
return true;
}
return that.kind === 'FragmentElement'
&& this.typeCondition?.name === that.typeCondition?.name
&& haveSameDirectives(this, that);
}
toString(): string {
return '...' + (this.typeCondition ? ' on ' + this.typeCondition : '') + this.appliedDirectivesToString();
}
}
export type OperationElement = Field<any> | FragmentElement;
export type OperationPath = OperationElement[];
export function operationPathToStringPath(path: OperationPath): string[] {
return path
.filter((p) => !(p.kind === 'FragmentElement' && !p.typeCondition))
.map((p) => p.kind === 'Field' ? p.responseName() : `... on ${p.typeCondition?.coordinate}`);
}
export function sameOperationPaths(p1: OperationPath, p2: OperationPath): boolean {
if (p1 === p2) {
return true;
}
if (p1.length !== p2.length) {
return false;
}
for (let i = 0; i < p1.length; i++) {
if (!p1[i].equals(p2[i])) {
return false;
}
}
return true;
}
/**
* Returns all the "conditional" directive applications (`@skip` and `@include`) in the provided path.
*/
export function conditionalDirectivesInOperationPath(path: OperationPath): Directive<any, any>[] {
return path.map((e) => e.appliedDirectives).flat().filter((d) => isConditionalDirective(d));
}
export function concatOperationPaths(head: OperationPath, tail: OperationPath): OperationPath {
// While this is mainly a simple array concatenation, we optimize slightly by recognizing if the
// tail path starts by a fragment selection that is useless given the end of the head path.
if (head.length === 0) {
return tail;
}
if (tail.length === 0) {
return head;
}
const lastOfHead = head[head.length - 1];
const conditionals = conditionalDirectivesInOperationPath(head);
let firstOfTail = tail[0];
// Note that in practice, we may be able to eliminate a few elements at the beginning of the path
// due do conditionals ('@skip' and '@include'). Indeed, a (tail) path crossing multiple conditions
// may start with: [ ... on X @include(if: $c1), ... on X @ksip(if: $c2), (...)], but if `head`
// already ends on type `X` _and_ both the conditions on `$c1` and `$c2` are alredy found on `head`,
// then we can remove both fragments in `tail`.
while (firstOfTail && isUselessFollowupElement(lastOfHead, firstOfTail, conditionals)) {
tail = tail.slice(1);
firstOfTail = tail[0];
}
return head.concat(tail);
}
function isUselessFollowupElement(first: OperationElement, followup: OperationElement, conditionals: Directive<any, any>[]): boolean {
const typeOfFirst = first.kind === 'Field'
? first.baseType()
: first.typeCondition;
// The followup is useless if it's a fragment (with no directives we would want to preserve) whose type
// is already that of the first element (or a supertype).
return !!typeOfFirst
&& followup.kind === 'FragmentElement'
&& !!followup.typeCondition
&& (followup.appliedDirectives.length === 0 || isDirectiveApplicationsSubset(conditionals, followup.appliedDirectives))
&& isSubtype(followup.typeCondition, typeOfFirst);
}
export type RootOperationPath = {
rootKind: SchemaRootKind,
path: OperationPath
}
// Computes for every fragment, which other fragments use it (so the reverse of it's dependencies, the other fragment it uses).
function computeFragmentsDependents(fragments: NamedFragments): SetMultiMap<string, string> {
const reverseDeps = new SetMultiMap<string, string>();
for (const fragment of fragments.definitions()) {
for (const dependency of fragment.fragmentUsages().keys()) {
reverseDeps.add(dependency, fragment.name);
}
}
return reverseDeps;
}
function clearKeptFragments(
usages: Map<string, number>,
fragments: NamedFragments,
minUsagesToOptimize: number
) {
// `toCheck` will contain only fragments that we know we want to keep (but haven't handled/removed from `usages` yet).
let toCheck = Array.from(usages.entries()).filter(([_, count]) => count >= minUsagesToOptimize).map(([name, _]) => name);
while (toCheck.length > 0) {
const newToCheck = [];
for (const name of toCheck) {
// We "keep" that fragment so clear it.
usages.delete(name);
// But as it is used, bump the usage for every fragment it uses.
const ownUsages = fragments.get(name)!.fragmentUsages();
for (const [otherName, otherCount] of ownUsages.entries()) {
const prevCount = usages.get(otherName);
// We're interested in fragment not in `usages` anymore.
if (prevCount !== undefined) {
const newCount = prevCount + otherCount;
usages.set(otherName, newCount);
if (prevCount < minUsagesToOptimize && newCount >= minUsagesToOptimize) {
newToCheck.push(otherName);
}
}
}
}
toCheck = newToCheck;
}
}
// Checks, in `selectionSet`, which fragments (of `fragments`) are used at least `minUsagesToOptimize` times.
// Returns the updated set of fragments containing only the fragment definitions with usage above our threshold,
// and `undefined` or `null` if no such fragment meets said threshold. When this method returns `null`, it
// additionally means that no fragments are use at all in `selectionSet` (and so `undefined` means that
// "some" fragments are used in `selectionSet`, but just none of them is used at least `minUsagesToOptimize`
// times).
function computeFragmentsToKeep(
selectionSet: SelectionSet,
fragments: NamedFragments,
minUsagesToOptimize: number
): NamedFragments | undefined | null {
// We start by collecting the usages within the selection set.
const usages = new Map<string, number>();
selectionSet.collectUsedFragmentNames(usages);
// If we have no fragment in the selection set, then it's simple, we just don't keep any fragments.
if (usages.size === 0) {
return null;
}
// We're going to remove fragments from usages as we categorize them as kept or expanded, so we
// first ensure that it has entries for every fragment, default to 0.
for (const fragment of fragments.definitions()) {
if (usages.get(fragment.name) === undefined) {
usages.set(fragment.name, 0);
}
}
// At this point, `usages` contains the usages of fragments "in the selection". From that, we want
// to decide which fragment to "keep", and which to re-expand. But there is 2 subtlety:
// 1. when we decide to keep some fragment F, then we should could it's own usages of other fragments. That
// is, if a fragment G is use once in the selection, but also use once in a fragment F that we
// keep, then the usages for G is really 2 (but if F is unused, then we don't want to count
// it's usage of G for instance).
// 2. when we decide to expand a fragment, then this also impact the usages of other fragments it
// uses, as those gets "inlined" into the selection. But that also mean we have to be careful
// of the order in which we pick fragments to expand. Say we have:
// ```graphql
// query {
// ...F1
// }
//
// fragment F1 {
// a { ...F2 }
// b { ...F2 }
// }
//
// fragment F2 {
// // something
// }
// ```
// then at this point where we've only counted usages in the query selection, `usages` will be
// `{ F1: 1, F2: 0 }`. But we do not want to expand _both_ F1 and F2. Instead, we want to expand
// F1 first, and then realize that this increases F2 usages to 2, which means we stop there and keep F2.
// Generalizing this, it means we want to first pick up fragments to expand that are _not_ used by any
// other fragments that may be expanded.
const reverseDependencies = computeFragmentsDependents(fragments);
// We'll add to `toExpand` fragment we will definitively expand.
const toExpand = new Set<string>;
let shouldContinue = true;
while (shouldContinue) {
// We'll do an iteration, but if we make no progress, we won't continue (we don't want to loop forever).
shouldContinue = false;
clearKeptFragments(usages, fragments, minUsagesToOptimize);
for (const name of mapKeys(usages)) {
// Note that we modify `usages` as we iterate it, so 1) we use `mapKeys` above which copy into a list and 2)
// we get the `count` manually instead of relying on (possibly outdated) entries.
const count = usages.get(name)!;
// A unused fragment is not technically expanded, it is just removed and we can ignore for now (it's count
// count increase later but ...).
if (count === 0) {
continue;
}
// If we find a fragment to keep, it means some fragment we expanded earlier in this iteration bump this
// one count. We unsure `shouldContinue` is set so `clearKeptFragments` is called again, but let that
// method deal with it otherwise.
if (count >= minUsagesToOptimize) {
shouldContinue = true;
break;
}
const fragmentsUsingName = reverseDependencies.get(name);
if (!fragmentsUsingName || [...fragmentsUsingName].every((fragName) => toExpand.has(fragName) || !usages.get(fragName))) {
// This fragment is not used enough, and is only used by fragments we keep, so we
// are guaranteed that expanding another fragment will not increase its usage. So
// we definitively expand it.
toExpand.add(name);
usages.delete(name);
// We've added to `toExpand`, so it's worth redoing another iteration
// after that to see if something changes.
shouldContinue = true;
// Now that we expand it, we should bump the usage for every fragment it uses.
const nameUsages = fragments.get(name)!.fragmentUsages();
for (const [otherName, otherCount] of nameUsages.entries()) {
const prev = usages.get(otherName);
// Note that if `otherName` is not part of usages, it means it's a fragment we
// already decided to keep/expand, so we just ignore it.
if (prev !== undefined) {
usages.set(otherName, prev + count * otherCount);
}
}
}
}
}
// Finally, we know that to expand, which is `toExpand` plus whatever remains in `usage` (typically
// genuinely unused fragments).
for (const name of usages.keys()) {
toExpand.add(name);
}
return toExpand.size === 0 ? fragments : fragments.filter((f) => !toExpand.has(f.name));
}
export class Operation extends DirectiveTargetElement<Operation> {
constructor(
schema: Schema,
readonly rootKind: SchemaRootKind,
readonly selectionSet: SelectionSet,
readonly variableDefinitions: VariableDefinitions,
readonly fragments?: NamedFragments,
readonly name?: string,
directives: readonly Directive<any>[] = []) {
super(schema, directives);
}
// Returns a copy of this operation with the provided updated selection set.
// Note that this method assumes that the existing `this.fragments` is still appropriate.
private withUpdatedSelectionSet(newSelectionSet: SelectionSet): Operation {
if (this.selectionSet === newSelectionSet) {
return this;
}
return new Operation(
this.schema(),
this.rootKind,
newSelectionSet,
this.variableDefinitions,
this.fragments,
this.name,
this.appliedDirectives,
);
}
private collectUndefinedVariablesFromFragments(fragments: NamedFragments): Variable[] {
const collector = new VariableCollector();
for (const namedFragment of fragments.definitions()) {
namedFragment.selectionSet.usedVariables().forEach(v => {
if (!this.variableDefinitions.definition(v)) {
collector.add(v);
}
});
}
return collector.variables();
}
// Returns a copy of this operation with the provided updated selection set and fragments.
private withUpdatedSelectionSetAndFragments(
newSelectionSet: SelectionSet,
newFragments: NamedFragments | undefined,
allAvailableVariables?: VariableDefinitions,
): Operation {
if (this.selectionSet === newSelectionSet && newFragments === this.fragments) {
return this;
}
let newVariableDefinitions = this.variableDefinitions;
if (allAvailableVariables && newFragments) {
const undefinedVariables = this.collectUndefinedVariablesFromFragments(newFragments);
if (undefinedVariables.length > 0) {
newVariableDefinitions = new VariableDefinitions();
newVariableDefinitions.addAll(this.variableDefinitions);
newVariableDefinitions.addAll(allAvailableVariables.filter(undefinedVariables));
}
}
return new Operation(
this.schema(),
this.rootKind,
newSelectionSet,
newVariableDefinitions,
newFragments,
this.name,
this.appliedDirectives,
);
}
optimize(
fragments?: NamedFragments,
minUsagesToOptimize: number = DEFAULT_MIN_USAGES_TO_OPTIMIZE,
allAvailableVariables?: VariableDefinitions,
): Operation {
assert(minUsagesToOptimize >= 1, `Expected 'minUsagesToOptimize' to be at least 1, but got ${minUsagesToOptimize}`)
if (!fragments || fragments.isEmpty()) {
return this;
}
let optimizedSelection = this.selectionSet.optimize(fragments);
if (optimizedSelection === this.selectionSet) {
return this;
}
let finalFragments = computeFragmentsToKeep(optimizedSelection, fragments, minUsagesToOptimize);
// If there is fragment usages and we're not keeping all fragments, we need to expand fragments.
if (finalFragments !== null && finalFragments?.size !== fragments.size) {
// Note that optimizing all fragments to potentially re-expand some is not entirely optimal, but it's unclear
// how to do otherwise, and it probably don't matter too much in practice (we only call this optimization
// on the final computed query plan, so not a very hot path; plus in most cases we won't even reach that
// point either because there is no fragment, or none will have been optimized away so we'll exit above).
optimizedSelection = optimizedSelection.expandFragments(finalFragments);
// Expanding fragments could create some "inefficiencies" that we wouldn't have if we hadn't re-optimized
// the fragments to de-optimize it later, so we do a final "normalize" pass to remove those.
optimizedSelection = optimizedSelection.normalize({ parentType: optimizedSelection.parentType });
// And if we've expanded some fragments but kept others, then it's not 100% impossible that some
// fragment was used multiple times in some expanded fragment(s), but that post-expansion all of
// it's usages are "dead" branches that are removed by the final `normalize`. In that case though,
// we need to ensure we don't include the now-unused fragment in the final list of fragments.
// TODO: remark that the same reasoning could leave a single instance of a fragment usage, so if
// we really really want to never have less than `minUsagesToOptimize`, we could do some loop of
// `expand then normalize` unless all fragments are provably used enough. We don't bother, because
// leaving this is not a huge deal and it's not worth the complexity, but it could be that we can
// refactor all this later to avoid this case without additional complexity.
if (finalFragments) {
// Note that removing a fragment might lead to another fragment being unused, so we need to iterate
// until there is nothing more to remove, or we're out of fragments.
let beforeRemoval: NamedFragments;
do {
beforeRemoval = finalFragments;
const usages = new Map<string, number>();
// Collecting all usages, both in the selection and within other fragments.
optimizedSelection.collectUsedFragmentNames(usages);
finalFragments.collectUsedFragmentNames(usages);
finalFragments = finalFragments.filter((f) => (usages.get(f.name) ?? 0) > 0);
} while (finalFragments && finalFragments.size < beforeRemoval.size);
}
}
return this.withUpdatedSelectionSetAndFragments(
optimizedSelection,
finalFragments ?? undefined,
allAvailableVariables,
);
}
generateQueryFragments(): Operation {
const [minimizedSelectionSet, fragments] = this.selectionSet.minimizeSelectionSet();
return new Operation(
this.schema(),
this.rootKind,
minimizedSelectionSet,
this.variableDefinitions,
fragments,
this.name,
this.appliedDirectives,
);
}
expandAllFragments(): Operation {
// We clear up the fragments since we've expanded all.
// Also note that expanding fragment usually generate unecessary fragments/inefficient selections, so it
// basically always make sense to normalize afterwards. Besides, fragment reuse (done by `optimize`) rely
// on the fact that its input is normalized to work properly, so all the more reason to do it here.
const expanded = this.selectionSet.expandFragments();
return this.withUpdatedSelectionSetAndFragments(expanded.normalize({ parentType: expanded.parentType }), undefined);
}
normalize(): Operation {
return this.withUpdatedSelectionSet(this.selectionSet.normalize({ parentType: this.selectionSet.parentType }));
}
/**
* Returns this operation but potentially modified so all/some of the @defer applications have been removed.
*
* @param labelsToRemove - If provided, then only the `@defer` applications with labels in the provided
* set will be remove. Other `@defer` applications will be untouched. If `undefined`, then all `@defer`
* applications are removed.
*/
withoutDefer(labelsToRemove?: Set<string>): Operation {
return this.withUpdatedSelectionSet(this.selectionSet.withoutDefer(labelsToRemove));
}
/**
* Returns this operation but modified to "normalize" all the @defer applications.
*
* "Normalized" in this context means that all the `@defer` application in the
* resulting operation will:
* - have a (unique) label. Which imply that this method generates label for
* any `@defer` not having a label.
* - have a non-trivial `if` condition, if any. By non-trivial, we mean that
* the condition will be a variable and not an hard-coded `true` or `false`.
* To do this, this method will remove the condition of any `@defer` that
* has `if: true`, and will completely remove any `@defer` application that
* has `if: false`.
*/
withNormalizedDefer(): {
operation: Operation,
hasDefers: boolean,
assignedDeferLabels: Set<string>,
deferConditions: SetMultiMap<string, string>,
} {
const normalizer = new DeferNormalizer();
const { hasDefers, hasNonLabelledOrConditionalDefers } = normalizer.init(this.selectionSet);
let updatedOperation: Operation = this;
if (hasNonLabelledOrConditionalDefers) {
updatedOperation = this.withUpdatedSelectionSet(this.selectionSet.withNormalizedDefer(normalizer));
}
return {
operation: updatedOperation,
hasDefers,
assignedDeferLabels: normalizer.assignedLabels,
deferConditions: normalizer.deferConditions,
};
}
collectDefaultedVariableValues(): Record<string, any> {
const defaultedVariableValues: Record<string, any> = {};
for (const { variable, defaultValue } of this.variableDefinitions.definitions()) {
if (defaultValue !== undefined) {
defaultedVariableValues[variable.name] = defaultValue;
}
}
return defaultedVariableValues;
}
toString(expandFragments: boolean = false, prettyPrint: boolean = true): string {
return this.selectionSet.toOperationString(this.rootKind, this.variableDefinitions, this.fragments, this.name, this.appliedDirectives, expandFragments, prettyPrint);
}
}
export type FragmentRestrictionAtType = { selectionSet: SelectionSet, validator?: FieldsConflictValidator };
export class NamedFragmentDefinition extends DirectiveTargetElement<NamedFragmentDefinition> {
private _selectionSet: SelectionSet | undefined;
// Lazily computed cache of the expanded selection set.
private _expandedSelectionSet: SelectionSet | undefined;
private _fragmentUsages: Map<string, number> | undefined;
private _includedFragmentNames: Set<string> | undefined;
private readonly expandedSelectionSetsAtTypesCache = new Map<string, FragmentRestrictionAtType>();
constructor(
schema: Schema,
readonly name: string,
readonly typeCondition: CompositeType,
directives?: Directive<NamedFragmentDefinition>[],
) {
super(schema, directives);
}
setSelectionSet(selectionSet: SelectionSet): NamedFragmentDefinition {
assert(!this._selectionSet, 'Attempting to set the selection set of a fragment definition already built')
// We set the selection set post-construction to simplify the handling of fragments that use other fragments,
// but let's make sure we've properly used the fragment type condition as parent type of the selection set, as we should.
assert(selectionSet.parentType === this.typeCondition, `Fragment selection set parent is ${selectionSet.parentType} differs from the fragment condition type ${this.typeCondition}`);
this._selectionSet = selectionSet;
return this;
}
get selectionSet(): SelectionSet {
assert(this._selectionSet, () => `Trying to access fragment definition ${this.name} before it is fully built`);
return this._selectionSet;
}
withUpdatedSelectionSet(newSelectionSet: SelectionSet): NamedFragmentDefinition {
return new NamedFragmentDefinition(this.schema(), this.name, this.typeCondition).setSelectionSet(newSelectionSet);
}
fragmentUsages(): ReadonlyMap<string, number> {
if (!this._fragmentUsages) {
this._fragmentUsages = new Map();
this.selectionSet.collectUsedFragmentNames(this._fragmentUsages);
}
return this._fragmentUsages;
}
collectUsedFragmentNames(collector: Map<string, number>) {
const usages = this.fragmentUsages();
for (const [name, count] of usages.entries()) {
const prevCount = collector.get(name);
collector.set(name, prevCount ? prevCount + count : count);
}
}
collectVariables(collector: VariableCollector) {
this.selectionSet.collectVariables(collector);
this.collectVariablesInAppliedDirectives(collector);
}
toFragmentDefinitionNode() : FragmentDefinitionNode {
return {
kind: Kind.FRAGMENT_DEFINITION,
name: {
kind: Kind.NAME,
value: this.name
},
typeCondition: {
kind: Kind.NAMED_TYPE,
name: {
kind: Kind.NAME,
value: this.typeCondition.name
}
},
selectionSet: this.selectionSet.toSelectionSetNode()
};
}
/**
* Whether this fragment may apply _directly_ at the provided type, meaning that the fragment sub-selection
* (_without_ the fragment condition, hence the "directly") can be normalized at `type` and this without
* "widening" the runtime types to types that do not intersect the fragment condition.
*
* For that to be true, we need one of this to be true:
* 1. the runtime types of the fragment condition must be at least as general as those of the provided `type`.
* Otherwise, putting it at `type` without its condition would "generalize" more than the fragment meant to (and
* so we'd "widen" the runtime types more than what the query meant to.
* 2. either `type` and `this.typeCondition` are equal, or `type` is an object or `this.typeCondition` is a union
* The idea is that, assuming our 1st point, then:
* - if both are equal, things works trivially.
* - if `type` is an object, `this.typeCondition` is either the same object, or a union/interface for which
* type is a valid runtime. In all case, anything valid on `this.typeCondition` would apply to `type` too.
* - if `this.typeCondition` is a union, then it's selection can only have fragments at top-level
* (no fields save for `__typename`), and normalising is always fine with top-level fragments.
* But in any other case, both types must be abstract (if `this.typeCondition` is an object, the 1st condition
* imply `type` can only be the same type) and we're in one of:
* - `type` and `this.typeCondition` are both different interfaces (that intersect but are different).
* - `type` is aunion and `this.typeCondition` an interface.
* And in both cases, since `this.typeCondition` is an interface, the fragment selection set may have field selections
* on that interface, and those fields may not be valid for `type`.
*
* @param type - the type at which we're looking at applying the fragment
*/
canApplyDirectlyAtType(type: CompositeType): boolean {
if (sameType(type, this.typeCondition)) {
return true;
}
// No point computing runtime types if the condition is an object (it can never cover all of
// the runtimes of `type` unless it's the same type, which is already covered).
if (!isAbstractType(this.typeCondition)) {
return false;
}
const conditionRuntimes = possibleRuntimeTypes(this.typeCondition);
const typeRuntimes = possibleRuntimeTypes(type);
// The fragment condition must be at least as general as the provided type (in other words, all of the
// runtimes of `type` must be in `conditionRuntimes`).
// Note: the `length` test is technically redundant, but just avoid the more costly sub-set check if we
// can cheaply show it's unnecessary.
if (conditionRuntimes.length < typeRuntimes.length
|| !typeRuntimes.every((t1) => conditionRuntimes.some((t2) => sameType(t1, t2)))) {
return false;
}
return isObjectType(type) || isUnionType(this.typeCondition);
}
private expandedSelectionSet(): SelectionSet {
if (!this._expandedSelectionSet) {
this._expandedSelectionSet = this.selectionSet.expandFragments();
}
return this._expandedSelectionSet;
}
/**
* This methods *assumes* that `this.canApplyDirectlyAtType(type)` is `true` (and may crash if this is not true), and returns
* a version fo this named fragment selection set that corresponds to the "expansion" of this named fragment at `type`
*
* The overall idea here is that if we have an interface I with 2 implementations T1 and T2, and we have a fragment like:
* ```graphql
* fragment X