From e2002dc4e945c03e6788281c4b532a341fbe9ce9 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 4b6640c5afb..eb743a9bb8d 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 = @@ -179,8 +180,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 "${name}" of required type "${inspect(argType)}" ` + @@ -200,8 +204,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 "${name}" of required type "${inspect(argType)}" ` + diff --git a/src/index.ts b/src/index.ts index 183d249a890..702fc5d108e 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 80c17c7f402..287ab112e5b 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 09cb7974131..df6372378a0 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 f4c419bb667..8ef0c1f07e2 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 6dfd193f5e5..fd4f81c15b6 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 f9ba319ce60..322b9619864 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 448ac2bcdb3..478f4c824cf 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 c76bf0cc5d1..145c2319725 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 6ba9cff68e5..7158f7bd0fe 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 1bfe2e69012..abd186b0283 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 a52741583fc..878ea31bbfc 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, isInputObjectType, @@ -110,8 +113,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( @@ -278,8 +284,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( @@ -320,3 +329,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 2597998478d..7ec9671905a 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 e79fd8043a8..fe058d0fd8c 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 42d48c6a6c2..0f76080c62f 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 93bf43d391b..bf47f2fd521 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 9312f885482..e7b948c39fd 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 =