diff --git a/src/execution/values.ts b/src/execution/values.ts index db67b2f2b37..420be7ad877 100644 --- a/src/execution/values.ts +++ b/src/execution/values.ts @@ -20,6 +20,7 @@ import type { GraphQLDirective } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; import { + coerceDefaultValue, coerceInputLiteral, coerceInputValue, } from '../utilities/coerceInputValue.js'; @@ -170,8 +171,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.`, @@ -190,8 +194,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 ed051de17b4..89c8eeac66c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -199,6 +199,7 @@ export type { GraphQLScalarSerializer, GraphQLScalarValueParser, GraphQLScalarLiteralParser, + GraphQLDefaultValueUsage, } from './type/index.js'; // 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 bf7f6d4587d..f523ebaa441 100644 --- a/src/type/__tests__/definition-test.ts +++ b/src/type/__tests__/definition-test.ts @@ -4,6 +4,7 @@ import { describe, it } from 'mocha'; import { identityFunc } from '../../jsutils/identityFunc.js'; import { inspect } from '../../jsutils/inspect.js'; +import { Kind } from '../../language/kinds.js'; import { parseValue } from '../../language/parser.js'; import type { GraphQLNullableType, GraphQLType } from '../definition.js'; @@ -617,6 +618,65 @@ describe('Type System: Input Objects', () => { 'not used anymore', ); }); + + 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().f).to.deep.include({ + coordinate: 'SomeInputObject.f', + name: 'f', + description: undefined, + type: ScalarType, + defaultValue: { value: 3 }, + deprecationReason: undefined, + extensions: {}, + 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: Kind.INT, value: '3' }, + }, + }, + }); + expect(inputObjType.getFields().f).to.deep.include({ + coordinate: 'SomeInputObject.f', + name: 'f', + description: undefined, + type: ScalarType, + defaultValue: { literal: { kind: 'IntValue', value: '3' } }, + deprecationReason: undefined, + extensions: {}, + 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: Kind.INT, 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 7455dca97df..d202cb9fa4c 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -16,6 +16,7 @@ import { toObjMap } from '../jsutils/toObjMap.js'; import { GraphQLError } from '../error/GraphQLError.js'; import type { + ConstValueNode, EnumTypeDefinitionNode, EnumTypeExtensionNode, EnumValueDefinitionNode, @@ -908,6 +909,7 @@ export interface GraphQLArgumentConfig { description?: Maybe; type: GraphQLInputType; defaultValue?: unknown; + defaultValueLiteral?: ConstValueNode | undefined; deprecationReason?: Maybe; extensions?: Maybe>; astNode?: Maybe; @@ -984,7 +986,7 @@ export class GraphQLArgument extends GraphQLSchemaElement { name: string; description: Maybe; type: GraphQLInputType; - defaultValue: unknown; + defaultValue: GraphQLDefaultValueUsage | undefined; deprecationReason: Maybe; extensions: Readonly; astNode: Maybe; @@ -999,7 +1001,7 @@ export class GraphQLArgument extends GraphQLSchemaElement { this.name = assertName(name); this.description = config.description; this.type = config.type; - this.defaultValue = config.defaultValue; + this.defaultValue = defineDefaultValue(coordinate, config); this.deprecationReason = config.deprecationReason; this.extensions = toObjMap(config.extensions); this.astNode = config.astNode; @@ -1013,7 +1015,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, @@ -1029,6 +1032,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 * @@ -1608,6 +1631,7 @@ export interface GraphQLInputFieldConfig { description?: Maybe; type: GraphQLInputType; defaultValue?: unknown; + defaultValueLiteral?: ConstValueNode | undefined; deprecationReason?: Maybe; extensions?: Maybe>; astNode?: Maybe; @@ -1619,7 +1643,7 @@ export class GraphQLInputField extends GraphQLSchemaElement { name: string; description: Maybe; type: GraphQLInputType; - defaultValue: unknown; + defaultValue: GraphQLDefaultValueUsage | undefined; deprecationReason: Maybe; extensions: Readonly; astNode: Maybe; @@ -1640,7 +1664,7 @@ export class GraphQLInputField extends GraphQLSchemaElement { this.name = assertName(name); this.description = config.description; this.type = config.type; - this.defaultValue = config.defaultValue; + this.defaultValue = defineDefaultValue(coordinate, config); this.deprecationReason = config.deprecationReason; this.extensions = toObjMap(config.extensions); this.astNode = config.astNode; @@ -1654,7 +1678,8 @@ export class GraphQLInputField 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, diff --git a/src/type/index.ts b/src/type/index.ts index e633914d960..9d47da09317 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -120,6 +120,7 @@ export type { GraphQLScalarSerializer, GraphQLScalarValueParser, GraphQLScalarLiteralParser, + GraphQLDefaultValueUsage, } from './definition.js'; export { diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 15e17771ad8..be211cc6a33 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -395,8 +395,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 != null, 'Invalid default value'); + return print(literal); }, }, isDeprecated: { diff --git a/src/utilities/TypeInfo.ts b/src/utilities/TypeInfo.ts index fe8ea1fbab9..716f6be39d5 100644 --- a/src/utilities/TypeInfo.ts +++ b/src/utilities/TypeInfo.ts @@ -9,6 +9,7 @@ import { getEnterLeaveForKind } from '../language/visitor.js'; import type { GraphQLArgument, GraphQLCompositeType, + GraphQLDefaultValueUsage, GraphQLEnumValue, GraphQLField, GraphQLInputField, @@ -43,7 +44,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; @@ -117,7 +118,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 2b0b0ae9399..54d8a7b12fb 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -445,6 +445,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 @@ -600,6 +601,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 2aa0fe7458f..b67f09db40d 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -5,6 +5,7 @@ import { identityFunc } from '../../jsutils/identityFunc.js'; import { invariant } from '../../jsutils/invariant.js'; import type { ObjMap } from '../../jsutils/ObjMap.js'; +import { Kind } from '../../language/kinds.js'; import { parseValue } from '../../language/parser.js'; import { print } from '../../language/printer.js'; @@ -24,7 +25,11 @@ import { GraphQLString, } from '../../type/scalars.js'; -import { coerceInputLiteral, coerceInputValue } from '../coerceInputValue.js'; +import { + coerceDefaultValue, + coerceInputLiteral, + coerceInputValue, +} from '../coerceInputValue.js'; interface CoerceResult { value: unknown; @@ -610,10 +615,14 @@ describe('coerceInputLiteral', () => { name: 'TestInput', fields: { int: { type: GraphQLInt, defaultValue: 42 }, + float: { + type: GraphQLFloat, + defaultValueLiteral: { kind: Kind.FLOAT, value: '3.14' }, + }, }, }); - test('{}', type, { int: 42 }); + test('{}', type, { int: 42, float: 3.14 }); }); const testInputObj = new GraphQLInputObjectType({ @@ -681,3 +690,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: Kind.STRING, 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 e1d994a8ee3..bb03baf232e 100644 --- a/src/utilities/astFromValue.ts +++ b/src/utilities/astFromValue.ts @@ -4,7 +4,7 @@ import { isIterableObject } from '../jsutils/isIterableObject.js'; import { isObjectLike } from '../jsutils/isObjectLike.js'; import type { Maybe } from '../jsutils/Maybe.js'; -import type { ObjectFieldNode, ValueNode } from '../language/ast.js'; +import type { ConstObjectFieldNode, ConstValueNode } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; import type { GraphQLInputType } from '../type/definition.js'; @@ -41,7 +41,7 @@ import { GraphQLID } from '../type/scalars.js'; export function astFromValue( value: unknown, type: GraphQLInputType, -): Maybe { +): Maybe { if (isNonNullType(type)) { const astValue = astFromValue(value, type.ofType); if (astValue?.kind === Kind.NULL) { @@ -83,7 +83,7 @@ export function astFromValue( if (!isObjectLike(value)) { return null; } - const fieldNodes: Array = []; + const fieldNodes: Array = []; for (const field of Object.values(type.getFields())) { const fieldValue = astFromValue(value[field.name], field.type); if (fieldValue) { diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 12800069643..ed24a80e1f8 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -32,7 +32,6 @@ import { specifiedScalarTypes } from '../type/scalars.js'; import type { GraphQLSchemaValidationOptions } from '../type/schema.js'; import { GraphQLSchema } from '../type/schema.js'; -import { coerceInputLiteral } from './coerceInputValue.js'; import type { IntrospectionDirective, IntrospectionEnumType, @@ -370,17 +369,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 556fb1bc5ad..b192ca7866c 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -17,7 +17,10 @@ import { GraphQLError } from '../error/GraphQLError.js'; import type { ValueNode } from '../language/ast'; import { Kind } from '../language/kinds.js'; -import type { GraphQLInputType } from '../type/definition.js'; +import type { + GraphQLDefaultValueUsage, + GraphQLInputType, +} from '../type/definition'; import { assertLeafType, isInputObjectType, @@ -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( @@ -273,8 +279,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( @@ -311,3 +320,23 @@ function isMissingVariable( (variables == null || !hasOwnProperty(variables, valueNode.name.value)) ); } + +/** + * @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 9a127b4bc38..299ce946d04 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -83,8 +83,6 @@ import { assertValidSDLExtension } from '../validation/validate.js'; import { getDirectiveValues } from '../execution/values.js'; -import { coerceInputLiteral } from './coerceInputValue.js'; - interface Options extends GraphQLSchemaValidationOptions { /** * Set to true to assume the SDL is valid. @@ -536,9 +534,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, }; @@ -565,9 +561,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 6b693046291..c636cd85b42 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -5,6 +5,7 @@ import { keyMap } from '../jsutils/keyMap.js'; import { print } from '../language/printer.js'; import type { + GraphQLDefaultValueUsage, GraphQLEnumType, GraphQLField, GraphQLInputObjectType, @@ -525,8 +526,11 @@ function typeKindName(type: GraphQLNamedType): string { invariant(false, 'Unexpected type: ' + inspect(type)); } -function stringifyValue(value: unknown, type: GraphQLInputType): string { - const ast = astFromValue(value, type); +function stringifyValue( + defaultValue: GraphQLDefaultValueUsage, + type: GraphQLInputType, +): string { + const ast = defaultValue.literal ?? astFromValue(defaultValue.value, type); invariant(ast != null); return print(sortValueNode(ast)); } diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index 527cf4be175..88ee7f0b4b3 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -261,10 +261,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 != null, 'Invalid default value'); + argDecl += ` = ${print(literal)}`; } return argDecl + printDeprecated(arg.deprecationReason); } diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index b0c5524fa71..9550d1f187f 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -18,6 +18,7 @@ import { visit } from '../language/visitor.js'; import type { GraphQLArgument, GraphQLCompositeType, + GraphQLDefaultValueUsage, GraphQLEnumValue, GraphQLField, GraphQLInputType, @@ -32,7 +33,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 1a2c1d9c1c7..5a5f5097732 100644 --- a/src/validation/rules/VariablesInAllowedPositionRule.ts +++ b/src/validation/rules/VariablesInAllowedPositionRule.ts @@ -6,7 +6,10 @@ import type { ValueNode } from '../../language/ast.js'; import { Kind } from '../../language/kinds.js'; import type { ASTVisitor } from '../../language/visitor.js'; -import type { GraphQLType } from '../../type/definition.js'; +import type { + GraphQLDefaultValueUsage, + GraphQLType, +} from '../../type/definition.js'; import { isNonNullType } from '../../type/definition.js'; import type { GraphQLSchema } from '../../type/schema.js'; @@ -83,7 +86,7 @@ function allowedVariableUsage( varType: GraphQLType, varDefaultValue: Maybe, locationType: GraphQLType, - locationDefaultValue: Maybe, + locationDefaultValue: GraphQLDefaultValueUsage | undefined, ): boolean { if (isNonNullType(locationType) && !isNonNullType(varType)) { const hasNonNullVariableDefaultValue =