diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 5283aa4de5..7fbdebfdb9 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -18,6 +18,7 @@ import { GraphQLUnionType, } from '../../type/definition'; +import { Executor } from '../executor'; import { execute, executeSync } from '../execute'; describe('Execute: Handles basic execution tasks', () => { @@ -1151,6 +1152,32 @@ describe('Execute: Handles basic execution tasks', () => { expect(result).to.deep.equal({ data: { foo: null } }); }); + it('uses a custom Executor', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + const document = parse('{ foo }'); + + class CustomExecutor extends Executor { + executeField() { + return 'foo'; + } + } + + const result = executeSync({ + schema, + document, + CustomExecutor, + }); + + expect(result).to.deep.equal({ data: { foo: 'foo' } }); + }); + it('uses a custom field resolver', () => { const schema = new GraphQLSchema({ query: new GraphQLObjectType({ diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts new file mode 100644 index 0000000000..2da9440969 --- /dev/null +++ b/src/execution/collectFields.ts @@ -0,0 +1,159 @@ +import type { ObjMap } from '../jsutils/ObjMap'; + +import type { + SelectionSetNode, + FieldNode, + FragmentSpreadNode, + InlineFragmentNode, + FragmentDefinitionNode, +} from '../language/ast'; +import { Kind } from '../language/kinds'; + +import type { GraphQLSchema } from '../type/schema'; +import type { GraphQLObjectType } from '../type/definition'; +import { + GraphQLIncludeDirective, + GraphQLSkipDirective, +} from '../type/directives'; +import { isAbstractType } from '../type/definition'; + +import { typeFromAST } from '../utilities/typeFromAST'; + +import { getDirectiveValues } from './values'; + +/** + * Given a selectionSet, adds all of the fields in that selection to + * the passed in map of fields, and returns it at the end. + * + * CollectFields requires the "runtime type" of an object. For a field which + * returns an Interface or Union type, the "runtime type" will be the actual + * Object type returned by that field. + * + * @internal + */ +export function collectFields( + schema: GraphQLSchema, + fragments: ObjMap, + variableValues: { [variable: string]: unknown }, + runtimeType: GraphQLObjectType, + selectionSet: SelectionSetNode, + fields: Map>, + visitedFragmentNames: Set, +): Map> { + for (const selection of selectionSet.selections) { + switch (selection.kind) { + case Kind.FIELD: { + if (!shouldIncludeNode(variableValues, selection)) { + continue; + } + const name = getFieldEntryKey(selection); + const fieldList = fields.get(name); + if (fieldList !== undefined) { + fieldList.push(selection); + } else { + fields.set(name, [selection]); + } + break; + } + case Kind.INLINE_FRAGMENT: { + if ( + !shouldIncludeNode(variableValues, selection) || + !doesFragmentConditionMatch(schema, selection, runtimeType) + ) { + continue; + } + collectFields( + schema, + fragments, + variableValues, + runtimeType, + selection.selectionSet, + fields, + visitedFragmentNames, + ); + break; + } + case Kind.FRAGMENT_SPREAD: { + const fragName = selection.name.value; + if ( + visitedFragmentNames.has(fragName) || + !shouldIncludeNode(variableValues, selection) + ) { + continue; + } + visitedFragmentNames.add(fragName); + const fragment = fragments[fragName]; + if ( + !fragment || + !doesFragmentConditionMatch(schema, fragment, runtimeType) + ) { + continue; + } + collectFields( + schema, + fragments, + variableValues, + runtimeType, + fragment.selectionSet, + fields, + visitedFragmentNames, + ); + break; + } + } + } + return fields; +} + +/** + * Determines if a field should be included based on the @include and @skip + * directives, where @skip has higher precedence than @include. + */ +function shouldIncludeNode( + variableValues: { [variable: string]: unknown }, + node: FragmentSpreadNode | FieldNode | InlineFragmentNode, +): boolean { + const skip = getDirectiveValues(GraphQLSkipDirective, node, variableValues); + if (skip?.if === true) { + return false; + } + + const include = getDirectiveValues( + GraphQLIncludeDirective, + node, + variableValues, + ); + if (include?.if === false) { + return false; + } + return true; +} + +/** + * Determines if a fragment is applicable to the given type. + */ +function doesFragmentConditionMatch( + schema: GraphQLSchema, + fragment: FragmentDefinitionNode | InlineFragmentNode, + type: GraphQLObjectType, +): boolean { + const typeConditionNode = fragment.typeCondition; + if (!typeConditionNode) { + return true; + } + const conditionalType = typeFromAST(schema, typeConditionNode); + if (conditionalType === type) { + return true; + } + if (isAbstractType(conditionalType)) { + return schema.isSubType(conditionalType, type); + } + return false; +} + +/** + * Implements the logic to compute the key of a given field's entry + */ +function getFieldEntryKey(node: FieldNode): string { + return node.alias ? node.alias.value : node.name.value; +} diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 985d3173ed..4c588b5e9b 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1,71 +1,29 @@ -import type { Path } from '../jsutils/Path'; import type { ObjMap } from '../jsutils/ObjMap'; import type { PromiseOrValue } from '../jsutils/PromiseOrValue'; import type { Maybe } from '../jsutils/Maybe'; -import { inspect } from '../jsutils/inspect'; -import { memoize3 } from '../jsutils/memoize3'; -import { invariant } from '../jsutils/invariant'; import { devAssert } from '../jsutils/devAssert'; import { isPromise } from '../jsutils/isPromise'; import { isObjectLike } from '../jsutils/isObjectLike'; -import { promiseReduce } from '../jsutils/promiseReduce'; -import { promiseForObject } from '../jsutils/promiseForObject'; -import { addPath, pathToArray } from '../jsutils/Path'; -import { isIterableObject } from '../jsutils/isIterableObject'; import type { GraphQLFormattedError } from '../error/formatError'; import { GraphQLError } from '../error/GraphQLError'; -import { locatedError } from '../error/locatedError'; import type { DocumentNode, OperationDefinitionNode, - SelectionSetNode, - FieldNode, - FragmentSpreadNode, - InlineFragmentNode, FragmentDefinitionNode, } from '../language/ast'; import { Kind } from '../language/kinds'; import type { GraphQLSchema } from '../type/schema'; import type { - GraphQLObjectType, - GraphQLOutputType, - GraphQLLeafType, - GraphQLAbstractType, - GraphQLField, GraphQLFieldResolver, - GraphQLResolveInfo, GraphQLTypeResolver, - GraphQLList, } from '../type/definition'; import { assertValidSchema } from '../type/validate'; -import { - SchemaMetaFieldDef, - TypeMetaFieldDef, - TypeNameMetaFieldDef, -} from '../type/introspection'; -import { - GraphQLIncludeDirective, - GraphQLSkipDirective, -} from '../type/directives'; -import { - isObjectType, - isAbstractType, - isLeafType, - isListType, - isNonNullType, -} from '../type/definition'; - -import { typeFromAST } from '../utilities/typeFromAST'; -import { getOperationRootType } from '../utilities/getOperationRootType'; -import { - getVariableValues, - getArgumentValues, - getDirectiveValues, -} from './values'; +import { getVariableValues } from './values'; +import { Executor } from './executor'; /** * Terminology @@ -139,6 +97,7 @@ export interface ExecutionArgs { operationName?: Maybe; fieldResolver?: Maybe>; typeResolver?: Maybe>; + CustomExecutor?: Maybe; } /** @@ -161,6 +120,7 @@ export function execute(args: ExecutionArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + CustomExecutor, } = args; // If arguments are missing or incorrect, throw an error. @@ -184,6 +144,8 @@ export function execute(args: ExecutionArgs): PromiseOrValue { return { errors: exeContext }; } + const executor = new (CustomExecutor ?? Executor)(exeContext); + // Return a Promise that will eventually resolve to the data described by // The "Response" section of the GraphQL specification. // @@ -191,8 +153,8 @@ export function execute(args: ExecutionArgs): PromiseOrValue { // field and its descendants will be omitted, and sibling fields will still // be executed. An execution which encounters errors will still result in a // resolved Promise. - const data = executeOperation(exeContext, exeContext.operation, rootValue); - return buildResponse(exeContext, data); + const data = executor.executeOperation(); + return executor.buildResponse(data); } /** @@ -211,22 +173,6 @@ export function executeSync(args: ExecutionArgs): ExecutionResult { return result; } -/** - * Given a completed execution context and data, build the { errors, data } - * response defined by the "Response" section of the GraphQL specification. - */ -function buildResponse( - exeContext: ExecutionContext, - data: PromiseOrValue | null>, -): PromiseOrValue { - if (isPromise(data)) { - return data.then((resolved) => buildResponse(exeContext, resolved)); - } - return exeContext.errors.length === 0 - ? { data } - : { errors: exeContext.errors, data }; -} - /** * Essential assertions before executing to provide developer feedback for * improper use of the GraphQL library. @@ -326,772 +272,6 @@ export function buildExecutionContext( }; } -/** - * Implements the "Executing operations" section of the spec. - */ -function executeOperation( - exeContext: ExecutionContext, - operation: OperationDefinitionNode, - rootValue: unknown, -): PromiseOrValue | null> { - const type = getOperationRootType(exeContext.schema, operation); - const fields = collectFields( - exeContext, - type, - operation.selectionSet, - new Map(), - new Set(), - ); - - const path = undefined; - - // Errors from sub-fields of a NonNull type may propagate to the top level, - // at which point we still log the error and null the parent field, which - // in this case is the entire response. - try { - const result = - operation.operation === 'mutation' - ? executeFieldsSerially(exeContext, type, rootValue, path, fields) - : executeFields(exeContext, type, rootValue, path, fields); - if (isPromise(result)) { - return result.then(undefined, (error) => { - exeContext.errors.push(error); - return Promise.resolve(null); - }); - } - return result; - } catch (error) { - exeContext.errors.push(error); - return null; - } -} - -/** - * Implements the "Executing selection sets" section of the spec - * for fields that must be executed serially. - */ -function executeFieldsSerially( - exeContext: ExecutionContext, - parentType: GraphQLObjectType, - sourceValue: unknown, - path: Path | undefined, - fields: Map>, -): PromiseOrValue> { - return promiseReduce( - fields.entries(), - (results, [responseName, fieldNodes]) => { - const fieldPath = addPath(path, responseName, parentType.name); - const result = executeField( - exeContext, - parentType, - sourceValue, - fieldNodes, - fieldPath, - ); - if (result === undefined) { - return results; - } - if (isPromise(result)) { - return result.then((resolvedResult) => { - results[responseName] = resolvedResult; - return results; - }); - } - results[responseName] = result; - return results; - }, - Object.create(null), - ); -} - -/** - * Implements the "Executing selection sets" section of the spec - * for fields that may be executed in parallel. - */ -function executeFields( - exeContext: ExecutionContext, - parentType: GraphQLObjectType, - sourceValue: unknown, - path: Path | undefined, - fields: Map>, -): PromiseOrValue> { - const results = Object.create(null); - let containsPromise = false; - - for (const [responseName, fieldNodes] of fields.entries()) { - const fieldPath = addPath(path, responseName, parentType.name); - const result = executeField( - exeContext, - parentType, - sourceValue, - fieldNodes, - fieldPath, - ); - - if (result !== undefined) { - results[responseName] = result; - if (isPromise(result)) { - containsPromise = true; - } - } - } - - // If there are no promises, we can just return the object - if (!containsPromise) { - return results; - } - - // Otherwise, results is a map from field name to the result of resolving that - // field, which is possibly a promise. Return a promise that will return this - // same map, but with any promises replaced with the values they resolved to. - return promiseForObject(results); -} - -/** - * Given a selectionSet, adds all of the fields in that selection to - * the passed in map of fields, and returns it at the end. - * - * CollectFields requires the "runtime type" of an object. For a field which - * returns an Interface or Union type, the "runtime type" will be the actual - * Object type returned by that field. - * - * @internal - */ -export function collectFields( - exeContext: ExecutionContext, - runtimeType: GraphQLObjectType, - selectionSet: SelectionSetNode, - fields: Map>, - visitedFragmentNames: Set, -): Map> { - for (const selection of selectionSet.selections) { - switch (selection.kind) { - case Kind.FIELD: { - if (!shouldIncludeNode(exeContext, selection)) { - continue; - } - const name = getFieldEntryKey(selection); - const fieldList = fields.get(name); - if (fieldList !== undefined) { - fieldList.push(selection); - } else { - fields.set(name, [selection]); - } - break; - } - case Kind.INLINE_FRAGMENT: { - if ( - !shouldIncludeNode(exeContext, selection) || - !doesFragmentConditionMatch(exeContext, selection, runtimeType) - ) { - continue; - } - collectFields( - exeContext, - runtimeType, - selection.selectionSet, - fields, - visitedFragmentNames, - ); - break; - } - case Kind.FRAGMENT_SPREAD: { - const fragName = selection.name.value; - if ( - visitedFragmentNames.has(fragName) || - !shouldIncludeNode(exeContext, selection) - ) { - continue; - } - visitedFragmentNames.add(fragName); - const fragment = exeContext.fragments[fragName]; - if ( - !fragment || - !doesFragmentConditionMatch(exeContext, fragment, runtimeType) - ) { - continue; - } - collectFields( - exeContext, - runtimeType, - fragment.selectionSet, - fields, - visitedFragmentNames, - ); - break; - } - } - } - return fields; -} - -/** - * Determines if a field should be included based on the @include and @skip - * directives, where @skip has higher precedence than @include. - */ -function shouldIncludeNode( - exeContext: ExecutionContext, - node: FragmentSpreadNode | FieldNode | InlineFragmentNode, -): boolean { - const skip = getDirectiveValues( - GraphQLSkipDirective, - node, - exeContext.variableValues, - ); - if (skip?.if === true) { - return false; - } - - const include = getDirectiveValues( - GraphQLIncludeDirective, - node, - exeContext.variableValues, - ); - if (include?.if === false) { - return false; - } - return true; -} - -/** - * Determines if a fragment is applicable to the given type. - */ -function doesFragmentConditionMatch( - exeContext: ExecutionContext, - fragment: FragmentDefinitionNode | InlineFragmentNode, - type: GraphQLObjectType, -): boolean { - const typeConditionNode = fragment.typeCondition; - if (!typeConditionNode) { - return true; - } - const conditionalType = typeFromAST(exeContext.schema, typeConditionNode); - if (conditionalType === type) { - return true; - } - if (isAbstractType(conditionalType)) { - return exeContext.schema.isSubType(conditionalType, type); - } - return false; -} - -/** - * Implements the logic to compute the key of a given field's entry - */ -function getFieldEntryKey(node: FieldNode): string { - return node.alias ? node.alias.value : node.name.value; -} - -/** - * Implements the "Executing field" section of the spec - * In particular, this function figures out the value that the field returns by - * calling its resolve function, then calls completeValue to complete promises, - * serialize scalars, or execute the sub-selection-set for objects. - */ -function executeField( - exeContext: ExecutionContext, - parentType: GraphQLObjectType, - source: unknown, - fieldNodes: ReadonlyArray, - path: Path, -): PromiseOrValue { - const fieldDef = getFieldDef(exeContext.schema, parentType, fieldNodes[0]); - if (!fieldDef) { - return; - } - - const returnType = fieldDef.type; - const resolveFn = fieldDef.resolve ?? exeContext.fieldResolver; - - const info = buildResolveInfo( - exeContext, - fieldDef, - fieldNodes, - parentType, - path, - ); - - // Get the resolve function, regardless of if its result is normal or abrupt (error). - try { - // Build a JS object of arguments from the field.arguments AST, using the - // variables scope to fulfill any variable references. - // TODO: find a way to memoize, in case this field is within a List type. - const args = getArgumentValues( - fieldDef, - fieldNodes[0], - exeContext.variableValues, - ); - - // The resolve function's optional third argument is a context value that - // is provided to every resolve function within an execution. It is commonly - // used to represent an authenticated user, or request-specific caches. - const contextValue = exeContext.contextValue; - - const result = resolveFn(source, args, contextValue, info); - - let completed; - if (isPromise(result)) { - completed = result.then((resolved) => - completeValue(exeContext, returnType, fieldNodes, info, path, resolved), - ); - } else { - completed = completeValue( - exeContext, - returnType, - fieldNodes, - info, - path, - result, - ); - } - - if (isPromise(completed)) { - // Note: we don't rely on a `catch` method, but we do expect "thenable" - // to take a second callback for the error case. - return completed.then(undefined, (rawError) => { - const error = locatedError(rawError, fieldNodes, pathToArray(path)); - return handleFieldError(error, returnType, exeContext); - }); - } - return completed; - } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(path)); - return handleFieldError(error, returnType, exeContext); - } -} - -/** - * @internal - */ -export function buildResolveInfo( - exeContext: ExecutionContext, - fieldDef: GraphQLField, - fieldNodes: ReadonlyArray, - parentType: GraphQLObjectType, - path: Path, -): GraphQLResolveInfo { - // The resolve function's optional fourth argument is a collection of - // information about the current execution state. - return { - fieldName: fieldDef.name, - fieldNodes, - returnType: fieldDef.type, - parentType, - path, - schema: exeContext.schema, - fragments: exeContext.fragments, - rootValue: exeContext.rootValue, - operation: exeContext.operation, - variableValues: exeContext.variableValues, - }; -} - -function handleFieldError( - error: GraphQLError, - returnType: GraphQLOutputType, - exeContext: ExecutionContext, -): null { - // If the field type is non-nullable, then it is resolved without any - // protection from errors, however it still properly locates the error. - if (isNonNullType(returnType)) { - throw error; - } - - // Otherwise, error protection is applied, logging the error and resolving - // a null value for this field if one is encountered. - exeContext.errors.push(error); - return null; -} - -/** - * Implements the instructions for completeValue as defined in the - * "Field entries" section of the spec. - * - * If the field type is Non-Null, then this recursively completes the value - * for the inner type. It throws a field error if that completion returns null, - * as per the "Nullability" section of the spec. - * - * If the field type is a List, then this recursively completes the value - * for the inner type on each item in the list. - * - * If the field type is a Scalar or Enum, ensures the completed value is a legal - * value of the type by calling the `serialize` method of GraphQL type - * definition. - * - * If the field is an abstract type, determine the runtime type of the value - * and then complete based on that type - * - * Otherwise, the field type expects a sub-selection set, and will complete the - * value by executing all sub-selections. - */ -function completeValue( - exeContext: ExecutionContext, - returnType: GraphQLOutputType, - fieldNodes: ReadonlyArray, - info: GraphQLResolveInfo, - path: Path, - result: unknown, -): PromiseOrValue { - // If result is an Error, throw a located error. - if (result instanceof Error) { - throw result; - } - - // If field type is NonNull, complete for inner type, and throw field error - // if result is null. - if (isNonNullType(returnType)) { - const completed = completeValue( - exeContext, - returnType.ofType, - fieldNodes, - info, - path, - result, - ); - if (completed === null) { - throw new Error( - `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, - ); - } - return completed; - } - - // If result value is null or undefined then return null. - if (result == null) { - return null; - } - - // If field type is List, complete each item in the list with the inner type - if (isListType(returnType)) { - return completeListValue( - exeContext, - returnType, - fieldNodes, - info, - path, - result, - ); - } - - // If field type is a leaf type, Scalar or Enum, serialize to a valid value, - // returning null if serialization is not possible. - if (isLeafType(returnType)) { - return completeLeafValue(returnType, result); - } - - // If field type is an abstract type, Interface or Union, determine the - // runtime Object type and complete for that type. - if (isAbstractType(returnType)) { - return completeAbstractValue( - exeContext, - returnType, - fieldNodes, - info, - path, - result, - ); - } - - // If field type is Object, execute and complete all sub-selections. - // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618') - if (isObjectType(returnType)) { - return completeObjectValue( - exeContext, - returnType, - fieldNodes, - info, - path, - result, - ); - } - - // istanbul ignore next (Not reachable. All possible output types have been considered) - invariant( - false, - 'Cannot complete value of unexpected output type: ' + inspect(returnType), - ); -} - -/** - * Complete a list value by completing each item in the list with the - * inner type - */ -function completeListValue( - exeContext: ExecutionContext, - returnType: GraphQLList, - fieldNodes: ReadonlyArray, - info: GraphQLResolveInfo, - path: Path, - result: unknown, -): PromiseOrValue> { - if (!isIterableObject(result)) { - throw new GraphQLError( - `Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`, - ); - } - - // This is specified as a simple map, however we're optimizing the path - // where the list contains no Promises by avoiding creating another Promise. - const itemType = returnType.ofType; - let containsPromise = false; - const completedResults = Array.from(result, (item, index) => { - // No need to modify the info object containing the path, - // since from here on it is not ever accessed by resolver functions. - const itemPath = addPath(path, index, undefined); - try { - let completedItem; - if (isPromise(item)) { - completedItem = item.then((resolved) => - completeValue( - exeContext, - itemType, - fieldNodes, - info, - itemPath, - resolved, - ), - ); - } else { - completedItem = completeValue( - exeContext, - itemType, - fieldNodes, - info, - itemPath, - item, - ); - } - - if (isPromise(completedItem)) { - containsPromise = true; - // Note: we don't rely on a `catch` method, but we do expect "thenable" - // to take a second callback for the error case. - return completedItem.then(undefined, (rawError) => { - const error = locatedError( - rawError, - fieldNodes, - pathToArray(itemPath), - ); - return handleFieldError(error, itemType, exeContext); - }); - } - return completedItem; - } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); - return handleFieldError(error, itemType, exeContext); - } - }); - - return containsPromise ? Promise.all(completedResults) : completedResults; -} - -/** - * Complete a Scalar or Enum by serializing to a valid value, returning - * null if serialization is not possible. - */ -function completeLeafValue( - returnType: GraphQLLeafType, - result: unknown, -): unknown { - const serializedResult = returnType.serialize(result); - if (serializedResult === undefined) { - throw new Error( - `Expected a value of type "${inspect(returnType)}" but ` + - `received: ${inspect(result)}`, - ); - } - return serializedResult; -} - -/** - * Complete a value of an abstract type by determining the runtime object type - * of that value, then complete the value for that type. - */ -function completeAbstractValue( - exeContext: ExecutionContext, - returnType: GraphQLAbstractType, - fieldNodes: ReadonlyArray, - info: GraphQLResolveInfo, - path: Path, - result: unknown, -): PromiseOrValue> { - const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; - const contextValue = exeContext.contextValue; - const runtimeType = resolveTypeFn(result, contextValue, info, returnType); - - if (isPromise(runtimeType)) { - return runtimeType.then((resolvedRuntimeType) => - completeObjectValue( - exeContext, - ensureValidRuntimeType( - resolvedRuntimeType, - exeContext, - returnType, - fieldNodes, - info, - result, - ), - fieldNodes, - info, - path, - result, - ), - ); - } - - return completeObjectValue( - exeContext, - ensureValidRuntimeType( - runtimeType, - exeContext, - returnType, - fieldNodes, - info, - result, - ), - fieldNodes, - info, - path, - result, - ); -} - -function ensureValidRuntimeType( - runtimeTypeName: unknown, - exeContext: ExecutionContext, - returnType: GraphQLAbstractType, - fieldNodes: ReadonlyArray, - info: GraphQLResolveInfo, - result: unknown, -): GraphQLObjectType { - if (runtimeTypeName == null) { - throw new GraphQLError( - `Abstract type "${returnType.name}" must resolve to an Object type at runtime for field "${info.parentType.name}.${info.fieldName}". Either the "${returnType.name}" type should provide a "resolveType" function or each possible type should provide an "isTypeOf" function.`, - fieldNodes, - ); - } - - // releases before 16.0.0 supported returning `GraphQLObjectType` from `resolveType` - // TODO: remove in 17.0.0 release - if (isObjectType(runtimeTypeName)) { - throw new GraphQLError( - 'Support for returning GraphQLObjectType from resolveType was removed in graphql-js@16.0.0 please return type name instead.', - ); - } - - if (typeof runtimeTypeName !== 'string') { - throw new GraphQLError( - `Abstract type "${returnType.name}" must resolve to an Object type at runtime for field "${info.parentType.name}.${info.fieldName}" with ` + - `value ${inspect(result)}, received "${inspect(runtimeTypeName)}".`, - ); - } - - const runtimeType = exeContext.schema.getType(runtimeTypeName); - if (runtimeType == null) { - throw new GraphQLError( - `Abstract type "${returnType.name}" was resolved to a type "${runtimeTypeName}" that does not exist inside the schema.`, - fieldNodes, - ); - } - - if (!isObjectType(runtimeType)) { - throw new GraphQLError( - `Abstract type "${returnType.name}" was resolved to a non-object type "${runtimeTypeName}".`, - fieldNodes, - ); - } - - if (!exeContext.schema.isSubType(returnType, runtimeType)) { - throw new GraphQLError( - `Runtime Object type "${runtimeType.name}" is not a possible type for "${returnType.name}".`, - fieldNodes, - ); - } - - return runtimeType; -} - -/** - * Complete an Object value by executing all sub-selections. - */ -function completeObjectValue( - exeContext: ExecutionContext, - returnType: GraphQLObjectType, - fieldNodes: ReadonlyArray, - info: GraphQLResolveInfo, - path: Path, - result: unknown, -): PromiseOrValue> { - // Collect sub-fields to execute to complete this value. - const subFieldNodes = collectSubfields(exeContext, returnType, fieldNodes); - - // If there is an isTypeOf predicate function, call it with the - // current result. If isTypeOf returns false, then raise an error rather - // than continuing execution. - if (returnType.isTypeOf) { - const isTypeOf = returnType.isTypeOf(result, exeContext.contextValue, info); - - if (isPromise(isTypeOf)) { - return isTypeOf.then((resolvedIsTypeOf) => { - if (!resolvedIsTypeOf) { - throw invalidReturnTypeError(returnType, result, fieldNodes); - } - return executeFields( - exeContext, - returnType, - result, - path, - subFieldNodes, - ); - }); - } - - if (!isTypeOf) { - throw invalidReturnTypeError(returnType, result, fieldNodes); - } - } - - return executeFields(exeContext, returnType, result, path, subFieldNodes); -} - -function invalidReturnTypeError( - returnType: GraphQLObjectType, - result: unknown, - fieldNodes: ReadonlyArray, -): GraphQLError { - return new GraphQLError( - `Expected value of type "${returnType.name}" but got: ${inspect(result)}.`, - fieldNodes, - ); -} - -/** - * A memoized collection of relevant subfields with regard to the return - * type. Memoizing ensures the subfields are not repeatedly calculated, which - * saves overhead when resolving lists of values. - */ -const collectSubfields = memoize3(_collectSubfields); -function _collectSubfields( - exeContext: ExecutionContext, - returnType: GraphQLObjectType, - fieldNodes: ReadonlyArray, -): Map> { - let subFieldNodes = new Map(); - const visitedFragmentNames = new Set(); - for (const node of fieldNodes) { - if (node.selectionSet) { - subFieldNodes = collectFields( - exeContext, - returnType, - node.selectionSet, - subFieldNodes, - visitedFragmentNames, - ); - } - } - return subFieldNodes; -} - /** * If a resolveType function is not given, then a default resolve behavior is * used which attempts two strategies: @@ -1155,37 +335,3 @@ export const defaultFieldResolver: GraphQLFieldResolver = return property; } }; - -/** - * This method looks up the field on the given type definition. - * It has special casing for the three introspection fields, - * __schema, __type and __typename. __typename is special because - * it can always be queried as a field, even in situations where no - * other fields are allowed, like on a Union. __schema and __type - * could get automatically added to the query type, but that would - * require mutating type definitions, which would cause issues. - * - * @internal - */ -export function getFieldDef( - schema: GraphQLSchema, - parentType: GraphQLObjectType, - fieldNode: FieldNode, -): Maybe> { - const fieldName = fieldNode.name.value; - - if ( - fieldName === SchemaMetaFieldDef.name && - schema.getQueryType() === parentType - ) { - return SchemaMetaFieldDef; - } else if ( - fieldName === TypeMetaFieldDef.name && - schema.getQueryType() === parentType - ) { - return TypeMetaFieldDef; - } else if (fieldName === TypeNameMetaFieldDef.name) { - return TypeNameMetaFieldDef; - } - return parentType.getFields()[fieldName]; -} diff --git a/src/execution/executor.ts b/src/execution/executor.ts new file mode 100644 index 0000000000..ed877acb5f --- /dev/null +++ b/src/execution/executor.ts @@ -0,0 +1,735 @@ +import type { Path } from '../jsutils/Path'; +import type { ObjMap } from '../jsutils/ObjMap'; +import type { PromiseOrValue } from '../jsutils/PromiseOrValue'; +import type { Maybe } from '../jsutils/Maybe'; +import { inspect } from '../jsutils/inspect'; +import { memoize2 } from '../jsutils/memoize2'; +import { invariant } from '../jsutils/invariant'; +import { isPromise } from '../jsutils/isPromise'; +import { promiseReduce } from '../jsutils/promiseReduce'; +import { promiseForObject } from '../jsutils/promiseForObject'; +import { addPath, pathToArray } from '../jsutils/Path'; +import { isIterableObject } from '../jsutils/isIterableObject'; + +import { GraphQLError } from '../error/GraphQLError'; +import { locatedError } from '../error/locatedError'; + +import type { + OperationDefinitionNode, + FieldNode, + FragmentDefinitionNode, +} from '../language/ast'; + +import type { GraphQLSchema } from '../type/schema'; +import type { + GraphQLObjectType, + GraphQLOutputType, + GraphQLLeafType, + GraphQLAbstractType, + GraphQLField, + GraphQLFieldResolver, + GraphQLResolveInfo, + GraphQLTypeResolver, + GraphQLList, +} from '../type/definition'; +import { + SchemaMetaFieldDef, + TypeMetaFieldDef, + TypeNameMetaFieldDef, +} from '../type/introspection'; +import { + isObjectType, + isAbstractType, + isLeafType, + isListType, + isNonNullType, +} from '../type/definition'; + +import { getOperationRootType } from '../utilities/getOperationRootType'; + +import type { ExecutionContext, ExecutionResult } from './execute'; +import { getArgumentValues } from './values'; +import { collectFields } from './collectFields'; + +/** + * This class is exported only to assist people in implementing their own executors + * without duplicating too much code and should be used only as last resort for cases + * requiring custom execution or if certain features could not be contributed upstream. + * + * It is still part of the internal API and is versioned, so any changes to it are never + * considered breaking changes. If you still need to support multiple versions of the + * library, please use the `versionInfo` variable for version detection. + * + * @internal + */ +export class Executor { + /** + * A memoized collection of relevant subfields with regard to the return + * type. Memoizing ensures the subfields are not repeatedly calculated, which + * saves overhead when resolving lists of values. + */ + collectSubfields = memoize2( + (returnType: GraphQLObjectType, fieldNodes: ReadonlyArray) => + this._collectSubfields(returnType, fieldNodes), + ); + + protected _schema: GraphQLSchema; + protected _fragments: ObjMap; + protected _rootValue: unknown; + protected _contextValue: unknown; + protected _operation: OperationDefinitionNode; + protected _variableValues: { [variable: string]: unknown }; + protected _fieldResolver: GraphQLFieldResolver; + protected _typeResolver: GraphQLTypeResolver; + protected _errors: Array; + + constructor({ + schema, + fragments, + rootValue, + contextValue, + operation, + variableValues, + fieldResolver, + typeResolver, + errors, + }: ExecutionContext) { + this._schema = schema; + this._fragments = fragments; + this._rootValue = rootValue; + this._contextValue = contextValue; + this._operation = operation; + this._variableValues = variableValues; + this._fieldResolver = fieldResolver; + this._typeResolver = typeResolver; + this._errors = errors; + } + + /** + * Implements the "Executing operations" section of the spec. + */ + executeOperation(): PromiseOrValue | null> { + const { _schema, _fragments, _rootValue, _operation, _variableValues } = + this; + const type = getOperationRootType(_schema, _operation); + const fields = collectFields( + _schema, + _fragments, + _variableValues, + type, + _operation.selectionSet, + new Map(), + new Set(), + ); + + const path = undefined; + + // Errors from sub-fields of a NonNull type may propagate to the top level, + // at which point we still log the error and null the parent field, which + // in this case is the entire response. + try { + const result = + _operation.operation === 'mutation' + ? this.executeFieldsSerially(type, _rootValue, path, fields) + : this.executeFields(type, _rootValue, path, fields); + if (isPromise(result)) { + return result.then(undefined, (error) => { + this._errors.push(error); + return Promise.resolve(null); + }); + } + return result; + } catch (error) { + this._errors.push(error); + return null; + } + } + + /** + * Given a completed execution context and data, build the { errors, data } + * response defined by the "Response" section of the GraphQL specification. + */ + buildResponse( + data: PromiseOrValue | null>, + ): PromiseOrValue { + if (isPromise(data)) { + return data.then((resolved) => this.buildResponse(resolved)); + } + return this._errors.length === 0 + ? { data } + : { errors: this._errors, data }; + } + + /** + * Implements the "Executing selection sets" section of the spec + * for fields that must be executed serially. + */ + executeFieldsSerially( + parentType: GraphQLObjectType, + sourceValue: unknown, + path: Path | undefined, + fields: Map>, + ): PromiseOrValue> { + return promiseReduce( + fields.entries(), + (results, [responseName, fieldNodes]) => { + const fieldPath = addPath(path, responseName, parentType.name); + const result = this.executeField( + parentType, + sourceValue, + fieldNodes, + fieldPath, + ); + if (result === undefined) { + return results; + } + if (isPromise(result)) { + return result.then((resolvedResult) => { + results[responseName] = resolvedResult; + return results; + }); + } + results[responseName] = result; + return results; + }, + Object.create(null), + ); + } + + /** + * Implements the "Executing selection sets" section of the spec + * for fields that may be executed in parallel. + */ + executeFields( + parentType: GraphQLObjectType, + sourceValue: unknown, + path: Path | undefined, + fields: Map>, + ): PromiseOrValue> { + const results = Object.create(null); + let containsPromise = false; + + for (const [responseName, fieldNodes] of fields.entries()) { + const fieldPath = addPath(path, responseName, parentType.name); + const result = this.executeField( + parentType, + sourceValue, + fieldNodes, + fieldPath, + ); + + if (result !== undefined) { + results[responseName] = result; + if (isPromise(result)) { + containsPromise = true; + } + } + } + + // If there are no promises, we can just return the object + if (!containsPromise) { + return results; + } + + // Otherwise, results is a map from field name to the result of resolving that + // field, which is possibly a promise. Return a promise that will return this + // same map, but with any promises replaced with the values they resolved to. + return promiseForObject(results); + } + + /** + * Implements the "Executing field" section of the spec + * In particular, this function figures out the value that the field returns by + * calling its resolve function, then calls completeValue to complete promises, + * serialize scalars, or execute the sub-selection-set for objects. + */ + executeField( + parentType: GraphQLObjectType, + source: unknown, + fieldNodes: ReadonlyArray, + path: Path, + ): PromiseOrValue { + const { _schema, _contextValue, _variableValues, _fieldResolver } = this; + + const fieldDef = getFieldDef(_schema, parentType, fieldNodes[0]); + if (!fieldDef) { + return; + } + + const returnType = fieldDef.type; + const resolveFn = fieldDef.resolve ?? _fieldResolver; + + const info = this.buildResolveInfo(fieldDef, fieldNodes, parentType, path); + + // Get the resolve function, regardless of if its result is normal or abrupt (error). + try { + // Build a JS object of arguments from the field.arguments AST, using the + // variables scope to fulfill any variable references. + // TODO: find a way to memoize, in case this field is within a List type. + const args = getArgumentValues(fieldDef, fieldNodes[0], _variableValues); + + // The resolve function's optional third argument is a context value that + // is provided to every resolve function within an execution. It is commonly + // used to represent an authenticated user, or request-specific caches. + const result = resolveFn(source, args, _contextValue, info); + + let completed; + if (isPromise(result)) { + completed = result.then((resolved) => + this.completeValue(returnType, fieldNodes, info, path, resolved), + ); + } else { + completed = this.completeValue( + returnType, + fieldNodes, + info, + path, + result, + ); + } + + if (isPromise(completed)) { + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + return completed.then(undefined, (rawError) => { + const error = locatedError(rawError, fieldNodes, pathToArray(path)); + return this.handleFieldError(error, returnType); + }); + } + return completed; + } catch (rawError) { + const error = locatedError(rawError, fieldNodes, pathToArray(path)); + return this.handleFieldError(error, returnType); + } + } + + /** + * @internal + */ + buildResolveInfo( + fieldDef: GraphQLField, + fieldNodes: ReadonlyArray, + parentType: GraphQLObjectType, + path: Path, + ): GraphQLResolveInfo { + const { _schema, _fragments, _rootValue, _operation, _variableValues } = + this; + + // The resolve function's optional fourth argument is a collection of + // information about the current execution state. + return { + fieldName: fieldDef.name, + fieldNodes, + returnType: fieldDef.type, + parentType, + path, + schema: _schema, + fragments: _fragments, + rootValue: _rootValue, + operation: _operation, + variableValues: _variableValues, + }; + } + + handleFieldError(error: GraphQLError, returnType: GraphQLOutputType): null { + // If the field type is non-nullable, then it is resolved without any + // protection from errors, however it still properly locates the error. + if (isNonNullType(returnType)) { + throw error; + } + + // Otherwise, error protection is applied, logging the error and resolving + // a null value for this field if one is encountered. + this._errors.push(error); + return null; + } + + /** + * Implements the instructions for completeValue as defined in the + * "Field entries" section of the spec. + * + * If the field type is Non-Null, then this recursively completes the value + * for the inner type. It throws a field error if that completion returns null, + * as per the "Nullability" section of the spec. + * + * If the field type is a List, then this recursively completes the value + * for the inner type on each item in the list. + * + * If the field type is a Scalar or Enum, ensures the completed value is a legal + * value of the type by calling the `serialize` method of GraphQL type + * definition. + * + * If the field is an abstract type, determine the runtime type of the value + * and then complete based on that type + * + * Otherwise, the field type expects a sub-selection set, and will complete the + * value by executing all sub-selections. + */ + completeValue( + returnType: GraphQLOutputType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: Path, + result: unknown, + ): PromiseOrValue { + // If result is an Error, throw a located error. + if (result instanceof Error) { + throw result; + } + + // If field type is NonNull, complete for inner type, and throw field error + // if result is null. + if (isNonNullType(returnType)) { + const completed = this.completeValue( + returnType.ofType, + fieldNodes, + info, + path, + result, + ); + if (completed === null) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, + ); + } + return completed; + } + + // If result value is null or undefined then return null. + if (result == null) { + return null; + } + + // If field type is List, complete each item in the list with the inner type + if (isListType(returnType)) { + return this.completeListValue(returnType, fieldNodes, info, path, result); + } + + // If field type is a leaf type, Scalar or Enum, serialize to a valid value, + // returning null if serialization is not possible. + if (isLeafType(returnType)) { + return this.completeLeafValue(returnType, result); + } + + // If field type is an abstract type, Interface or Union, determine the + // runtime Object type and complete for that type. + if (isAbstractType(returnType)) { + return this.completeAbstractValue( + returnType, + fieldNodes, + info, + path, + result, + ); + } + + // If field type is Object, execute and complete all sub-selections. + // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618') + if (isObjectType(returnType)) { + return this.completeObjectValue( + returnType, + fieldNodes, + info, + path, + result, + ); + } + + // istanbul ignore next (Not reachable. All possible output types have been considered) + invariant( + false, + 'Cannot complete value of unexpected output type: ' + inspect(returnType), + ); + } + + /** + * Complete a list value by completing each item in the list with the + * inner type + */ + completeListValue( + returnType: GraphQLList, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: Path, + result: unknown, + ): PromiseOrValue> { + if (!isIterableObject(result)) { + throw new GraphQLError( + `Expected Iterable, but did not find one for field "${info.parentType.name}.${info.fieldName}".`, + ); + } + + // This is specified as a simple map, however we're optimizing the path + // where the list contains no Promises by avoiding creating another Promise. + const itemType = returnType.ofType; + let containsPromise = false; + const completedResults = Array.from(result, (item, index) => { + // No need to modify the info object containing the path, + // since from here on it is not ever accessed by resolver functions. + const itemPath = addPath(path, index, undefined); + try { + let completedItem; + if (isPromise(item)) { + completedItem = item.then((resolved) => + this.completeValue(itemType, fieldNodes, info, itemPath, resolved), + ); + } else { + completedItem = this.completeValue( + itemType, + fieldNodes, + info, + itemPath, + item, + ); + } + + if (isPromise(completedItem)) { + containsPromise = true; + // Note: we don't rely on a `catch` method, but we do expect "thenable" + // to take a second callback for the error case. + return completedItem.then(undefined, (rawError) => { + const error = locatedError( + rawError, + fieldNodes, + pathToArray(itemPath), + ); + return this.handleFieldError(error, itemType); + }); + } + return completedItem; + } catch (rawError) { + const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + return this.handleFieldError(error, itemType); + } + }); + + return containsPromise ? Promise.all(completedResults) : completedResults; + } + + /** + * Complete a Scalar or Enum by serializing to a valid value, returning + * null if serialization is not possible. + */ + completeLeafValue(returnType: GraphQLLeafType, result: unknown): unknown { + const serializedResult = returnType.serialize(result); + if (serializedResult === undefined) { + throw new Error( + `Expected a value of type "${inspect(returnType)}" but ` + + `received: ${inspect(result)}`, + ); + } + return serializedResult; + } + + /** + * Complete a value of an abstract type by determining the runtime object type + * of that value, then complete the value for that type. + */ + completeAbstractValue( + returnType: GraphQLAbstractType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: Path, + result: unknown, + ): PromiseOrValue> { + const { _contextValue, _typeResolver } = this; + + const resolveTypeFn = returnType.resolveType ?? _typeResolver; + const runtimeType = resolveTypeFn(result, _contextValue, info, returnType); + + if (isPromise(runtimeType)) { + return runtimeType.then((resolvedRuntimeType) => + this.completeObjectValue( + this.ensureValidRuntimeType( + resolvedRuntimeType, + returnType, + fieldNodes, + info, + result, + ), + fieldNodes, + info, + path, + result, + ), + ); + } + + return this.completeObjectValue( + this.ensureValidRuntimeType( + runtimeType, + returnType, + fieldNodes, + info, + result, + ), + fieldNodes, + info, + path, + result, + ); + } + + ensureValidRuntimeType( + runtimeTypeName: unknown, + returnType: GraphQLAbstractType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + result: unknown, + ): GraphQLObjectType { + if (runtimeTypeName == null) { + throw new GraphQLError( + `Abstract type "${returnType.name}" must resolve to an Object type at runtime for field "${info.parentType.name}.${info.fieldName}". Either the "${returnType.name}" type should provide a "resolveType" function or each possible type should provide an "isTypeOf" function.`, + fieldNodes, + ); + } + + // releases before 16.0.0 supported returning `GraphQLObjectType` from `resolveType` + // TODO: remove in 17.0.0 release + if (isObjectType(runtimeTypeName)) { + throw new GraphQLError( + 'Support for returning GraphQLObjectType from resolveType was removed in graphql-js@16.0.0 please return type name instead.', + ); + } + + if (typeof runtimeTypeName !== 'string') { + throw new GraphQLError( + `Abstract type "${returnType.name}" must resolve to an Object type at runtime for field "${info.parentType.name}.${info.fieldName}" with ` + + `value ${inspect(result)}, received "${inspect(runtimeTypeName)}".`, + ); + } + + const runtimeType = this._schema.getType(runtimeTypeName); + if (runtimeType == null) { + throw new GraphQLError( + `Abstract type "${returnType.name}" was resolved to a type "${runtimeTypeName}" that does not exist inside the schema.`, + fieldNodes, + ); + } + + if (!isObjectType(runtimeType)) { + throw new GraphQLError( + `Abstract type "${returnType.name}" was resolved to a non-object type "${runtimeTypeName}".`, + fieldNodes, + ); + } + + if (!this._schema.isSubType(returnType, runtimeType)) { + throw new GraphQLError( + `Runtime Object type "${runtimeType.name}" is not a possible type for "${returnType.name}".`, + fieldNodes, + ); + } + + return runtimeType; + } + + /** + * Complete an Object value by executing all sub-selections. + */ + completeObjectValue( + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: Path, + result: unknown, + ): PromiseOrValue> { + // Collect sub-fields to execute to complete this value. + const subFieldNodes = this.collectSubfields(returnType, fieldNodes); + + // If there is an isTypeOf predicate function, call it with the + // current result. If isTypeOf returns false, then raise an error rather + // than continuing execution. + if (returnType.isTypeOf) { + const isTypeOf = returnType.isTypeOf(result, this._contextValue, info); + + if (isPromise(isTypeOf)) { + return isTypeOf.then((resolvedIsTypeOf) => { + if (!resolvedIsTypeOf) { + throw invalidReturnTypeError(returnType, result, fieldNodes); + } + return this.executeFields(returnType, result, path, subFieldNodes); + }); + } + + if (!isTypeOf) { + throw invalidReturnTypeError(returnType, result, fieldNodes); + } + } + + return this.executeFields(returnType, result, path, subFieldNodes); + } + + /** + * A collection of relevant subfields with regard to the return type. + * See 'collectSubfields' above for the memoized version. + */ + protected _collectSubfields( + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, + ): Map> { + const { _schema, _fragments, _variableValues } = this; + + let subFieldNodes = new Map(); + const visitedFragmentNames = new Set(); + for (const node of fieldNodes) { + if (node.selectionSet) { + subFieldNodes = collectFields( + _schema, + _fragments, + _variableValues, + returnType, + node.selectionSet, + subFieldNodes, + visitedFragmentNames, + ); + } + } + return subFieldNodes; + } +} + +function invalidReturnTypeError( + returnType: GraphQLObjectType, + result: unknown, + fieldNodes: ReadonlyArray, +): GraphQLError { + return new GraphQLError( + `Expected value of type "${returnType.name}" but got: ${inspect(result)}.`, + fieldNodes, + ); +} + +/** + * This method looks up the field on the given type definition. + * It has special casing for the three introspection fields, + * __schema, __type and __typename. __typename is special because + * it can always be queried as a field, even in situations where no + * other fields are allowed, like on a Union. __schema and __type + * could get automatically added to the query type, but that would + * require mutating type definitions, which would cause issues. + * + * @internal + */ +export function getFieldDef( + schema: GraphQLSchema, + parentType: GraphQLObjectType, + fieldNode: FieldNode, +): Maybe> { + const fieldName = fieldNode.name.value; + + if ( + fieldName === SchemaMetaFieldDef.name && + schema.getQueryType() === parentType + ) { + return SchemaMetaFieldDef; + } else if ( + fieldName === TypeMetaFieldDef.name && + schema.getQueryType() === parentType + ) { + return TypeMetaFieldDef; + } else if (fieldName === TypeNameMetaFieldDef.name) { + return TypeNameMetaFieldDef; + } + return parentType.getFields()[fieldName]; +} diff --git a/src/execution/index.ts b/src/execution/index.ts index 5ae0706ec9..0acd3d1032 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -1,5 +1,7 @@ export { pathToArray as responsePathAsArray } from '../jsutils/Path'; +export { Executor } from './executor'; + export { execute, executeSync, diff --git a/src/graphql.ts b/src/graphql.ts index 03e6b95882..e42f1872ce 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -14,6 +14,7 @@ import type { import type { GraphQLSchema } from './type/schema'; import { validateSchema } from './type/validate'; +import type { Executor } from './execution/executor'; import type { ExecutionResult } from './execution/execute'; import { execute } from './execution/execute'; @@ -55,6 +56,18 @@ import { execute } from './execution/execute'; * A type resolver function to use when none is provided by the schema. * If not provided, the default type resolver is used (which looks for a * `__typename` field or alternatively calls the `isTypeOf` method). + * CustomExecutor: + * A custom Executor class to allow overriding execution behavior. + * + * Note: The Executor class is exported only to assist people in + * implementing their own executors without duplicating too much code and + * should be used only as last resort for cases requiring custom execution + * or if certain features could not be contributed upstream. + * + * It is still part of the internal API and is versioned, so any changes to + * it are never considered breaking changes. If you still need to support + * multiple versions of the library, please use the `versionInfo` variable + * for version detection. */ export interface GraphQLArgs { schema: GraphQLSchema; @@ -65,6 +78,7 @@ export interface GraphQLArgs { operationName?: Maybe; fieldResolver?: Maybe>; typeResolver?: Maybe>; + CustomExecutor?: Maybe; } export function graphql(args: GraphQLArgs): Promise { @@ -99,6 +113,7 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + CustomExecutor, } = args; // Validate Schema @@ -131,5 +146,6 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + CustomExecutor, }); } diff --git a/src/index.ts b/src/index.ts index b9aec6a43a..b2bccf4deb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -299,6 +299,7 @@ export type { /** Execute GraphQL queries. */ export { + Executor, execute, executeSync, defaultFieldResolver, diff --git a/src/jsutils/memoize2.ts b/src/jsutils/memoize2.ts new file mode 100644 index 0000000000..f63c3f5c11 --- /dev/null +++ b/src/jsutils/memoize2.ts @@ -0,0 +1,28 @@ +/** + * Memoizes the provided three-argument function. + */ +export function memoize2( + fn: (a1: A1, a2: A2) => R, +): (a1: A1, a2: A2) => R { + let cache0: WeakMap>; + + return function memoized(a1, a2) { + if (cache0 === undefined) { + cache0 = new WeakMap(); + } + + let cache1 = cache0.get(a1); + if (cache1 === undefined) { + cache1 = new WeakMap(); + cache0.set(a1, cache1); + } + + let fnResult = cache1.get(a2); + if (fnResult === undefined) { + fnResult = fn(a1, a2); + cache1.set(a2, fnResult); + } + + return fnResult; + }; +} diff --git a/src/jsutils/memoize3.ts b/src/jsutils/memoize3.ts deleted file mode 100644 index 213cb95d10..0000000000 --- a/src/jsutils/memoize3.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Memoizes the provided three-argument function. - */ -export function memoize3< - A1 extends object, - A2 extends object, - A3 extends object, - R, ->(fn: (a1: A1, a2: A2, a3: A3) => R): (a1: A1, a2: A2, a3: A3) => R { - let cache0: WeakMap>>; - - return function memoized(a1, a2, a3) { - if (cache0 === undefined) { - cache0 = new WeakMap(); - } - - let cache1 = cache0.get(a1); - if (cache1 === undefined) { - cache1 = new WeakMap(); - cache0.set(a1, cache1); - } - - let cache2 = cache1.get(a2); - if (cache2 === undefined) { - cache2 = new WeakMap(); - cache1.set(a2, cache2); - } - - let fnResult = cache2.get(a3); - if (fnResult === undefined) { - fnResult = fn(a1, a2, a3); - cache2.set(a3, fnResult); - } - - return fnResult; - }; -} diff --git a/src/subscription/subscribe.ts b/src/subscription/subscribe.ts index 6b4c6c13bf..6ee9e36c75 100644 --- a/src/subscription/subscribe.ts +++ b/src/subscription/subscribe.ts @@ -8,15 +8,14 @@ import { locatedError } from '../error/locatedError'; import type { DocumentNode } from '../language/ast'; -import type { ExecutionResult, ExecutionContext } from '../execution/execute'; +import type { ExecutionResult } from '../execution/execute'; +import { collectFields } from '../execution/collectFields'; import { getArgumentValues } from '../execution/values'; +import { Executor, getFieldDef } from '../execution/executor'; import { assertValidExecutionArguments, buildExecutionContext, - buildResolveInfo, - collectFields, execute, - getFieldDef, } from '../execution/execute'; import type { GraphQLSchema } from '../type/schema'; @@ -35,6 +34,7 @@ export interface SubscriptionArgs { operationName?: Maybe; fieldResolver?: Maybe>; subscribeFieldResolver?: Maybe>; + CustomExecutor?: Maybe; } /** @@ -70,6 +70,7 @@ export async function subscribe( operationName, fieldResolver, subscribeFieldResolver, + CustomExecutor, } = args; const resultOrStream = await createSourceEventStream( @@ -80,6 +81,7 @@ export async function subscribe( variableValues, operationName, subscribeFieldResolver, + CustomExecutor, ); if (!isAsyncIterable(resultOrStream)) { @@ -101,6 +103,7 @@ export async function subscribe( variableValues, operationName, fieldResolver, + CustomExecutor, }); // Map every source value to a ExecutionResult value as described above. @@ -143,6 +146,7 @@ export async function createSourceEventStream( variableValues?: Maybe<{ readonly [variable: string]: unknown }>, operationName?: Maybe, fieldResolver?: Maybe>, + CustomExecutor?: Maybe, ): Promise | ExecutionResult> { // If arguments are missing or incorrectly typed, this is an internal // developer mistake which should throw an early error. @@ -165,7 +169,9 @@ export async function createSourceEventStream( return { errors: exeContext }; } - const eventStream = await executeSubscription(exeContext); + const executor = new (CustomExecutor ?? SubscriptionExecutor)(exeContext); + + const eventStream = await executor.executeSubscription(); // Assert field returned an event stream, otherwise yield an error. if (!isAsyncIterable(eventStream)) { @@ -186,55 +192,69 @@ export async function createSourceEventStream( } } -async function executeSubscription( - exeContext: ExecutionContext, -): Promise { - const { schema, operation, variableValues, rootValue } = exeContext; - const type = getOperationRootType(schema, operation); - const fields = collectFields( - exeContext, - type, - operation.selectionSet, - new Map(), - new Set(), - ); - const [responseName, fieldNodes] = [...fields.entries()][0]; - const fieldDef = getFieldDef(schema, type, fieldNodes[0]); - - if (!fieldDef) { - const fieldName = fieldNodes[0].name.value; - throw new GraphQLError( - `The subscription field "${fieldName}" is not defined.`, - fieldNodes, +class SubscriptionExecutor extends Executor { + public async executeSubscription(): Promise { + const { + _schema, + _fragments, + _rootValue, + _contextValue, + _operation, + _variableValues, + _fieldResolver, + } = this; + const type = getOperationRootType(_schema, _operation); + const fields = collectFields( + _schema, + _fragments, + _variableValues, + type, + _operation.selectionSet, + new Map(), + new Set(), ); - } + const [responseName, fieldNodes] = [...fields.entries()][0]; + const fieldDef = getFieldDef(_schema, type, fieldNodes[0]); + + if (!fieldDef) { + const fieldName = fieldNodes[0].name.value; + throw new GraphQLError( + `The subscription field "${fieldName}" is not defined.`, + fieldNodes, + ); + } - const path = addPath(undefined, responseName, type.name); - const info = buildResolveInfo(exeContext, fieldDef, fieldNodes, type, path); + const path = addPath(undefined, responseName, type.name); + const info = this.buildResolveInfo(fieldDef, fieldNodes, type, path); - try { - // Implements the "ResolveFieldEventStream" algorithm from GraphQL specification. - // It differs from "ResolveFieldValue" due to providing a different `resolveFn`. + try { + // Implements the "ResolveFieldEventStream" algorithm from GraphQL specification. + // It differs from "ResolveFieldValue" due to providing a different `resolveFn`. - // Build a JS object of arguments from the field.arguments AST, using the - // variables scope to fulfill any variable references. - const args = getArgumentValues(fieldDef, fieldNodes[0], variableValues); + // Build a JS object of arguments from the field.arguments AST, using the + // variables scope to fulfill any variable references. + const args = getArgumentValues(fieldDef, fieldNodes[0], _variableValues); - // The resolve function's optional third argument is a context value that - // is provided to every resolve function within an execution. It is commonly - // used to represent an authenticated user, or request-specific caches. - const contextValue = exeContext.contextValue; + // Call the `subscribe()` resolver or the default resolver to produce an + // AsyncIterable yielding raw payloads. + const resolveFn = fieldDef.subscribe ?? _fieldResolver; - // Call the `subscribe()` resolver or the default resolver to produce an - // AsyncIterable yielding raw payloads. - const resolveFn = fieldDef.subscribe ?? exeContext.fieldResolver; - const eventStream = await resolveFn(rootValue, args, contextValue, info); + // The resolve function's optional third argument is a context value that + // is provided to every resolve function within an execution. It is commonly + // used to represent an authenticated user, or request-specific caches. + const eventStream = await resolveFn( + _rootValue, + args, + _contextValue, + info, + ); - if (eventStream instanceof Error) { - throw eventStream; + if (eventStream instanceof Error) { + throw eventStream; + } + return eventStream; + } catch (error) { + throw locatedError(error, fieldNodes, pathToArray(path)); } - return eventStream; - } catch (error) { - throw locatedError(error, fieldNodes, pathToArray(path)); } } diff --git a/src/validation/rules/SingleFieldSubscriptionsRule.ts b/src/validation/rules/SingleFieldSubscriptionsRule.ts index 0098ddc3d7..736f0f006f 100644 --- a/src/validation/rules/SingleFieldSubscriptionsRule.ts +++ b/src/validation/rules/SingleFieldSubscriptionsRule.ts @@ -8,12 +8,7 @@ import type { } from '../../language/ast'; import { Kind } from '../../language/kinds'; -import type { ExecutionContext } from '../../execution/execute'; -import { - collectFields, - defaultFieldResolver, - defaultTypeResolver, -} from '../../execution/execute'; +import { collectFields } from '../../execution/collectFields'; import type { ValidationContext } from '../ValidationContext'; @@ -43,20 +38,10 @@ export function SingleFieldSubscriptionsRule( fragments[definition.name.value] = definition; } } - // FIXME: refactor out `collectFields` into utility function that doesn't need fake context. - const fakeExecutionContext: ExecutionContext = { + const fields = collectFields( schema, fragments, - rootValue: undefined, - contextValue: undefined, - operation: node, variableValues, - fieldResolver: defaultFieldResolver, - typeResolver: defaultTypeResolver, - errors: [], - }; - const fields = collectFields( - fakeExecutionContext, subscriptionType, node.selectionSet, new Map(),