diff --git a/src/ast.ts b/src/ast.ts index a7460b03..3a8c7e7f 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -1,4 +1,4 @@ -import genFn from "generate-function"; +import { genFn } from "./generate"; import { type ArgumentNode, type ASTNode, @@ -35,13 +35,14 @@ import { import { type CompilationContext, GLOBAL_VARIABLES_NAME } from "./execution.js"; import createInspect from "./inspect.js"; import { getGraphQLErrorOptions, resolveFieldDef } from "./compat.js"; +import { WeakMemo } from "./memoize"; export interface JitFieldNode extends FieldNode { /** * @deprecated Use __internalShouldIncludePath instead * @see __internalShouldIncludePath */ - __internalShouldInclude?: string; + __internalShouldInclude?: string[]; // The shouldInclude logic is specific to the current path // This is because the same fieldNode can be reached from different paths @@ -49,7 +50,7 @@ export interface JitFieldNode extends FieldNode { __internalShouldIncludePath?: { // Key is the stringified ObjectPath, // Value is the shouldInclude logic for that path - [path: string]: string; + [path: string]: string[]; }; } @@ -59,6 +60,12 @@ export interface FieldsAndNodes { const inspect = createInspect(); +const compileSkipIncludeMemo = new WeakMap(); +const collectFieldsMemo = new WeakMemo< + [SelectionSetNode, GraphQLObjectType], + FieldsAndNodes +>(); + /** * Given a selectionSet, adds all of the fields in that selection to * the passed in map of fields, and returns it at the end. @@ -96,159 +103,200 @@ function collectFieldsImpl( selectionSet: SelectionSetNode, fields: FieldsAndNodes, visitedFragmentNames: { [key: string]: boolean }, - previousShouldInclude = "", + previousShouldInclude: string[] = [], parentResponsePath = "" ): FieldsAndNodes { - for (const selection of selectionSet.selections) { - switch (selection.kind) { - case Kind.FIELD: { - const name = getFieldEntryKey(selection); - if (!fields[name]) { - fields[name] = []; - } - const fieldNode: JitFieldNode = selection; - - // the current path of the field - // This is used to generate per path skip/include code - // because the same field can be reached from different paths (e.g. fragment reuse) - const currentPath = joinSkipIncludePath( - parentResponsePath, - - // use alias(instead of selection.name.value) if available as the responsePath used for lookup uses alias - name - ); + const memoized = collectFieldsMemo.get([selectionSet, runtimeType]); + if (memoized) { + return memoized; + } - // `should include`s generated for the current fieldNode - const compiledSkipInclude = compileSkipInclude( - compilationContext, - selection - ); + interface StackItem { + selectionSet: SelectionSetNode; + parentResponsePath: string; + previousShouldInclude: string[]; + } - /** - * Carry over fragment's skip and include code - * - * fieldNode.__internalShouldInclude - * --------------------------------- - * When the parent field has a skip or include, the current one - * should be skipped if the parent is skipped in the path. - * - * previousShouldInclude - * --------------------- - * `should include`s from fragment spread and inline fragments - * - * compileSkipInclude(selection) - * ----------------------------- - * `should include`s generated for the current fieldNode - */ - if (compilationContext.options.useExperimentalPathBasedSkipInclude) { - if (!fieldNode.__internalShouldIncludePath) - fieldNode.__internalShouldIncludePath = {}; - - fieldNode.__internalShouldIncludePath[currentPath] = - joinShouldIncludeCompilations( - fieldNode.__internalShouldIncludePath?.[currentPath] ?? "", - previousShouldInclude, - compiledSkipInclude - ); - } else { - // @deprecated - fieldNode.__internalShouldInclude = joinShouldIncludeCompilations( - fieldNode.__internalShouldInclude ?? "", - previousShouldInclude, - compiledSkipInclude - ); - } - /** - * We augment the entire subtree as the parent object's skip/include - * directives influence the child even if the child doesn't have - * skip/include on it's own. - * - * Refer the function definition for example. - */ - augmentFieldNodeTree(compilationContext, fieldNode, currentPath); - - fields[name].push(fieldNode); - break; - } + const stack: StackItem[] = []; - case Kind.INLINE_FRAGMENT: { - if ( - !doesFragmentConditionMatch( + stack.push({ + selectionSet, + parentResponsePath, + previousShouldInclude + }); + + while (stack.length > 0) { + const { selectionSet, parentResponsePath, previousShouldInclude } = + stack.pop()!; + + for (const selection of selectionSet.selections) { + switch (selection.kind) { + case Kind.FIELD: { + collectFieldsForField({ compilationContext, - selection, - runtimeType - ) - ) { - continue; + fields, + parentResponsePath, + previousShouldInclude, + selection + }); + break; } - // current fragment's shouldInclude - const compiledSkipInclude = compileSkipInclude( - compilationContext, - selection - ); + case Kind.INLINE_FRAGMENT: { + if ( + !doesFragmentConditionMatch( + compilationContext, + selection, + runtimeType + ) + ) { + continue; + } - // recurse - collectFieldsImpl( - compilationContext, - runtimeType, - selection.selectionSet, - fields, - visitedFragmentNames, - joinShouldIncludeCompilations( - // `should include`s from previous fragments - previousShouldInclude, - // current fragment's shouldInclude - compiledSkipInclude - ), - parentResponsePath - ); - break; - } + // current fragment's shouldInclude + const compiledSkipInclude = compileSkipInclude( + compilationContext, + selection + ); - case Kind.FRAGMENT_SPREAD: { - const fragName = selection.name.value; - if (visitedFragmentNames[fragName]) { - continue; - } - visitedFragmentNames[fragName] = true; - const fragment = compilationContext.fragments[fragName]; - if ( - !fragment || - !doesFragmentConditionMatch(compilationContext, fragment, runtimeType) - ) { - continue; + // push to stack + stack.push({ + selectionSet: selection.selectionSet, + parentResponsePath: parentResponsePath, + previousShouldInclude: joinShouldIncludeCompilations( + // `should include`s from previous fragments + previousShouldInclude, + // current fragment's shouldInclude + [compiledSkipInclude] + ) + }); + break; } - // current fragment's shouldInclude - const compiledSkipInclude = compileSkipInclude( - compilationContext, - selection - ); + case Kind.FRAGMENT_SPREAD: { + const fragName = selection.name.value; + if (visitedFragmentNames[fragName]) { + continue; + } + visitedFragmentNames[fragName] = true; + const fragment = compilationContext.fragments[fragName]; + if ( + !fragment || + !doesFragmentConditionMatch( + compilationContext, + fragment, + runtimeType + ) + ) { + continue; + } - // recurse - collectFieldsImpl( - compilationContext, - runtimeType, - fragment.selectionSet, - fields, - visitedFragmentNames, - joinShouldIncludeCompilations( - // `should include`s from previous fragments - previousShouldInclude, - // current fragment's shouldInclude - compiledSkipInclude - ), - parentResponsePath - ); + // current fragment's shouldInclude + const compiledSkipInclude = compileSkipInclude( + compilationContext, + selection + ); - break; + // push to stack + stack.push({ + selectionSet: fragment.selectionSet, + parentResponsePath, + previousShouldInclude: joinShouldIncludeCompilations( + // `should include`s from previous fragments + previousShouldInclude, + // current fragment's shouldInclude + [compiledSkipInclude] + ) + }); + break; + } } } } + + collectFieldsMemo.set([selectionSet, runtimeType], fields); + return fields; } +function collectFieldsForField({ + compilationContext, + fields, + parentResponsePath, + previousShouldInclude, + selection +}: { + compilationContext: CompilationContext; + fields: FieldsAndNodes; + parentResponsePath: string; + previousShouldInclude: string[]; + selection: FieldNode; +}) { + const name = getFieldEntryKey(selection); + if (!fields[name]) { + fields[name] = []; + } + const fieldNode: JitFieldNode = selection; + + // the current path of the field + // This is used to generate per path skip/include code + // because the same field can be reached from different paths (e.g. fragment reuse) + const currentPath = joinSkipIncludePath( + parentResponsePath, + + // use alias(instead of selection.name.value) if available as the responsePath used for lookup uses alias + name + ); + + // `should include`s generated for the current fieldNode + const compiledSkipInclude = compileSkipInclude(compilationContext, selection); + + /** + * Carry over fragment's skip and include code + * + * fieldNode.__internalShouldInclude + * --------------------------------- + * When the parent field has a skip or include, the current one + * should be skipped if the parent is skipped in the path. + * + * previousShouldInclude + * --------------------- + * `should include`s from fragment spread and inline fragments + * + * compileSkipInclude(selection) + * ----------------------------- + * `should include`s generated for the current fieldNode + */ + if (compilationContext.options.useExperimentalPathBasedSkipInclude) { + if (!fieldNode.__internalShouldIncludePath) + fieldNode.__internalShouldIncludePath = {}; + + fieldNode.__internalShouldIncludePath[currentPath] = + joinShouldIncludeCompilations( + fieldNode.__internalShouldIncludePath?.[currentPath] ?? [], + previousShouldInclude, + [compiledSkipInclude] + ); + } else { + // @deprecated + fieldNode.__internalShouldInclude = joinShouldIncludeCompilations( + fieldNode.__internalShouldInclude ?? [], + previousShouldInclude, + [compiledSkipInclude] + ); + } + /** + * We augment the entire subtree as the parent object's skip/include + * directives influence the child even if the child doesn't have + * skip/include on it's own. + * + * Refer the function definition for example. + */ + augmentFieldNodeTree(compilationContext, fieldNode, currentPath); + + fields[name].push(fieldNode); +} + /** * Augment __internalShouldInclude code for all sub-fields in the * tree with @param rootfieldNode as the root. @@ -303,66 +351,99 @@ function augmentFieldNodeTree( parentResponsePath: string ) { for (const selection of rootFieldNode.selectionSet?.selections ?? []) { - handle(rootFieldNode, selection, false, parentResponsePath); - } + /** + * Traverse through sub-selection and combine `shouldInclude`s + * from parent and current ones. + */ + interface StackItem { + parentFieldNode: JitFieldNode; + selection: SelectionNode; + comesFromFragmentSpread: boolean; + parentResponsePath: string; + } - /** - * Recursively traverse through sub-selection and combine `shouldInclude`s - * from parent and current ones. - */ - function handle( - parentFieldNode: JitFieldNode, - selection: SelectionNode, - comesFromFragmentSpread = false, - parentResponsePath: string - ) { - switch (selection.kind) { - case Kind.FIELD: { - const jitFieldNode: JitFieldNode = selection; - const currentPath = joinSkipIncludePath( - parentResponsePath, - - // use alias(instead of selection.name.value) if available as the responsePath used for lookup uses alias - getFieldEntryKey(jitFieldNode) - ); + const stack: StackItem[] = []; + + stack.push({ + parentFieldNode: rootFieldNode, + selection, + comesFromFragmentSpread: false, + parentResponsePath + }); + + while (stack.length > 0) { + const { + parentFieldNode, + selection, + comesFromFragmentSpread, + parentResponsePath + } = stack.pop()!; + + switch (selection.kind) { + case Kind.FIELD: { + const jitFieldNode: JitFieldNode = selection; + const currentPath = joinSkipIncludePath( + parentResponsePath, - if (!comesFromFragmentSpread) { - if (compilationContext.options.useExperimentalPathBasedSkipInclude) { - if (!jitFieldNode.__internalShouldIncludePath) - jitFieldNode.__internalShouldIncludePath = {}; - - jitFieldNode.__internalShouldIncludePath[currentPath] = - joinShouldIncludeCompilations( - parentFieldNode.__internalShouldIncludePath?.[ - parentResponsePath - ] ?? "", - jitFieldNode.__internalShouldIncludePath?.[currentPath] ?? "" - ); - } else { - // @deprecated - jitFieldNode.__internalShouldInclude = - joinShouldIncludeCompilations( - parentFieldNode.__internalShouldInclude ?? "", - jitFieldNode.__internalShouldInclude ?? "" - ); + // use alias(instead of selection.name.value) if available as the responsePath used for lookup uses alias + getFieldEntryKey(jitFieldNode) + ); + + if (!comesFromFragmentSpread) { + if ( + compilationContext.options.useExperimentalPathBasedSkipInclude + ) { + if (!jitFieldNode.__internalShouldIncludePath) + jitFieldNode.__internalShouldIncludePath = {}; + + jitFieldNode.__internalShouldIncludePath[currentPath] = + joinShouldIncludeCompilations( + parentFieldNode.__internalShouldIncludePath?.[ + parentResponsePath + ] ?? [], + jitFieldNode.__internalShouldIncludePath?.[currentPath] ?? [] + ); + } else { + // @deprecated + jitFieldNode.__internalShouldInclude = + joinShouldIncludeCompilations( + parentFieldNode.__internalShouldInclude ?? [], + jitFieldNode.__internalShouldInclude ?? [] + ); + } } + // go further down the query tree + for (const selection of jitFieldNode.selectionSet?.selections ?? []) { + stack.push({ + parentFieldNode: jitFieldNode, + selection, + comesFromFragmentSpread: false, + parentResponsePath: currentPath + }); + } + break; } - // go further down the query tree - for (const selection of jitFieldNode.selectionSet?.selections ?? []) { - handle(jitFieldNode, selection, false, currentPath); - } - break; - } - case Kind.INLINE_FRAGMENT: { - for (const subSelection of selection.selectionSet.selections) { - handle(parentFieldNode, subSelection, true, parentResponsePath); + case Kind.INLINE_FRAGMENT: { + for (const subSelection of selection.selectionSet.selections) { + stack.push({ + parentFieldNode, + selection: subSelection, + comesFromFragmentSpread: true, + parentResponsePath + }); + } + break; } - break; - } - case Kind.FRAGMENT_SPREAD: { - const fragment = compilationContext.fragments[selection.name.value]; - for (const subSelection of fragment.selectionSet.selections) { - handle(parentFieldNode, subSelection, true, parentResponsePath); + case Kind.FRAGMENT_SPREAD: { + const fragment = compilationContext.fragments[selection.name.value]; + for (const subSelection of fragment.selectionSet.selections) { + stack.push({ + parentFieldNode, + selection: subSelection, + comesFromFragmentSpread: true, + parentResponsePath + }); + } } } } @@ -392,7 +473,7 @@ function augmentFieldNodeTree( * * @param compilations */ -function joinShouldIncludeCompilations(...compilations: string[]) { +function joinShouldIncludeCompilations(...compilations: string[][]): string[] { // remove "true" since we are joining with '&&' as `true && X` = `X` // This prevents an explosion of `&& true` which could break // V8's internal size limit for string. @@ -404,16 +485,16 @@ function joinShouldIncludeCompilations(...compilations: string[]) { // Failing to do this results in [RangeError: invalid array length] // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_array_length - // remove empty strings - let filteredCompilations = compilations.filter((it) => it); + const conditionsSet = new Set(); + for (const conditions of compilations) { + for (const condition of conditions) { + if (condition !== "true") { + conditionsSet.add(condition); + } + } + } - // Split conditions by && and flatten it - filteredCompilations = ([] as string[]).concat( - ...filteredCompilations.map((e) => e.split(" && ").map((it) => it.trim())) - ); - // Deduplicate items - filteredCompilations = Array.from(new Set(filteredCompilations)); - return filteredCompilations.join(" && "); + return Array.from(conditionsSet); } /** @@ -427,7 +508,17 @@ function compileSkipInclude( compilationContext: CompilationContext, node: SelectionNode ): string { - const gen = genFn(); + const memoized = compileSkipIncludeMemo.get(node); + if (memoized) { + return memoized; + } + + // minor optimization to avoid compilation if there are no directives + if (node.directives == null || node.directives.length < 1) { + const ret = "true"; + compileSkipIncludeMemo.set(node, ret); + return ret; + } const { skipValue, includeValue } = compileSkipIncludeDirectiveValues( compilationContext, @@ -445,17 +536,20 @@ function compileSkipInclude( * or fragment must not be queried if either the @skip * condition is true or the @include condition is false. */ + let ret: string; if (skipValue != null && includeValue != null) { - gen(`${skipValue} === false && ${includeValue} === true`); + ret = `${skipValue} === false && ${includeValue} === true`; } else if (skipValue != null) { - gen(`(${skipValue} === false)`); + ret = `(${skipValue} === false)`; } else if (includeValue != null) { - gen(`(${includeValue} === true)`); + ret = `(${includeValue} === true)`; } else { - gen(`true`); + ret = `true`; } - return gen.toString(); + compileSkipIncludeMemo.set(node, ret); + + return ret; } /** diff --git a/src/execution.ts b/src/execution.ts index 4d65df5b..a0a68829 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -1,6 +1,6 @@ import { type TypedDocumentNode } from "@graphql-typed-document-node/core"; import fastJson from "fast-json-stringify"; -import genFn from "generate-function"; +import { genFn } from "./generate"; import { type ASTNode, type DocumentNode, @@ -65,8 +65,10 @@ import { failToParseVariables } from "./variables.js"; import { getGraphQLErrorOptions, getOperationRootType } from "./compat.js"; +import memoize from "lodash.memoize"; const inspect = createInspect(); +const joinOriginPaths = memoize(joinOriginPathsImpl); export interface CompilerOptions { customJSONSerializer: boolean; @@ -129,6 +131,7 @@ interface DeferredField { name: string; responsePath: ObjectPath; originPaths: string[]; + originPathsFormatted: string; destinationPaths: string[]; parentType: GraphQLObjectType; fieldName: string; @@ -517,7 +520,7 @@ function compileDeferredField( ): string { const { name, - originPaths, + originPathsFormatted, destinationPaths, fieldNodes, fieldType, @@ -562,7 +565,7 @@ function compileDeferredField( responsePath ); const emptyError = createErrorObject(context, fieldNodes, responsePath, '""'); - const resolverParentPath = originPaths.join("."); + const resolverParentPath = originPathsFormatted; const resolverCall = `${GLOBAL_EXECUTION_CONTEXT}.resolvers.${resolverName}( ${resolverParentPath},${topLevelArgs},${GLOBAL_CONTEXT_NAME}, ${executionInfo})`; const resultParentPath = destinationPaths.join("."); @@ -661,7 +664,7 @@ function compileType( destinationPaths: string[], previousPath: ObjectPath ): string { - const sourcePath = originPaths.join("."); + const sourcePath = joinOriginPaths(originPaths); let body = `${sourcePath} == null ? `; let errorDestination; if (isNonNullType(type)) { @@ -754,7 +757,7 @@ function compileLeafType( context.options.disableLeafSerialization && (type instanceof GraphQLEnumType || isSpecifiedScalarType(type)) ) { - body += `${originPaths.join(".")}`; + body += `${joinOriginPaths(originPaths)}`; } else { const serializerName = getSerializerName(type.name); context.serializers[serializerName] = getSerializer( @@ -775,8 +778,8 @@ function compileLeafType( "message" )});} `); - body += `${GLOBAL_EXECUTION_CONTEXT}.serializers.${serializerName}(${GLOBAL_EXECUTION_CONTEXT}, ${originPaths.join( - "." + body += `${GLOBAL_EXECUTION_CONTEXT}.serializers.${serializerName}(${GLOBAL_EXECUTION_CONTEXT}, ${joinOriginPaths( + originPaths )}, ${serializerErrorHandler}, ${parentIndexes})`; } return body; @@ -815,15 +818,17 @@ function compileObjectType( body( `!${GLOBAL_EXECUTION_CONTEXT}.isTypeOfs["${ type.name - }IsTypeOf"](${originPaths.join( - "." + }IsTypeOf"](${joinOriginPaths( + originPaths )}) ? (${errorDestination}.push(${createErrorObject( context, fieldNodes, responsePath as any, `\`Expected value of type "${ type.name - }" but got: $\{${GLOBAL_INSPECT_NAME}(${originPaths.join(".")})}.\`` + }" but got: $\{${GLOBAL_INSPECT_NAME}(${joinOriginPaths( + originPaths + )})}.\`` )}), null) :` ); } @@ -872,15 +877,32 @@ function compileObjectType( name ); - const fieldCondition = context.options.useExperimentalPathBasedSkipInclude - ? fieldNodes - .map((it) => it.__internalShouldIncludePath?.[serializedResponsePath]) - .filter((it) => it) - .join(" || ") || /* if(true) - default */ "true" - : fieldNodes - .map((it) => it.__internalShouldInclude) - .filter((it) => it) - .join(" || ") || /* if(true) - default */ "true"; + const fieldConditionsList = ( + context.options.useExperimentalPathBasedSkipInclude + ? fieldNodes.map( + (it) => it.__internalShouldIncludePath?.[serializedResponsePath] + ) + : fieldNodes.map((it) => it.__internalShouldInclude) + ).filter(isNotNull); + + let fieldCondition = fieldConditionsList + .map((it) => { + if (it.length > 0) { + return `(${it.join(" && ")})`; + } + // default: if there are no conditions, it means that the field + // is always included in the path + return "true"; + }) + .filter(isNotNull) + .join(" || "); + + // if it's an empty string, it means that the field is always included + if (!fieldCondition) { + // if there are no conditions, it means that the field + // is always included in the path + fieldCondition = "true"; + } body(` ( @@ -907,6 +929,7 @@ function compileObjectType( name, responsePath: addPath(responsePath, name), originPaths, + originPathsFormatted: joinOriginPaths(originPaths), destinationPaths, parentType: type, fieldName: field.name, @@ -1046,8 +1069,8 @@ function compileAbstractType( return null; } })( - ${GLOBAL_EXECUTION_CONTEXT}.typeResolvers.${typeResolverName}(${originPaths.join( - "." + ${GLOBAL_EXECUTION_CONTEXT}.typeResolvers.${typeResolverName}(${joinOriginPaths( + originPaths )}, ${GLOBAL_CONTEXT_NAME}, ${getExecutionInfo( @@ -1916,3 +1939,11 @@ function mapAsyncIterator( } }; } + +function joinOriginPathsImpl(originPaths: string[]) { + return originPaths.join("."); +} + +function isNotNull(it: T): it is Exclude { + return it != null; +} diff --git a/src/generate.ts b/src/generate.ts new file mode 100644 index 00000000..f2d370a0 --- /dev/null +++ b/src/generate.ts @@ -0,0 +1,12 @@ +export function genFn() { + let body = ""; + + function add(str: string) { + body += str + "\n"; + return add; + } + + add.toString = () => body; + + return add; +} diff --git a/src/memoize.ts b/src/memoize.ts index 4ee78aff..951558dd 100644 --- a/src/memoize.ts +++ b/src/memoize.ts @@ -75,3 +75,37 @@ export function memoize4(fn: T): T { ) ) as T; } + +type KeyTuple = [...K]; + +export class WeakMemo> { + private baseMap = new WeakMap(); + + set(keys: KeyTuple, value: V) { + let map = this.baseMap; + for (const key of keys.slice(0, -1)) { + let nextMap = map.get(key); + if (!nextMap) { + nextMap = new WeakMap(); + map.set(key, nextMap); + } + map = nextMap; + } + map.set(keys[keys.length - 1], value); + return this; + } + + get(keys: KeyTuple): V | undefined { + let map = this.baseMap; + for (const key of keys.slice(0, -1)) { + map = map.get(key); + if (!map) return undefined; + } + return map.get(keys[keys.length - 1]); + } + + has(keys: KeyTuple): boolean { + let item = this.get(keys); + return item != null; + } +} diff --git a/src/resolve-info.ts b/src/resolve-info.ts index b8bcd5b8..f73c3b22 100644 --- a/src/resolve-info.ts +++ b/src/resolve-info.ts @@ -1,4 +1,4 @@ -import genFn from "generate-function"; +import { genFn } from "./generate"; import { doTypesOverlap, type FieldNode, diff --git a/src/variables.ts b/src/variables.ts index dbbca5cf..fcb827e0 100644 --- a/src/variables.ts +++ b/src/variables.ts @@ -1,4 +1,4 @@ -import genFn from "generate-function"; +import { genFn } from "./generate"; import { GraphQLBoolean, GraphQLError,