UNPKG

@apollo/federation-internals

Version:
1,282 lines (1,124 loc) 167 kB
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