diff --git a/src/delegate/results/handleObject.ts b/src/delegate/results/handleObject.ts index d1fa0d97323..99bdf89cc21 100644 --- a/src/delegate/results/handleObject.ts +++ b/src/delegate/results/handleObject.ts @@ -6,7 +6,9 @@ import { FieldNode, GraphQLObjectType, } from 'graphql'; -import { collectFields, ExecutionContext } from 'graphql/execution/execute'; +import { ExecutionContext } from 'graphql/execution/execute'; + +import { collectFields } from '../../utils/collectFields'; import { SubschemaConfig, diff --git a/src/utils/SchemaDirectiveVisitor.ts b/src/utils/SchemaDirectiveVisitor.ts index 76291e0a547..23fa03f2762 100644 --- a/src/utils/SchemaDirectiveVisitor.ts +++ b/src/utils/SchemaDirectiveVisitor.ts @@ -4,7 +4,6 @@ import { DirectiveLocationEnum, TypeSystemExtensionNode, } from 'graphql'; -import { getArgumentValues } from 'graphql/execution/values'; import { VisitableSchemaType } from '../Interfaces'; @@ -12,6 +11,7 @@ import each from './each'; import valueFromASTUntyped from './valueFromASTUntyped'; import { SchemaVisitor } from './SchemaVisitor'; import { visitSchema } from './visitSchema'; +import { getArgumentValues } from './getArgumentValues'; const hasOwn = Object.prototype.hasOwnProperty; diff --git a/src/utils/collectFields.ts b/src/utils/collectFields.ts new file mode 100644 index 00000000000..a6ac4a7d6b4 --- /dev/null +++ b/src/utils/collectFields.ts @@ -0,0 +1,152 @@ +import { ExecutionContext } from 'graphql/execution/execute'; +import { + GraphQLObjectType, + SelectionSetNode, + FieldNode, + Kind, + FragmentSpreadNode, + InlineFragmentNode, + getDirectiveValues, + GraphQLSkipDirective, + GraphQLIncludeDirective, + FragmentDefinitionNode, + typeFromAST, + isAbstractType, +} from 'graphql'; + +/** + * 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: Record>, + visitedFragmentNames: Record, +): Record> { + for (const selection of selectionSet.selections) { + switch (selection.kind) { + case Kind.FIELD: { + if (!shouldIncludeNode(exeContext, selection)) { + continue; + } + const name = getFieldEntryKey(selection); + if (!fields[name]) { + fields[name] = []; + } + fields[name].push(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[fragName] || + !shouldIncludeNode(exeContext, selection) + ) { + continue; + } + visitedFragmentNames[fragName] = true; + 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.isPossibleType(conditionalType, type); + } + return false; +} + +/** + * Implements the logic to compute the key of a given field's entry + */ +function getFieldEntryKey(node: FieldNode): string { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return node.alias ? node.alias.value : node.name.value; +} diff --git a/src/utils/getArgumentValues.ts b/src/utils/getArgumentValues.ts new file mode 100644 index 00000000000..81d3800aa30 --- /dev/null +++ b/src/utils/getArgumentValues.ts @@ -0,0 +1,101 @@ +import { + valueFromAST, + GraphQLField, + GraphQLDirective, + DirectiveNode, + FieldNode, + isNonNullType, + GraphQLError, + Kind, + print, +} from 'graphql'; + +import { keyMap } from './keyMap'; +import { inspect } from './inspect'; + +/** + * Prepares an object map of argument values given a list of argument + * definitions and list of argument AST nodes. + * + * Note: The returned value is a plain Object with a prototype, since it is + * exposed to user code. Care should be taken to not pull values from the + * Object prototype. + */ +export function getArgumentValues( + def: GraphQLField | GraphQLDirective, + node: FieldNode | DirectiveNode, + variableValues?: { [variableName: string]: any }, +): { [argument: string]: any } { + const coercedValues = {}; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const argumentNodes = node.arguments ?? []; + const argNodeMap = keyMap(argumentNodes, (arg) => arg.name.value); + + for (const argDef of def.args) { + const name = argDef.name; + const argType = argDef.type; + const argumentNode = argNodeMap[name]; + + if (!argumentNode) { + if (argDef.defaultValue !== undefined) { + coercedValues[name] = argDef.defaultValue; + } else if (isNonNullType(argType)) { + throw new GraphQLError( + `Argument "${name}" of required type "${inspect(argType)}" ` + + 'was not provided.', + node, + ); + } + continue; + } + + const valueNode = argumentNode.value; + let isNull = valueNode.kind === Kind.NULL; + + if (valueNode.kind === Kind.VARIABLE) { + const variableName = valueNode.name.value; + if ( + variableValues == null || + !hasOwnProperty(variableValues, variableName) + ) { + if (argDef.defaultValue !== undefined) { + coercedValues[name] = argDef.defaultValue; + } else if (isNonNullType(argType)) { + throw new GraphQLError( + `Argument "${name}" of required type "${inspect(argType)}" ` + + `was provided the variable "$${variableName}" which was not provided a runtime value.`, + valueNode, + ); + } + continue; + } + isNull = variableValues[variableName] == null; + } + + if (isNull && isNonNullType(argType)) { + throw new GraphQLError( + `Argument "${name}" of non-null type "${inspect(argType)}" ` + + 'must not be null.', + valueNode, + ); + } + + const coercedValue = valueFromAST(valueNode, argType, variableValues); + if (coercedValue === undefined) { + // Note: ValuesOfCorrectTypeRule validation should catch this before + // execution. This is a runtime check to ensure execution does not + // continue with an invalid argument value. + throw new GraphQLError( + `Argument "${name}" has invalid value ${print(valueNode)}.`, + valueNode, + ); + } + coercedValues[name] = coercedValue; + } + return coercedValues; +} + +function hasOwnProperty(obj: any, prop: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, prop); +} diff --git a/src/utils/inspect.ts b/src/utils/inspect.ts new file mode 100644 index 00000000000..2ffd57ec5d8 --- /dev/null +++ b/src/utils/inspect.ts @@ -0,0 +1,120 @@ +const MAX_ARRAY_LENGTH = 10; +const MAX_RECURSIVE_DEPTH = 2; + +/** + * Used to print values in error messages. + */ +export function inspect(value: any): string { + return formatValue(value, []); +} + +function formatValue(value: any, seenValues: Array): string { + switch (typeof value) { + case 'string': + return JSON.stringify(value); + case 'function': + return value.name + ? `[function ${(value as Function).name}]` + : '[function]'; + case 'object': + if (value === null) { + return 'null'; + } + return formatObjectValue(value, seenValues); + default: + return String(value); + } +} + +function formatObjectValue( + value: any, + previouslySeenValues: Array, +): string { + if (previouslySeenValues.indexOf(value) !== -1) { + return '[Circular]'; + } + + const seenValues = [...previouslySeenValues, value]; + const customInspectFn = getCustomFn(value); + + if (customInspectFn !== undefined) { + const customValue = customInspectFn.call(value); + + // check for infinite recursion + if (customValue !== value) { + return typeof customValue === 'string' + ? customValue + : formatValue(customValue, seenValues); + } + } else if (Array.isArray(value)) { + return formatArray(value, seenValues); + } + + return formatObject(value, seenValues); +} + +function formatObject(object: any, seenValues: Array) { + const keys = Object.keys(object); + if (keys.length === 0) { + return '{}'; + } + + if (seenValues.length > MAX_RECURSIVE_DEPTH) { + return '[' + getObjectTag(object) + ']'; + } + + const properties = keys.map((key) => { + const value = formatValue(object[key], seenValues); + return key + ': ' + value; + }); + + return '{ ' + properties.join(', ') + ' }'; +} + +function formatArray(array: Array, seenValues: Array): string { + if (array.length === 0) { + return '[]'; + } + + if (seenValues.length > MAX_RECURSIVE_DEPTH) { + return '[Array]'; + } + + const len = Math.min(MAX_ARRAY_LENGTH, array.length); + const remaining = array.length - len; + const items = []; + + for (let i = 0; i < len; ++i) { + items.push(formatValue(array[i], seenValues)); + } + + if (remaining === 1) { + items.push('... 1 more item'); + } else if (remaining > 1) { + items.push(`... ${remaining.toString(10)} more items`); + } + + return '[' + items.join(', ') + ']'; +} + +function getCustomFn(obj: any) { + if (typeof obj.inspect === 'function') { + return obj.inspect; + } +} + +function getObjectTag(obj: any): string { + const tag = Object.prototype.toString + .call(obj) + .replace(/^\[object /, '') + .replace(/]$/, ''); + + if (tag === 'Object' && typeof obj.constructor === 'function') { + const name = obj.constructor.name; + if (typeof name === 'string' && name !== '') { + return name; + } + } + + return tag; +} diff --git a/src/utils/keyMap.ts b/src/utils/keyMap.ts new file mode 100644 index 00000000000..c493d01d72b --- /dev/null +++ b/src/utils/keyMap.ts @@ -0,0 +1,32 @@ +/** + * Creates a keyed JS object from an array, given a function to produce the keys + * for each value in the array. + * + * This provides a convenient lookup for the array items if the key function + * produces unique results. + * + * const phoneBook = [ + * { name: 'Jon', num: '555-1234' }, + * { name: 'Jenny', num: '867-5309' } + * ] + * + * // { Jon: { name: 'Jon', num: '555-1234' }, + * // Jenny: { name: 'Jenny', num: '867-5309' } } + * const entriesByName = keyMap( + * phoneBook, + * entry => entry.name + * ) + * + * // { name: 'Jenny', num: '857-6309' } + * const jennyEntry = entriesByName['Jenny'] + * + */ +export function keyMap( + list: ReadonlyArray, + keyFn: (item: T) => string, +): { [name: string]: T } { + return list.reduce((map, item) => { + map[keyFn(item)] = item; + return map; + }, Object.create(null)); +}