diff --git a/src/execution/__tests__/variables-test.js b/src/execution/__tests__/variables-test.js index 1c45e2bc9da..17f862786e4 100644 --- a/src/execution/__tests__/variables-test.js +++ b/src/execution/__tests__/variables-test.js @@ -98,7 +98,6 @@ const TestType = new GraphQLObjectType({ }), fieldWithNestedInputObject: fieldWithInputArg({ type: TestNestedInputObject, - defaultValue: 'Hello World', }), list: fieldWithInputArg({ type: new GraphQLList(GraphQLString) }), nnList: fieldWithInputArg({ diff --git a/src/execution/values.js b/src/execution/values.js index d91633acd43..3b82451084f 100644 --- a/src/execution/values.js +++ b/src/execution/values.js @@ -20,7 +20,10 @@ import { isInputType, isNonNullType } from '../type/definition'; import { typeFromAST } from '../utilities/typeFromAST'; import { valueFromAST } from '../utilities/valueFromAST'; -import { coerceInputValue } from '../utilities/coerceInputValue'; +import { + coerceInputValue, + coerceDefaultValue, +} from '../utilities/coerceInputValue'; type CoercedVariableValues = | {| errors: $ReadOnlyArray |} @@ -174,7 +177,7 @@ export function getArgumentValues( if (!argumentNode) { if (argDef.defaultValue !== undefined) { - coercedValues[name] = argDef.defaultValue; + coercedValues[name] = coerceDefaultValue(argDef); } else if (isNonNullType(argType)) { throw new GraphQLError( `Argument "${name}" of required type "${inspect(argType)}" ` + @@ -195,7 +198,7 @@ export function getArgumentValues( !hasOwnProperty(variableValues, variableName) ) { if (argDef.defaultValue !== undefined) { - coercedValues[name] = argDef.defaultValue; + coercedValues[name] = coerceDefaultValue(argDef); } else if (isNonNullType(argType)) { throw new GraphQLError( `Argument "${name}" of required type "${inspect(argType)}" ` + diff --git a/src/type/definition.js b/src/type/definition.js index 9461e79fb8c..9b3f022449e 100644 --- a/src/type/definition.js +++ b/src/type/definition.js @@ -809,21 +809,11 @@ function defineFieldMap( `${config.name}.${fieldName} args must be an object with argument names as keys.`, ); - const args = Object.entries(argsConfig).map(([argName, argConfig]) => ({ - name: argName, - description: argConfig.description, - type: argConfig.type, - defaultValue: argConfig.defaultValue, - deprecationReason: argConfig.deprecationReason, - extensions: argConfig.extensions && toObjMap(argConfig.extensions), - astNode: argConfig.astNode, - })); - return { name: fieldName, description: fieldConfig.description, type: fieldConfig.type, - args, + args: defineArguments(argsConfig), resolve: fieldConfig.resolve, subscribe: fieldConfig.subscribe, deprecationReason: fieldConfig.deprecationReason, @@ -833,6 +823,20 @@ function defineFieldMap( }); } +export function defineArguments( + config: GraphQLFieldConfigArgumentMap, +): $ReadOnlyArray { + return Object.entries(config).map(([argName, argConfig]) => ({ + name: argName, + description: argConfig.description, + type: argConfig.type, + defaultValue: argConfig.defaultValue, + deprecationReason: argConfig.deprecationReason, + extensions: argConfig.extensions && toObjMap(argConfig.extensions), + astNode: argConfig.astNode, + })); +} + function isPlainObj(obj: mixed): boolean { return isObjectLike(obj) && !Array.isArray(obj); } diff --git a/src/type/directives.js b/src/type/directives.js index cbe32751a46..a6ca5005c98 100644 --- a/src/type/directives.js +++ b/src/type/directives.js @@ -14,7 +14,11 @@ import type { GraphQLFieldConfigArgumentMap, } from './definition'; import { GraphQLString, GraphQLBoolean } from './scalars'; -import { argsToArgsConfig, GraphQLNonNull } from './definition'; +import { + defineArguments, + argsToArgsConfig, + GraphQLNonNull, +} from './definition'; /** * Test if the given value is a GraphQL directive. @@ -44,7 +48,7 @@ export class GraphQLDirective { name: string; description: ?string; locations: Array; - args: Array; + args: $ReadOnlyArray; isRepeatable: boolean; extensions: ?ReadOnlyObjMap; astNode: ?DirectiveDefinitionNode; @@ -69,15 +73,7 @@ export class GraphQLDirective { `@${config.name} args must be an object with argument names as keys.`, ); - this.args = Object.entries(args).map(([argName, argConfig]) => ({ - name: argName, - description: argConfig.description, - type: argConfig.type, - defaultValue: argConfig.defaultValue, - deprecationReason: argConfig.deprecationReason, - extensions: argConfig.extensions && toObjMap(argConfig.extensions), - astNode: argConfig.astNode, - })); + this.args = defineArguments(args); } toConfig(): GraphQLDirectiveNormalizedConfig { diff --git a/src/type/validate.js b/src/type/validate.js index bc0763a0f72..1221d92dd20 100644 --- a/src/type/validate.js +++ b/src/type/validate.js @@ -1,4 +1,12 @@ +import type { Path } from '../jsutils/Path'; +import { addPath, pathToArray } from '../jsutils/Path'; +import { didYouMean } from '../jsutils/didYouMean'; import { inspect } from '../jsutils/inspect'; +import { invariant } from '../jsutils/invariant'; +import { isIterableObject } from '../jsutils/isIterableObject'; +import { isObjectLike } from '../jsutils/isObjectLike'; +import { printPathArray } from '../jsutils/printPathArray'; +import { suggestionList } from '../jsutils/suggestionList'; import { GraphQLError } from '../error/GraphQLError'; import { locatedError } from '../error/locatedError'; @@ -20,18 +28,22 @@ import type { GraphQLUnionType, GraphQLEnumType, GraphQLInputObjectType, + GraphQLInputType, } from './definition'; import { assertSchema } from './schema'; import { isIntrospectionType } from './introspection'; import { isDirective, GraphQLDeprecatedDirective } from './directives'; import { + getNamedType, isObjectType, isInterfaceType, isUnionType, isEnumType, isInputObjectType, isNamedType, + isListType, isNonNullType, + isLeafType, isInputType, isOutputType, isRequiredArgument, @@ -190,6 +202,15 @@ function validateDirectives(context: SchemaValidationContext): void { ], ); } + + if (arg.defaultValue !== undefined) { + validateDefaultValue(arg.defaultValue, arg.type).forEach((error) => { + context.reportError( + `Argument @${directive.name}(${arg.name}:) has invalid default value: ${error}`, + arg.astNode?.defaultValue, + ); + }); + } } } } @@ -306,6 +327,15 @@ function validateFields( ], ); } + + if (arg.defaultValue !== undefined) { + validateDefaultValue(arg.defaultValue, arg.type).forEach((error) => { + context.reportError( + `Argument ${type.name}.${field.name}(${argName}:) has invalid default value: ${error}`, + arg.astNode?.defaultValue, + ); + }); + } } } } @@ -528,7 +558,7 @@ function validateInputFields( ); } - // Ensure the arguments are valid + // Ensure the input fields are valid for (const field of fields) { // Ensure they are named correctly. validateName(context, field); @@ -552,6 +582,15 @@ function validateInputFields( ], ); } + + if (field.defaultValue !== undefined) { + validateDefaultValue(field.defaultValue, field.type).forEach((error) => { + context.reportError( + `Input field ${inputObj.name}.${field.name} has invalid default value: ${error}`, + field.astNode?.defaultValue, + ); + }); + } } } @@ -584,22 +623,32 @@ function createInputObjectCircularRefsValidator( const fields = Object.values(inputObj.getFields()); for (const field of fields) { - if (isNonNullType(field.type) && isInputObjectType(field.type.ofType)) { - const fieldType = field.type.ofType; - const cycleIndex = fieldPathIndexByTypeName[fieldType.name]; - - fieldPath.push(field); - if (cycleIndex === undefined) { - detectCycleRecursive(fieldType); - } else { - const cyclePath = fieldPath.slice(cycleIndex); - const pathStr = cyclePath.map((fieldObj) => fieldObj.name).join('.'); - context.reportError( - `Cannot reference Input Object "${fieldType.name}" within itself through a series of non-null fields: "${pathStr}".`, - cyclePath.map((fieldObj) => fieldObj.astNode), - ); + const fieldType = getNamedType(field.type); + if (isInputObjectType(fieldType)) { + const isNonNullField = + isNonNullType(field.type) && field.type.ofType === fieldType; + if (isNonNullField || !isEmptyValue(field.defaultValue)) { + const cycleIndex = fieldPathIndexByTypeName[fieldType.name]; + + fieldPath.push(field); + if (cycleIndex === undefined) { + detectCycleRecursive(fieldType); + } else { + const cyclePath = fieldPath.slice(cycleIndex); + const pathStr = cyclePath + .map((fieldObj) => fieldObj.name) + .join('.'); + context.reportError( + `Cannot reference Input Object "${ + fieldType.name + }" within itself through a series of ${ + isNonNullField ? 'non-null fields' : 'non-empty default values' + }: "${pathStr}".`, + cyclePath.map((fieldObj) => fieldObj.astNode), + ); + } + fieldPath.pop(); } - fieldPath.pop(); } } @@ -607,6 +656,10 @@ function createInputObjectCircularRefsValidator( } } +function isEmptyValue(value: mixed) { + return value == null || (Array.isArray(value) && value.length === 0); +} + function getAllImplementsInterfaceNodes( type: GraphQLObjectType | GraphQLInterfaceType, iface: GraphQLInterfaceType, @@ -643,3 +696,126 @@ function getDeprecatedDirectiveNode( (node) => node.name.value === GraphQLDeprecatedDirective.name, ); } + +/** + * Coerce an internal JavaScript value given a GraphQL Input Type. + */ +function validateDefaultValue( + inputValue: mixed, + type: GraphQLInputType, + path?: Path, +): Array { + if (isNonNullType(type)) { + if (inputValue !== null) { + return validateDefaultValue(inputValue, type.ofType, path); + } + return invalidDefaultValue( + `Expected non-nullable type "${inspect(type)}" not to be null.`, + path, + ); + } + + if (inputValue === null) { + return []; + } + + if (isListType(type)) { + const itemType = type.ofType; + if (isIterableObject(inputValue)) { + const errors = []; + Array.from(inputValue).forEach((itemValue, index) => { + errors.push( + ...validateDefaultValue( + itemValue, + itemType, + addPath(path, index, undefined), + ), + ); + }); + return errors; + } + // Lists accept a non-list value as a list of one. + return validateDefaultValue(inputValue, itemType, path); + } + + if (isInputObjectType(type)) { + if (!isObjectLike(inputValue)) { + return invalidDefaultValue( + `Expected type "${type.name}" to be an object.`, + path, + ); + } + + const errors = []; + const fieldDefs = type.getFields(); + + for (const field of Object.values(fieldDefs)) { + const fieldPath = addPath(path, field.name, type.name); + const fieldValue = inputValue[field.name]; + + if (fieldValue === undefined) { + if (field.defaultValue === undefined && isNonNullType(field.type)) { + return invalidDefaultValue( + `Field "${field.name}" of required type "${inspect( + field.type, + )}" was not provided.`, + fieldPath, + ); + } + continue; + } + + errors.push(...validateDefaultValue(fieldValue, field.type, fieldPath)); + } + + // Ensure every provided field is defined. + for (const fieldName of Object.keys(inputValue)) { + if (!fieldDefs[fieldName]) { + const suggestions = suggestionList( + fieldName, + Object.keys(type.getFields()), + ); + errors.push( + ...invalidDefaultValue( + `Field "${fieldName}" is not defined by type "${type.name}".` + + didYouMean(suggestions), + path, + ), + ); + } + } + return errors; + } + + // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618') + if (isLeafType(type)) { + let parseResult; + let caughtError; + + // Scalars and Enums determine if a input value is valid via serialize(), + // which can throw to indicate failure. If it throws, maintain a reference + // to the original error. + try { + parseResult = type.serialize(inputValue); + } catch (error) { + caughtError = error; + } + if (parseResult === undefined) { + return invalidDefaultValue( + caughtError?.message ?? `Expected type "${type.name}".`, + path, + ); + } + return []; + } + + // istanbul ignore next (Not reachable. All possible input types have been considered) + invariant(false, 'Unexpected input type: ' + inspect((type: empty))); +} + +function invalidDefaultValue(message, path) { + return [ + (path ? `(at defaultValue${printPathArray(pathToArray(path))}) ` : '') + + message, + ]; +} diff --git a/src/utilities/coerceInputValue.d.ts b/src/utilities/coerceInputValue.d.ts index 78dafb257fc..0f4d457cb37 100644 --- a/src/utilities/coerceInputValue.d.ts +++ b/src/utilities/coerceInputValue.d.ts @@ -15,3 +15,14 @@ export function coerceInputValue( type: GraphQLInputType, onError?: OnErrorCB, ): unknown; + +/** + * Coerces the default value of an input field or argument. + */ +export function coerceDefaultValue( + withDefaultValue: { + readonly type: GraphQLInputType; + readonly defaultValue: unknown; + }, + onError?: OnErrorCB, +): unknown; diff --git a/src/utilities/coerceInputValue.js b/src/utilities/coerceInputValue.js index 9c9546fbc2e..711188e2796 100644 --- a/src/utilities/coerceInputValue.js +++ b/src/utilities/coerceInputValue.js @@ -10,7 +10,7 @@ import { isIterableObject } from '../jsutils/isIterableObject'; import { GraphQLError } from '../error/GraphQLError'; -import type { GraphQLInputType } from '../type/definition'; +import type { GraphQLInputType, GraphQLLeafType } from '../type/definition'; import { isLeafType, isInputObjectType, @@ -24,6 +24,8 @@ type OnErrorCB = ( error: GraphQLError, ) => void; +type CoerceCB = (type: GraphQLLeafType, value: mixed) => mixed; + /** * Coerces a JavaScript value given a GraphQL Input Type. */ @@ -32,7 +34,26 @@ export function coerceInputValue( type: GraphQLInputType, onError: OnErrorCB = defaultOnError, ): mixed { - return coerceInputValueImpl(inputValue, type, onError); + return coerceInputValueImpl(inputValue, type, inputCoercion, onError); +} + +export function coerceDefaultValue( + withDefaultValue: { +type: GraphQLInputType, +defaultValue: mixed, ... }, + onError: OnErrorCB = defaultOnError, +): mixed { + // Memoize the result of coercing the default value in a field hidden to the + // type system. + let coercedDefaultValue = (withDefaultValue: any)._coercedDefaultValue; + if (coercedDefaultValue === undefined) { + coercedDefaultValue = coerceInputValueImpl( + withDefaultValue.defaultValue, + withDefaultValue.type, + defaultValueCoercion, + onError, + ); + (withDefaultValue: any)._coercedDefaultValue = coercedDefaultValue; + } + return coercedDefaultValue; } function defaultOnError( @@ -48,15 +69,35 @@ function defaultOnError( throw error; } +function inputCoercion(type: GraphQLLeafType, value: mixed): mixed { + // Scalars and Enums determine if a external input value is valid via + // parseValue(), + return type.parseValue(value); +} + +function defaultValueCoercion(type: GraphQLLeafType, value: mixed): mixed { + // Default values are initially represented as internal values, "serialize" + // converts the internal value to an external value, and "parseValue" converts + // back to an internal value. + return type.parseValue(type.serialize(value)); +} + function coerceInputValueImpl( inputValue: mixed, type: GraphQLInputType, + coerce: CoerceCB, onError: OnErrorCB, path: Path | void, ): mixed { if (isNonNullType(type)) { if (inputValue != null) { - return coerceInputValueImpl(inputValue, type.ofType, onError, path); + return coerceInputValueImpl( + inputValue, + type.ofType, + coerce, + onError, + path, + ); } onError( pathToArray(path), @@ -78,11 +119,17 @@ function coerceInputValueImpl( if (isIterableObject(inputValue)) { return Array.from(inputValue, (itemValue, index) => { const itemPath = addPath(path, index, undefined); - return coerceInputValueImpl(itemValue, itemType, onError, itemPath); + return coerceInputValueImpl( + itemValue, + itemType, + coerce, + onError, + itemPath, + ); }); } // Lists accept a non-list value as a list of one. - return [coerceInputValueImpl(inputValue, itemType, onError, path)]; + return [coerceInputValueImpl(inputValue, itemType, coerce, onError, path)]; } if (isInputObjectType(type)) { @@ -103,7 +150,7 @@ function coerceInputValueImpl( if (fieldValue === undefined) { if (field.defaultValue !== undefined) { - coercedValue[field.name] = field.defaultValue; + coercedValue[field.name] = coerceDefaultValue(field); } else if (isNonNullType(field.type)) { const typeStr = inspect(field.type); onError( @@ -120,6 +167,7 @@ function coerceInputValueImpl( coercedValue[field.name] = coerceInputValueImpl( fieldValue, field.type, + coerce, onError, addPath(path, field.name, type.name), ); @@ -147,13 +195,12 @@ function coerceInputValueImpl( // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618') if (isLeafType(type)) { - let parseResult; + let coercedResult; - // Scalars and Enums determine if a input value is valid via parseValue(), - // which can throw to indicate failure. If it throws, maintain a reference - // to the original error. + // Coercion can throw to indicate failure. If it throws, maintain a + // reference to the original error. try { - parseResult = type.parseValue(inputValue); + coercedResult = coerce(type, inputValue); } catch (error) { if (error instanceof GraphQLError) { onError(pathToArray(path), inputValue, error); @@ -173,14 +220,14 @@ function coerceInputValueImpl( } return; } - if (parseResult === undefined) { + if (coercedResult === undefined) { onError( pathToArray(path), inputValue, new GraphQLError(`Expected type "${type.name}".`), ); } - return parseResult; + return coercedResult; } // istanbul ignore next (Not reachable. All possible input types have been considered) diff --git a/src/utilities/printSchema.js b/src/utilities/printSchema.js index b77923eb411..f20b8edb426 100644 --- a/src/utilities/printSchema.js +++ b/src/utilities/printSchema.js @@ -228,7 +228,7 @@ function printBlock(items: $ReadOnlyArray): string { } function printArgs( - args: Array, + args: $ReadOnlyArray, indentation: string = '', ): string { if (args.length === 0) { diff --git a/src/utilities/valueFromAST.js b/src/utilities/valueFromAST.js index 258976462b9..0fe4bc7d570 100644 --- a/src/utilities/valueFromAST.js +++ b/src/utilities/valueFromAST.js @@ -14,6 +14,8 @@ import { isNonNullType, } from '../type/definition'; +import { coerceDefaultValue } from './coerceInputValue'; + /** * Produces a JavaScript value given a GraphQL Value AST. * @@ -112,7 +114,7 @@ export function valueFromAST( const fieldNode = fieldNodes[field.name]; if (!fieldNode || isMissingVariable(fieldNode.value, variables)) { if (field.defaultValue !== undefined) { - coercedObj[field.name] = field.defaultValue; + coercedObj[field.name] = coerceDefaultValue(field); } else if (isNonNullType(field.type)) { return; // Invalid: intentionally return no value. }