From 3110ca16307c22bf578b7d80cc18fece78f11d32 Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Mon, 10 May 2021 11:44:12 -0700 Subject: [PATCH] Preserve defaultValue literals Fixes #3051 This change solves the problem of default values defined via SDL not always resolving correctly through introspection by preserving the original GraphQL literal in the schema definition. This changes argument and input field definitions `defaultValue` field from just the "value" to a new `GraphQLDefaultValueUsage` type which contains either or both "value" and "literal" fields. Since either of these fields may be set, new functions for resolving a value or literal from either have been added - `getLiteralDefaultValue` and `getCoercedDefaultValue` - these replace uses that either assumed a set value or were manually converting a value back to a literal. Here is the flow for how a default value defined in an SDL would be converted into a functional schema and back to an SDL: **Before this change:** ``` (SDL) --parse-> (AST) --coerceInputLiteral--> (defaultValue config) --valueToAST--> (AST) --print --> (SDL) ``` `coerceInputLiteral` performs coercion which is a one-way function, and `valueToAST` is unsafe and set to be deprecated in #3049. **After this change:** ``` (SDL) --parse-> (defaultValue literal config) --print --> (SDL) ``` --- src/execution/values.ts | 15 +++-- src/index.ts | 1 + src/type/__tests__/definition-test.ts | 63 +++++++++++++++++++ src/type/definition.ts | 37 +++++++++-- src/type/index.ts | 1 + src/type/introspection.ts | 9 ++- src/utilities/TypeInfo.ts | 5 +- .../__tests__/buildClientSchema-test.ts | 23 +++++++ .../__tests__/coerceInputValue-test.ts | 35 ++++++++++- src/utilities/astFromValue.ts | 4 +- src/utilities/buildClientSchema.ts | 13 ++-- src/utilities/coerceInputValue.ts | 39 ++++++++++-- src/utilities/extendSchema.ts | 10 +-- src/utilities/findBreakingChanges.ts | 11 ++-- src/utilities/printSchema.ts | 9 ++- src/validation/ValidationContext.ts | 3 +- .../rules/VariablesInAllowedPositionRule.ts | 7 ++- 17 files changed, 235 insertions(+), 50 deletions(-) diff --git a/src/execution/values.ts b/src/execution/values.ts index 346abc38e9..7ce5bca3b3 100644 --- a/src/execution/values.ts +++ b/src/execution/values.ts @@ -23,6 +23,7 @@ import { typeFromAST } from '../utilities/typeFromAST'; import { coerceInputValue, coerceInputLiteral, + coerceDefaultValue, } from '../utilities/coerceInputValue'; type CoercedVariableValues = @@ -177,8 +178,11 @@ export function getArgumentValues( const argumentNode = argNodeMap[name]; if (!argumentNode) { - if (argDef.defaultValue !== undefined) { - coercedValues[name] = argDef.defaultValue; + if (argDef.defaultValue) { + coercedValues[name] = coerceDefaultValue( + argDef.defaultValue, + argDef.type, + ); } else if (isNonNullType(argType)) { throw new GraphQLError( `Argument ${argDef} of required type ${argType} was not provided.`, @@ -197,8 +201,11 @@ export function getArgumentValues( variableValues == null || !hasOwnProperty(variableValues, variableName) ) { - if (argDef.defaultValue !== undefined) { - coercedValues[name] = argDef.defaultValue; + if (argDef.defaultValue) { + coercedValues[name] = coerceDefaultValue( + argDef.defaultValue, + argDef.type, + ); } else if (isNonNullType(argType)) { throw new GraphQLError( `Argument ${argDef} of required type ${argType} ` + diff --git a/src/index.ts b/src/index.ts index 183d249a89..702fc5d108 100644 --- a/src/index.ts +++ b/src/index.ts @@ -181,6 +181,7 @@ export type { GraphQLScalarSerializer, GraphQLScalarValueParser, GraphQLScalarLiteralParser, + GraphQLDefaultValueUsage, } from './type/index'; /** Parse and operate on GraphQL language source files. */ diff --git a/src/type/__tests__/definition-test.ts b/src/type/__tests__/definition-test.ts index 80c17c7f40..287ab112e5 100644 --- a/src/type/__tests__/definition-test.ts +++ b/src/type/__tests__/definition-test.ts @@ -826,6 +826,69 @@ describe('Type System: Input Objects', () => { ); }); }); + + describe('Input Object fields may have default values', () => { + it('accepts an Input Object type with a default value', () => { + const inputObjType = new GraphQLInputObjectType({ + name: 'SomeInputObject', + fields: { + f: { type: ScalarType, defaultValue: 3 }, + }, + }); + expect(inputObjType.getFields()).to.deep.equal({ + f: { + coordinate: 'SomeInputObject.f', + name: 'f', + description: undefined, + type: ScalarType, + defaultValue: { value: 3 }, + deprecationReason: undefined, + extensions: undefined, + astNode: undefined, + }, + }); + }); + + it('accepts an Input Object type with a default value literal', () => { + const inputObjType = new GraphQLInputObjectType({ + name: 'SomeInputObject', + fields: { + f: { + type: ScalarType, + defaultValueLiteral: { kind: 'IntValue', value: '3' }, + }, + }, + }); + expect(inputObjType.getFields()).to.deep.equal({ + f: { + coordinate: 'SomeInputObject.f', + name: 'f', + description: undefined, + type: ScalarType, + defaultValue: { literal: { kind: 'IntValue', value: '3' } }, + deprecationReason: undefined, + extensions: undefined, + astNode: undefined, + }, + }); + }); + + it('rejects an Input Object type with potentially conflicting default values', () => { + const inputObjType = new GraphQLInputObjectType({ + name: 'SomeInputObject', + fields: { + f: { + type: ScalarType, + defaultValue: 3, + defaultValueLiteral: { kind: 'IntValue', value: '3' }, + }, + }, + }); + expect(() => inputObjType.getFields()).to.throw( + 'f has both a defaultValue and a defaultValueLiteral property, but only one must be provided.', + ); + }); + }); }); describe('Type System: List', () => { diff --git a/src/type/definition.ts b/src/type/definition.ts index 09cb797413..df6372378a 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -21,6 +21,7 @@ import { print } from '../language/printer'; import type { FieldNode, ValueNode, + ConstValueNode, OperationDefinitionNode, FragmentDefinitionNode, ScalarTypeDefinitionNode, @@ -964,6 +965,7 @@ export interface GraphQLArgumentConfig { description?: Maybe; type: GraphQLInputType; defaultValue?: unknown; + defaultValueLiteral?: ConstValueNode; deprecationReason?: Maybe; extensions?: Maybe>; astNode?: Maybe; @@ -1049,7 +1051,7 @@ export class GraphQLArgument extends GraphQLSchemaElement { name: string; description: Maybe; type: GraphQLInputType; - defaultValue: unknown; + defaultValue: GraphQLDefaultValueUsage | undefined; deprecationReason: Maybe; extensions: Maybe>; astNode: Maybe; @@ -1064,7 +1066,7 @@ export class GraphQLArgument extends GraphQLSchemaElement { this.name = name; this.description = config.description; this.type = config.type; - this.defaultValue = config.defaultValue; + this.defaultValue = defineDefaultValue(coordinate, config); this.deprecationReason = config.deprecationReason; this.extensions = config.extensions && toObjMap(config.extensions); this.astNode = config.astNode; @@ -1074,7 +1076,8 @@ export class GraphQLArgument extends GraphQLSchemaElement { return { description: this.description, type: this.type, - defaultValue: this.defaultValue, + defaultValue: this.defaultValue?.value, + defaultValueLiteral: this.defaultValue?.literal, deprecationReason: this.deprecationReason, extensions: this.extensions, astNode: this.astNode, @@ -1090,6 +1093,26 @@ export type GraphQLFieldMap = ObjMap< GraphQLField >; +export type GraphQLDefaultValueUsage = + | { value: unknown; literal?: never } + | { literal: ConstValueNode; value?: never }; + +function defineDefaultValue( + coordinate: string, + config: GraphQLArgumentConfig | GraphQLInputFieldConfig, +): GraphQLDefaultValueUsage | undefined { + if (config.defaultValue === undefined && !config.defaultValueLiteral) { + return; + } + devAssert( + !(config.defaultValue !== undefined && config.defaultValueLiteral), + `${coordinate} has both a defaultValue and a defaultValueLiteral property, but only one must be provided.`, + ); + return config.defaultValueLiteral + ? { literal: config.defaultValueLiteral } + : { value: config.defaultValue }; +} + /** * Custom extensions * @@ -1694,6 +1717,7 @@ export interface GraphQLInputFieldConfig { description?: Maybe; type: GraphQLInputType; defaultValue?: unknown; + defaultValueLiteral?: ConstValueNode; deprecationReason?: Maybe; extensions?: Maybe>; astNode?: Maybe; @@ -1705,7 +1729,7 @@ export class GraphQLInputField extends GraphQLSchemaElement { name: string; description: Maybe; type: GraphQLInputType; - defaultValue: unknown; + defaultValue: GraphQLDefaultValueUsage | undefined; deprecationReason: Maybe; extensions: Maybe>; astNode: Maybe; @@ -1726,7 +1750,7 @@ export class GraphQLInputField extends GraphQLSchemaElement { this.name = name; this.description = config.description; this.type = config.type; - this.defaultValue = config.defaultValue; + this.defaultValue = defineDefaultValue(coordinate, config); this.deprecationReason = config.deprecationReason; this.extensions = config.extensions && toObjMap(config.extensions); this.astNode = config.astNode; @@ -1736,7 +1760,8 @@ export class GraphQLInputField extends GraphQLSchemaElement { return { description: this.description, type: this.type, - defaultValue: this.defaultValue, + defaultValue: this.defaultValue?.value, + defaultValueLiteral: this.defaultValue?.literal, extensions: this.extensions, astNode: this.astNode, }; diff --git a/src/type/index.ts b/src/type/index.ts index f4c419bb66..8ef0c1f07e 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -116,6 +116,7 @@ export type { GraphQLScalarSerializer, GraphQLScalarValueParser, GraphQLScalarLiteralParser, + GraphQLDefaultValueUsage, } from './definition'; export { diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 6dfd193f5e..fd4f81c15b 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -384,8 +384,13 @@ export const __InputValue: GraphQLObjectType = new GraphQLObjectType({ 'A GraphQL-formatted string representing the default value for this input value.', resolve(inputValue) { const { type, defaultValue } = inputValue; - const valueAST = astFromValue(defaultValue, type); - return valueAST ? print(valueAST) : null; + if (!defaultValue) { + return null; + } + const literal = + defaultValue.literal ?? astFromValue(defaultValue.value, type); + invariant(literal, 'Invalid default value'); + return print(literal); }, }, isDeprecated: { diff --git a/src/utilities/TypeInfo.ts b/src/utilities/TypeInfo.ts index f9ba319ce6..322b961986 100644 --- a/src/utilities/TypeInfo.ts +++ b/src/utilities/TypeInfo.ts @@ -17,6 +17,7 @@ import type { GraphQLArgument, GraphQLInputField, GraphQLEnumValue, + GraphQLDefaultValueUsage, } from '../type/definition'; import { isObjectType, @@ -49,7 +50,7 @@ export class TypeInfo { private _parentTypeStack: Array>; private _inputTypeStack: Array>; private _fieldDefStack: Array>>; - private _defaultValueStack: Array>; + private _defaultValueStack: Array; private _directive: Maybe; private _argument: Maybe; private _enumValue: Maybe; @@ -119,7 +120,7 @@ export class TypeInfo { } } - getDefaultValue(): Maybe { + getDefaultValue(): GraphQLDefaultValueUsage | undefined { if (this._defaultValueStack.length > 0) { return this._defaultValueStack[this._defaultValueStack.length - 1]; } diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index 448ac2bcdb..478f4c824c 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -444,6 +444,7 @@ describe('Type System: build schema from introspection', () => { } type Query { + defaultID(intArg: ID = "123"): String defaultInt(intArg: Int = 30): String defaultList(listArg: [Int] = [1, 2, 3]): String defaultObject(objArg: Geo = {lat: 37.485, lon: -122.148}): String @@ -599,6 +600,28 @@ describe('Type System: build schema from introspection', () => { expect(result.data).to.deep.equal({ foo: 'bar' }); }); + it('can use client schema for execution if resolvers are added', () => { + const schema = buildSchema(` + type Query { + foo(bar: String = "abc"): String + } + `); + + const introspection = introspectionFromSchema(schema); + const clientSchema = buildClientSchema(introspection); + + const QueryType: GraphQLObjectType = clientSchema.getType('Query') as any; + QueryType.getFields().foo.resolve = (_value, args) => args.bar; + + const result = graphqlSync({ + schema: clientSchema, + source: '{ foo }', + }); + + expect(result.data).to.deep.equal({ foo: 'abc' }); + expect(result.data).to.deep.equal({ foo: 'abc' }); + }); + it('can build invalid schema', () => { const schema = buildSchema('type Query', { assumeValid: true }); diff --git a/src/utilities/__tests__/coerceInputValue-test.ts b/src/utilities/__tests__/coerceInputValue-test.ts index c76bf0cc5d..145c231972 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -24,7 +24,11 @@ import { GraphQLInputObjectType, } from '../../type/definition'; -import { coerceInputValue, coerceInputLiteral } from '../coerceInputValue'; +import { + coerceInputValue, + coerceInputLiteral, + coerceDefaultValue, +} from '../coerceInputValue'; interface CoerceResult { value: unknown; @@ -610,10 +614,14 @@ describe('coerceInputLiteral', () => { name: 'TestInput', fields: { int: { type: GraphQLInt, defaultValue: 42 }, + float: { + type: GraphQLFloat, + defaultValueLiteral: { kind: 'FloatValue', value: '3.14' }, + }, }, }); - test('{}', type, { int: 42 }); + test('{}', type, { int: 42, float: 3.14 }); }); const testInputObj = new GraphQLInputObjectType({ @@ -681,3 +689,26 @@ describe('coerceInputLiteral', () => { }); }); }); + +describe('coerceDefaultValue', () => { + it('memoizes coercion', () => { + const parseValueCalls: any = []; + + const spyScalar = new GraphQLScalarType({ + name: 'SpyScalar', + parseValue(value) { + parseValueCalls.push(value); + return value; + }, + }); + + const defaultValueUsage = { + literal: { kind: 'StringValue', value: 'hello' }, + } as const; + expect(coerceDefaultValue(defaultValueUsage, spyScalar)).to.equal('hello'); + + // Call a second time + expect(coerceDefaultValue(defaultValueUsage, spyScalar)).to.equal('hello'); + expect(parseValueCalls).to.deep.equal(['hello']); + }); +}); diff --git a/src/utilities/astFromValue.ts b/src/utilities/astFromValue.ts index 6ba9cff68e..7158f7bd0f 100644 --- a/src/utilities/astFromValue.ts +++ b/src/utilities/astFromValue.ts @@ -4,7 +4,7 @@ import { isObjectLike } from '../jsutils/isObjectLike'; import { isIterableObject } from '../jsutils/isIterableObject'; import type { Maybe } from '../jsutils/Maybe'; -import type { ValueNode } from '../language/ast'; +import type { ConstValueNode } from '../language/ast'; import { Kind } from '../language/kinds'; import type { GraphQLInputType } from '../type/definition'; @@ -41,7 +41,7 @@ import { export function astFromValue( value: unknown, type: GraphQLInputType, -): Maybe { +): Maybe { if (isNonNullType(type)) { const astValue = astFromValue(value, type.ofType); if (astValue?.kind === Kind.NULL) { diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 1bfe2e6901..abd186b028 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -47,7 +47,6 @@ import type { IntrospectionTypeRef, IntrospectionNamedTypeRef, } from './getIntrospectionQuery'; -import { coerceInputLiteral } from './coerceInputValue'; /** * Build a GraphQLSchema for use by client tools. @@ -366,17 +365,13 @@ export function buildClientSchema( ); } - const defaultValue = - inputValueIntrospection.defaultValue != null - ? coerceInputLiteral( - parseConstValue(inputValueIntrospection.defaultValue), - type, - ) - : undefined; return { description: inputValueIntrospection.description, type, - defaultValue, + defaultValueLiteral: + inputValueIntrospection.defaultValue != null + ? parseConstValue(inputValueIntrospection.defaultValue) + : undefined, deprecationReason: inputValueIntrospection.deprecationReason, }; } diff --git a/src/utilities/coerceInputValue.ts b/src/utilities/coerceInputValue.ts index 021194b0c0..9c25caf62f 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -14,7 +14,10 @@ import { isIterableObject } from '../jsutils/isIterableObject'; import { GraphQLError } from '../error/GraphQLError'; -import type { GraphQLInputType } from '../type/definition'; +import type { + GraphQLInputType, + GraphQLDefaultValueUsage, +} from '../type/definition'; import { isLeafType, assertLeafType, @@ -111,8 +114,11 @@ function coerceInputValueImpl( const fieldValue = inputValue[field.name]; if (fieldValue === undefined) { - if (field.defaultValue !== undefined) { - coercedValue[field.name] = field.defaultValue; + if (field.defaultValue) { + coercedValue[field.name] = coerceDefaultValue( + field.defaultValue, + field.type, + ); } else if (isNonNullType(field.type)) { const typeStr = inspect(field.type); onError( @@ -279,8 +285,11 @@ export function coerceInputLiteral( if (isRequiredInputField(field)) { return; // Invalid: intentionally return no value. } - if (field.defaultValue !== undefined) { - coercedValue[field.name] = field.defaultValue; + if (field.defaultValue) { + coercedValue[field.name] = coerceDefaultValue( + field.defaultValue, + field.type, + ); } } else { const fieldValue = coerceInputLiteral( @@ -317,3 +326,23 @@ function isMissingVariable( (variables == null || variables[valueNode.name.value] === undefined) ); } + +/** + * @internal + */ +export function coerceDefaultValue( + defaultValue: GraphQLDefaultValueUsage, + type: GraphQLInputType, +): unknown { + // Memoize the result of coercing the default value in a hidden field. + let coercedValue = (defaultValue as any)._memoizedCoercedValue; + // istanbul ignore else (memoized case) + if (coercedValue === undefined) { + coercedValue = defaultValue.literal + ? coerceInputLiteral(defaultValue.literal, type) + : defaultValue.value; + invariant(coercedValue !== undefined); + (defaultValue as any)._memoizedCoercedValue = coercedValue; + } + return coercedValue; +} diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 2597998478..7ec9671905 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -80,8 +80,6 @@ import { GraphQLInputObjectType, } from '../type/definition'; -import { coerceInputLiteral } from './coerceInputValue'; - interface Options extends GraphQLSchemaValidationOptions { /** * Set to true to assume the SDL is valid. @@ -491,9 +489,7 @@ export function extendSchemaImpl( argConfigMap[arg.name.value] = { type, description: arg.description?.value, - defaultValue: arg.defaultValue - ? coerceInputLiteral(arg.defaultValue, type) - : undefined, + defaultValueLiteral: arg.defaultValue, deprecationReason: getDeprecationReason(arg), astNode: arg, }; @@ -520,9 +516,7 @@ export function extendSchemaImpl( inputFieldMap[field.name.value] = { type, description: field.description?.value, - defaultValue: field.defaultValue - ? coerceInputLiteral(field.defaultValue, type) - : undefined, + defaultValueLiteral: field.defaultValue, deprecationReason: getDeprecationReason(field), astNode: field, }; diff --git a/src/utilities/findBreakingChanges.ts b/src/utilities/findBreakingChanges.ts index e79fd8043a..fe058d0fd8 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -17,6 +17,7 @@ import type { GraphQLObjectType, GraphQLInterfaceType, GraphQLInputObjectType, + GraphQLDefaultValueUsage, } from '../type/definition'; import { isSpecifiedScalarType } from '../type/scalars'; import { @@ -527,10 +528,12 @@ function typeKindName(type: GraphQLNamedType): string { invariant(false, 'Unexpected type: ' + inspect(type)); } -function stringifyValue(value: unknown, type: GraphQLInputType): string { - const ast = astFromValue(value, type); - invariant(ast != null); - +function stringifyValue( + defaultValue: GraphQLDefaultValueUsage, + type: GraphQLInputType, +): string { + const ast = defaultValue.literal ?? astFromValue(defaultValue.value, type); + invariant(ast); const sortedAST = visit(ast, { ObjectValue(objectNode) { // Make a copy since sort mutates array diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index 42d48c6a6c..0f76080c62 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -259,10 +259,13 @@ function printArgs( } function printInputValue(arg: GraphQLInputField): string { - const defaultAST = astFromValue(arg.defaultValue, arg.type); let argDecl = arg.name + ': ' + String(arg.type); - if (defaultAST) { - argDecl += ` = ${print(defaultAST)}`; + if (arg.defaultValue) { + const literal = + arg.defaultValue.literal ?? + astFromValue(arg.defaultValue.value, arg.type); + invariant(literal, 'Invalid default value'); + argDecl += ` = ${print(literal)}`; } return argDecl + printDeprecated(arg.deprecationReason); } diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index 93bf43d391..bf47f2fd52 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -25,6 +25,7 @@ import type { GraphQLField, GraphQLArgument, GraphQLEnumValue, + GraphQLDefaultValueUsage, } from '../type/definition'; import { TypeInfo, visitWithTypeInfo } from '../utilities/TypeInfo'; @@ -33,7 +34,7 @@ type NodeWithSelectionSet = OperationDefinitionNode | FragmentDefinitionNode; interface VariableUsage { readonly node: VariableNode; readonly type: Maybe; - readonly defaultValue: Maybe; + readonly defaultValue: GraphQLDefaultValueUsage | undefined; } /** diff --git a/src/validation/rules/VariablesInAllowedPositionRule.ts b/src/validation/rules/VariablesInAllowedPositionRule.ts index 9312f88548..e7b948c39f 100644 --- a/src/validation/rules/VariablesInAllowedPositionRule.ts +++ b/src/validation/rules/VariablesInAllowedPositionRule.ts @@ -7,7 +7,10 @@ import type { ValueNode } from '../../language/ast'; import type { ASTVisitor } from '../../language/visitor'; import type { GraphQLSchema } from '../../type/schema'; -import type { GraphQLType } from '../../type/definition'; +import type { + GraphQLType, + GraphQLDefaultValueUsage, +} from '../../type/definition'; import { isNonNullType } from '../../type/definition'; import { typeFromAST } from '../../utilities/typeFromAST'; @@ -79,7 +82,7 @@ function allowedVariableUsage( varType: GraphQLType, varDefaultValue: Maybe, locationType: GraphQLType, - locationDefaultValue: Maybe, + locationDefaultValue: GraphQLDefaultValueUsage | undefined, ): boolean { if (isNonNullType(locationType) && !isNonNullType(varType)) { const hasNonNullVariableDefaultValue =