From 8754fbe2fa9595bb45476cdf9b40baec918ca0d8 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Thu, 2 Apr 2020 16:45:34 +0200 Subject: [PATCH 1/2] Avoid using internal api of graphql-js --- src/delegate/results/handleObject.ts | 3 +- src/utils/SchemaDirectiveVisitor.ts | 2 +- src/utils/collectFields.ts | 151 +++++++++++++++++++++++++++ src/utils/getArgumentValues.ts | 100 ++++++++++++++++++ src/utils/inspect.ts | 115 ++++++++++++++++++++ src/utils/keyMap.ts | 32 ++++++ 6 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 src/utils/collectFields.ts create mode 100644 src/utils/getArgumentValues.ts create mode 100644 src/utils/inspect.ts create mode 100644 src/utils/keyMap.ts diff --git a/src/delegate/results/handleObject.ts b/src/delegate/results/handleObject.ts index d1fa0d97323..5167abea2e4 100644 --- a/src/delegate/results/handleObject.ts +++ b/src/delegate/results/handleObject.ts @@ -6,7 +6,8 @@ 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..c41169ec819 --- /dev/null +++ b/src/utils/collectFields.ts @@ -0,0 +1,151 @@ +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 { + 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..380bab69f3f --- /dev/null +++ b/src/utils/getArgumentValues.ts @@ -0,0 +1,100 @@ +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 = {}; + + /* istanbul ignore next (See https://github.com/graphql/graphql-js/issues/2203) */ + 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..06509f82ca0 --- /dev/null +++ b/src/utils/inspect.ts @@ -0,0 +1,115 @@ +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: any[]): string { + switch (typeof value) { + case 'string': + return JSON.stringify(value); + case 'function': + return value.name ? `[function ${value.name}]` : '[function]'; + case 'object': + if (value === null) { + return 'null'; + } + return formatObjectValue(value, seenValues); + default: + return String(value); + } +} + +function formatObjectValue(value: any, previouslySeenValues: any[]): 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: any[]) { + 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: any = formatValue(object[key], seenValues); + return key + ': ' + value; + }); + + return '{ ' + properties.join(', ') + ' }'; +} + +function formatArray(array: any[], seenValues: any[]): 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} 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..c700eb80d91 --- /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: readonly T[], + keyFn: (item: T) => string, +): { [name: string]: T } { + return list.reduce((map, item) => { + map[keyFn(item)] = item; + return map; + }, Object.create(null)); +} From e902a374ef288bb4b1b9fee88ddeb8e4dbef2de3 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Thu, 2 Apr 2020 17:00:18 +0200 Subject: [PATCH 2/2] I will turn that eslint off one day --- src/delegate/results/handleObject.ts | 1 + src/utils/collectFields.ts | 1 + src/utils/getArgumentValues.ts | 3 ++- src/utils/inspect.ts | 19 ++++++++++++------- src/utils/keyMap.ts | 2 +- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/delegate/results/handleObject.ts b/src/delegate/results/handleObject.ts index 5167abea2e4..99bdf89cc21 100644 --- a/src/delegate/results/handleObject.ts +++ b/src/delegate/results/handleObject.ts @@ -7,6 +7,7 @@ import { GraphQLObjectType, } from 'graphql'; import { ExecutionContext } from 'graphql/execution/execute'; + import { collectFields } from '../../utils/collectFields'; import { diff --git a/src/utils/collectFields.ts b/src/utils/collectFields.ts index c41169ec819..a6ac4a7d6b4 100644 --- a/src/utils/collectFields.ts +++ b/src/utils/collectFields.ts @@ -147,5 +147,6 @@ function doesFragmentConditionMatch( * 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 index 380bab69f3f..81d3800aa30 100644 --- a/src/utils/getArgumentValues.ts +++ b/src/utils/getArgumentValues.ts @@ -9,6 +9,7 @@ import { Kind, print, } from 'graphql'; + import { keyMap } from './keyMap'; import { inspect } from './inspect'; @@ -27,7 +28,7 @@ export function getArgumentValues( ): { [argument: string]: any } { const coercedValues = {}; - /* istanbul ignore next (See https://github.com/graphql/graphql-js/issues/2203) */ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const argumentNodes = node.arguments ?? []; const argNodeMap = keyMap(argumentNodes, (arg) => arg.name.value); diff --git a/src/utils/inspect.ts b/src/utils/inspect.ts index 06509f82ca0..2ffd57ec5d8 100644 --- a/src/utils/inspect.ts +++ b/src/utils/inspect.ts @@ -8,12 +8,14 @@ export function inspect(value: any): string { return formatValue(value, []); } -function formatValue(value: any, seenValues: any[]): string { +function formatValue(value: any, seenValues: Array): string { switch (typeof value) { case 'string': return JSON.stringify(value); case 'function': - return value.name ? `[function ${value.name}]` : '[function]'; + return value.name + ? `[function ${(value as Function).name}]` + : '[function]'; case 'object': if (value === null) { return 'null'; @@ -24,7 +26,10 @@ function formatValue(value: any, seenValues: any[]): string { } } -function formatObjectValue(value: any, previouslySeenValues: any[]): string { +function formatObjectValue( + value: any, + previouslySeenValues: Array, +): string { if (previouslySeenValues.indexOf(value) !== -1) { return '[Circular]'; } @@ -48,7 +53,7 @@ function formatObjectValue(value: any, previouslySeenValues: any[]): string { return formatObject(value, seenValues); } -function formatObject(object: any, seenValues: any[]) { +function formatObject(object: any, seenValues: Array) { const keys = Object.keys(object); if (keys.length === 0) { return '{}'; @@ -59,14 +64,14 @@ function formatObject(object: any, seenValues: any[]) { } const properties = keys.map((key) => { - const value: any = formatValue(object[key], seenValues); + const value = formatValue(object[key], seenValues); return key + ': ' + value; }); return '{ ' + properties.join(', ') + ' }'; } -function formatArray(array: any[], seenValues: any[]): string { +function formatArray(array: Array, seenValues: Array): string { if (array.length === 0) { return '[]'; } @@ -86,7 +91,7 @@ function formatArray(array: any[], seenValues: any[]): string { if (remaining === 1) { items.push('... 1 more item'); } else if (remaining > 1) { - items.push(`... ${remaining} more items`); + items.push(`... ${remaining.toString(10)} more items`); } return '[' + items.join(', ') + ']'; diff --git a/src/utils/keyMap.ts b/src/utils/keyMap.ts index c700eb80d91..c493d01d72b 100644 --- a/src/utils/keyMap.ts +++ b/src/utils/keyMap.ts @@ -22,7 +22,7 @@ * */ export function keyMap( - list: readonly T[], + list: ReadonlyArray, keyFn: (item: T) => string, ): { [name: string]: T } { return list.reduce((map, item) => {