diff --git a/src/index.ts b/src/index.ts index 702fc5d108..0fae538294 100644 --- a/src/index.ts +++ b/src/index.ts @@ -416,6 +416,10 @@ export { /** A helper to use within recursive-descent visitors which need to be aware of the GraphQL type system. */ TypeInfo, visitWithTypeInfo, + /** Converts a value to a const value by replacing variables. */ + replaceVariables, + /** Create a GraphQL literal (AST) from a JavaScript input value. */ + valueToLiteral, /** Coerces a JavaScript value to a GraphQL type, or produces errors. */ coerceInputValue, /** Coerces a GraphQL literal (AST) to a GraphQL type, or returns undefined. */ diff --git a/src/type/__tests__/definition-test.ts b/src/type/__tests__/definition-test.ts index 287ab112e5..b0ceabcfc5 100644 --- a/src/type/__tests__/definition-test.ts +++ b/src/type/__tests__/definition-test.ts @@ -4,7 +4,7 @@ import { describe, it } from 'mocha'; import { inspect } from '../../jsutils/inspect'; import { identityFunc } from '../../jsutils/identityFunc'; -import { parseValue } from '../../language/parser'; +import { parseConstValue } from '../../language/parser'; import type { GraphQLType, GraphQLNullableType } from '../definition'; import { @@ -83,15 +83,12 @@ describe('Type System: Scalars', () => { }, }); - expect(scalar.parseLiteral(parseValue('null'))).to.equal( + expect(scalar.parseLiteral(parseConstValue('null'))).to.equal( 'parseValue: null', ); - expect(scalar.parseLiteral(parseValue('{ foo: "bar" }'))).to.equal( + expect(scalar.parseLiteral(parseConstValue('{ foo: "bar" }'))).to.equal( 'parseValue: { foo: "bar" }', ); - expect( - scalar.parseLiteral(parseValue('{ foo: { bar: $var } }'), { var: 'baz' }), - ).to.equal('parseValue: { foo: { bar: "baz" } }'); }); it('rejects a Scalar type without name', () => { @@ -139,6 +136,17 @@ describe('Type System: Scalars', () => { ); }); + it('rejects a Scalar type defining valueToLiteral with an incorrect type', () => { + expect( + () => + new GraphQLScalarType({ + name: 'SomeScalar', + // @ts-expect-error + valueToLiteral: {}, + }), + ).to.throw('SomeScalar must provide "valueToLiteral" as a function.'); + }); + it('rejects a Scalar type defining specifiedByURL with an incorrect type', () => { expect( () => diff --git a/src/type/__tests__/scalars-test.ts b/src/type/__tests__/scalars-test.ts index f30b01eeac..b6248f9a05 100644 --- a/src/type/__tests__/scalars-test.ts +++ b/src/type/__tests__/scalars-test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { parseValue as parseValueToAST } from '../../language/parser'; +import { parseConstValue } from '../../language/parser'; import { GraphQLID, @@ -66,7 +66,7 @@ describe('Type System: Specified scalar types', () => { it('parseLiteral', () => { function parseLiteral(str: string) { - return GraphQLInt.parseLiteral(parseValueToAST(str), undefined); + return GraphQLInt.parseLiteral(parseConstValue(str)); } expect(parseLiteral('1')).to.equal(1); @@ -104,9 +104,6 @@ describe('Type System: Specified scalar types', () => { expect(() => parseLiteral('ENUM_VALUE')).to.throw( 'Int cannot represent non-integer value: ENUM_VALUE', ); - expect(() => parseLiteral('$var')).to.throw( - 'Int cannot represent non-integer value: $var', - ); }); it('serialize', () => { @@ -231,7 +228,7 @@ describe('Type System: Specified scalar types', () => { it('parseLiteral', () => { function parseLiteral(str: string) { - return GraphQLFloat.parseLiteral(parseValueToAST(str), undefined); + return GraphQLFloat.parseLiteral(parseConstValue(str)); } expect(parseLiteral('1')).to.equal(1); @@ -264,9 +261,6 @@ describe('Type System: Specified scalar types', () => { expect(() => parseLiteral('ENUM_VALUE')).to.throw( 'Float cannot represent non numeric value: ENUM_VALUE', ); - expect(() => parseLiteral('$var')).to.throw( - 'Float cannot represent non numeric value: $var', - ); }); it('serialize', () => { @@ -344,7 +338,7 @@ describe('Type System: Specified scalar types', () => { it('parseLiteral', () => { function parseLiteral(str: string) { - return GraphQLString.parseLiteral(parseValueToAST(str), undefined); + return GraphQLString.parseLiteral(parseConstValue(str)); } expect(parseLiteral('"foo"')).to.equal('foo'); @@ -371,9 +365,6 @@ describe('Type System: Specified scalar types', () => { expect(() => parseLiteral('ENUM_VALUE')).to.throw( 'String cannot represent a non string value: ENUM_VALUE', ); - expect(() => parseLiteral('$var')).to.throw( - 'String cannot represent a non string value: $var', - ); }); it('serialize', () => { @@ -456,7 +447,7 @@ describe('Type System: Specified scalar types', () => { it('parseLiteral', () => { function parseLiteral(str: string) { - return GraphQLBoolean.parseLiteral(parseValueToAST(str), undefined); + return GraphQLBoolean.parseLiteral(parseConstValue(str)); } expect(parseLiteral('true')).to.equal(true); @@ -489,9 +480,6 @@ describe('Type System: Specified scalar types', () => { expect(() => parseLiteral('ENUM_VALUE')).to.throw( 'Boolean cannot represent a non boolean value: ENUM_VALUE', ); - expect(() => parseLiteral('$var')).to.throw( - 'Boolean cannot represent a non boolean value: $var', - ); }); it('serialize', () => { @@ -571,7 +559,7 @@ describe('Type System: Specified scalar types', () => { it('parseLiteral', () => { function parseLiteral(str: string) { - return GraphQLID.parseLiteral(parseValueToAST(str), undefined); + return GraphQLID.parseLiteral(parseConstValue(str)); } expect(parseLiteral('""')).to.equal(''); @@ -604,9 +592,6 @@ describe('Type System: Specified scalar types', () => { expect(() => parseLiteral('ENUM_VALUE')).to.throw( 'ID cannot represent a non-string and non-integer value: ENUM_VALUE', ); - expect(() => parseLiteral('$var')).to.throw( - 'ID cannot represent a non-string and non-integer value: $var', - ); }); it('serialize', () => { diff --git a/src/type/definition.ts b/src/type/definition.ts index df6372378a..8c681ddf97 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -20,7 +20,6 @@ import { Kind } from '../language/kinds'; import { print } from '../language/printer'; import type { FieldNode, - ValueNode, ConstValueNode, OperationDefinitionNode, FragmentDefinitionNode, @@ -590,9 +589,39 @@ export interface GraphQLScalarTypeExtensions { * if (value % 2 === 1) { * return value; * } + * }, + * parseValue(value) { + * if (value % 2 === 1) { + * return value; + * } + * } + * valueToLiteral(value) { + * if (value % 2 === 1) { + * return parse(`${value}`); + * } * } * }); * + * Custom scalars behavior is defined via the following functions: + * + * - serialize(value): Implements "Result Coercion". Given an internal value, + * produces an external value valid for this type. Returns undefined or + * throws an error to indicate invalid values. + * + * - parseValue(value): Implements "Input Coercion" for values. Given an + * external value (for example, variable values), produces an internal value + * valid for this type. Returns undefined or throws an error to indicate + * invalid values. + * + * - parseLiteral(ast): Implements "Input Coercion" for literals. Given an + * GraphQL literal (AST) (for example, an argument value), produces an + * internal value valid for this type. Returns undefined or throws an error + * to indicate invalid values. + * + * - valueToLiteral(value): Converts an external value to a GraphQL + * literal (AST). Returns undefined or throws an error to indicate + * invalid values. + * */ export class GraphQLScalarType extends GraphQLSchemaElement { name: string; @@ -601,6 +630,7 @@ export class GraphQLScalarType extends GraphQLSchemaElement { serialize: GraphQLScalarSerializer; parseValue: GraphQLScalarValueParser; parseLiteral: GraphQLScalarLiteralParser; + valueToLiteral: Maybe; extensions: Maybe>; astNode: Maybe; extensionASTNodes: ReadonlyArray; @@ -614,8 +644,8 @@ export class GraphQLScalarType extends GraphQLSchemaElement { this.serialize = config.serialize ?? identityFunc; this.parseValue = parseValue; this.parseLiteral = - config.parseLiteral ?? - ((node, variables) => parseValue(valueFromASTUntyped(node, variables))); + config.parseLiteral ?? ((node) => parseValue(valueFromASTUntyped(node))); + this.valueToLiteral = config.valueToLiteral; this.extensions = config.extensions && toObjMap(config.extensions); this.astNode = config.astNode; this.extensionASTNodes = config.extensionASTNodes ?? []; @@ -641,6 +671,13 @@ export class GraphQLScalarType extends GraphQLSchemaElement { `${this.name} must provide both "parseValue" and "parseLiteral" functions.`, ); } + + if (config.valueToLiteral) { + devAssert( + typeof config.valueToLiteral === 'function', + `${this.name} must provide "valueToLiteral" as a function.`, + ); + } } toConfig(): GraphQLScalarTypeNormalizedConfig { @@ -651,6 +688,7 @@ export class GraphQLScalarType extends GraphQLSchemaElement { serialize: this.serialize, parseValue: this.parseValue, parseLiteral: this.parseLiteral, + valueToLiteral: this.valueToLiteral, extensions: this.extensions, astNode: this.astNode, extensionASTNodes: this.extensionASTNodes, @@ -671,10 +709,13 @@ export type GraphQLScalarValueParser = ( ) => Maybe; export type GraphQLScalarLiteralParser = ( - valueNode: ValueNode, - variables?: Maybe>, + valueNode: ConstValueNode, ) => Maybe; +export type GraphQLScalarValueToLiteral = ( + inputValue: unknown, +) => ConstValueNode | undefined; + export interface GraphQLScalarTypeConfig { name: string; description?: Maybe; @@ -685,6 +726,8 @@ export interface GraphQLScalarTypeConfig { parseValue?: GraphQLScalarValueParser; /** Parses an externally provided literal value to use as an input. */ parseLiteral?: GraphQLScalarLiteralParser; + /** Translates an externally provided value to a literal (AST). */ + valueToLiteral?: Maybe; extensions?: Maybe>; astNode?: Maybe; extensionASTNodes?: Maybe>; @@ -1457,10 +1500,7 @@ export class GraphQLEnumType /* */ extends GraphQLSchemaElement { return enumValue.value; } - parseLiteral( - valueNode: ValueNode, - _variables: Maybe>, - ): Maybe /* T */ { + parseLiteral(valueNode: ConstValueNode): Maybe /* T */ { // Note: variables will be resolved to a value before calling this function. if (valueNode.kind !== Kind.ENUM) { const valueStr = print(valueNode); @@ -1483,6 +1523,12 @@ export class GraphQLEnumType /* */ extends GraphQLSchemaElement { return enumValue.value; } + valueToLiteral(value: unknown): ConstValueNode | undefined { + if (typeof value === 'string' && this.getValue(value)) { + return { kind: Kind.ENUM, value }; + } + } + toConfig(): GraphQLEnumTypeNormalizedConfig { return { name: this.name, diff --git a/src/type/scalars.ts b/src/type/scalars.ts index b8a4ee11f4..ce26e1c58e 100644 --- a/src/type/scalars.ts +++ b/src/type/scalars.ts @@ -6,6 +6,8 @@ import { print } from '../language/printer'; import { GraphQLError } from '../error/GraphQLError'; +import { defaultScalarValueToLiteral } from '../utilities/valueToLiteral'; + import type { GraphQLNamedType } from './definition'; import { GraphQLScalarType } from './definition'; @@ -79,6 +81,16 @@ export const GraphQLInt: GraphQLScalarType = new GraphQLScalarType({ } return num; }, + valueToLiteral(value) { + if ( + typeof value === 'number' && + Number.isInteger(value) && + value <= MAX_INT && + value >= MIN_INT + ) { + return { kind: Kind.INT, value: String(value) }; + } + }, }); function serializeFloat(outputValue: unknown): number { @@ -125,6 +137,12 @@ export const GraphQLFloat: GraphQLScalarType = new GraphQLScalarType({ } return parseFloat(valueNode.value); }, + valueToLiteral(value) { + const literal = defaultScalarValueToLiteral(value); + if (literal.kind === Kind.FLOAT || literal.kind === Kind.INT) { + return literal; + } + }, }); // Support serializing objects with custom valueOf() or toJSON() functions - @@ -188,6 +206,12 @@ export const GraphQLString: GraphQLScalarType = new GraphQLScalarType({ } return valueNode.value; }, + valueToLiteral(value) { + const literal = defaultScalarValueToLiteral(value); + if (literal.kind === Kind.STRING) { + return literal; + } + }, }); function serializeBoolean(outputValue: unknown): boolean { @@ -227,6 +251,12 @@ export const GraphQLBoolean: GraphQLScalarType = new GraphQLScalarType({ } return valueNode.value; }, + valueToLiteral(value) { + const literal = defaultScalarValueToLiteral(value); + if (literal.kind === Kind.BOOLEAN) { + return literal; + } + }, }); function serializeID(outputValue: unknown): string { @@ -267,6 +297,16 @@ export const GraphQLID: GraphQLScalarType = new GraphQLScalarType({ } return valueNode.value; }, + valueToLiteral(value) { + // ID types can use number values and Int literals. + const stringValue = Number.isInteger(value) ? String(value) : value; + if (typeof stringValue === 'string') { + // Will parse as an IntValue. + return /^-?(?:0|[1-9][0-9]*)$/.test(stringValue) + ? { kind: Kind.INT, value: stringValue } + : { kind: Kind.STRING, value: stringValue, block: false }; + } + }, }); export const specifiedScalarTypes: ReadonlyArray = diff --git a/src/utilities/__tests__/coerceInputValue-test.ts b/src/utilities/__tests__/coerceInputValue-test.ts index cb3eab6847..318eaa3ea4 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -523,6 +523,13 @@ describe('coerceInputLiteral', () => { }); test('"value"', printScalar, '~~~"value"~~~'); + testWithVariables( + '($var: String)', + { var: 'value' }, + '{ field: $var }', + printScalar, + '~~~{field: "value"}~~~', + ); const throwScalar = new GraphQLScalarType({ name: 'ThrowScalar', diff --git a/src/utilities/__tests__/replaceVariables-test.ts b/src/utilities/__tests__/replaceVariables-test.ts new file mode 100644 index 0000000000..f71205d5a2 --- /dev/null +++ b/src/utilities/__tests__/replaceVariables-test.ts @@ -0,0 +1,77 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { ReadOnlyObjMap } from '../../jsutils/ObjMap'; +import { invariant } from '../../jsutils/invariant'; + +import type { ValueNode } from '../../language/ast'; +import { parseValue as _parseValue, Parser } from '../../language/parser'; + +import { GraphQLInt } from '../../type/scalars'; +import { GraphQLSchema } from '../../type/schema'; + +import { getVariableValues } from '../../execution/values'; + +import { replaceVariables } from '../replaceVariables'; + +function parseValue(ast: string): ValueNode { + return _parseValue(ast, { noLocation: true }); +} + +function testVariables(variableDefs: string, inputs: ReadOnlyObjMap) { + const parser = new Parser(variableDefs, { noLocation: true }); + parser.expectToken(''); + const variableValuesOrErrors = getVariableValues( + new GraphQLSchema({ types: [GraphQLInt] }), + parser.parseVariableDefinitions(), + inputs, + ); + invariant(variableValuesOrErrors.variableValues); + return variableValuesOrErrors.variableValues; +} + +describe('replaceVariables', () => { + it('does not change simple AST', () => { + const ast = parseValue('null'); + expect(replaceVariables(ast, undefined)).to.equal(ast); + }); + + it('replaces simple Variables', () => { + const ast = parseValue('$var'); + const vars = testVariables('($var: Int)', { var: 123 }); + expect(replaceVariables(ast, vars)).to.deep.equal(parseValue('123')); + }); + + it('replaces Variables with default values', () => { + const ast = parseValue('$var'); + const vars = testVariables('($var: Int = 123)', {}); + expect(replaceVariables(ast, vars)).to.deep.equal(parseValue('123')); + }); + + it('replaces nested Variables', () => { + const ast = parseValue('{ foo: [ $var ], bar: $var }'); + const vars = testVariables('($var: Int)', { var: 123 }); + expect(replaceVariables(ast, vars)).to.deep.equal( + parseValue('{ foo: [ 123 ], bar: 123 }'), + ); + }); + + it('replaces missing Variables with null', () => { + const ast = parseValue('$var'); + expect(replaceVariables(ast, undefined)).to.deep.equal(parseValue('null')); + }); + + it('replaces missing Variables in lists with null', () => { + const ast = parseValue('[1, $var]'); + expect(replaceVariables(ast, undefined)).to.deep.equal( + parseValue('[1, null]'), + ); + }); + + it('omits missing Variables from objects', () => { + const ast = parseValue('{ foo: 1, bar: $var }'); + expect(replaceVariables(ast, undefined)).to.deep.equal( + parseValue('{ foo: 1 }'), + ); + }); +}); diff --git a/src/utilities/__tests__/valueToLiteral-test.ts b/src/utilities/__tests__/valueToLiteral-test.ts new file mode 100644 index 0000000000..090ad50d7c --- /dev/null +++ b/src/utilities/__tests__/valueToLiteral-test.ts @@ -0,0 +1,217 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { GraphQLInputType } from '../../type/definition'; +import { + GraphQLList, + GraphQLNonNull, + GraphQLScalarType, + GraphQLEnumType, + GraphQLInputObjectType, +} from '../../type/definition'; +import { + GraphQLBoolean, + GraphQLInt, + GraphQLFloat, + GraphQLString, + GraphQLID, +} from '../../type/scalars'; + +import { Kind } from '../../language/kinds'; +import { parseConstValue } from '../../language/parser'; + +import { valueToLiteral, defaultScalarValueToLiteral } from '../valueToLiteral'; + +describe('valueToLiteral', () => { + function test( + value: unknown, + type: GraphQLInputType, + expected: string | undefined, + ) { + return expect(valueToLiteral(value, type)).to.deep.equal( + expected && parseConstValue(expected, { noLocation: true }), + ); + } + + it('converts null values to Null AST', () => { + test(null, GraphQLString, 'null'); + test(undefined, GraphQLString, 'null'); + test(null, new GraphQLNonNull(GraphQLString), undefined); + }); + + it('converts boolean values to Boolean ASTs', () => { + test(true, GraphQLBoolean, 'true'); + test(false, GraphQLBoolean, 'false'); + test('false', GraphQLBoolean, undefined); + }); + + it('converts int number values to Int ASTs', () => { + test(0, GraphQLInt, '0'); + test(-1, GraphQLInt, '-1'); + test(2147483647, GraphQLInt, '2147483647'); + test(2147483648, GraphQLInt, undefined); + test(0.5, GraphQLInt, undefined); + }); + + it('converts float number values to Float ASTs', () => { + test(123.5, GraphQLFloat, '123.5'); + test(2e40, GraphQLFloat, '2e+40'); + test(1099511627776, GraphQLFloat, '1099511627776'); + test('0.5', GraphQLFloat, undefined); + // Non-finite + test(NaN, GraphQLFloat, undefined); + test(Infinity, GraphQLFloat, undefined); + }); + + it('converts String ASTs to String values', () => { + test('hello world', GraphQLString, '"hello world"'); + test(123, GraphQLString, undefined); + }); + + it('converts ID values to Int/String ASTs', () => { + test('hello world', GraphQLID, '"hello world"'); + test('123', GraphQLID, '123'); + test(123, GraphQLID, '123'); + test( + '123456789123456789123456789123456789', + GraphQLID, + '123456789123456789123456789123456789', + ); + test(123.5, GraphQLID, undefined); + }); + + const myEnum = new GraphQLEnumType({ + name: 'MyEnum', + values: { + HELLO: {}, + COMPLEX: { value: { someArbitrary: 'complexValue' } }, + }, + }); + + it('converts Enum names to Enum ASTs', () => { + test('HELLO', myEnum, 'HELLO'); + test('COMPLEX', myEnum, 'COMPLEX'); + // Undefined Enum + test('GOODBYE', myEnum, undefined); + test(123, myEnum, undefined); + }); + + it('converts List ASTs to array values', () => { + test(['FOO', 'BAR'], new GraphQLList(GraphQLString), '["FOO", "BAR"]'); + test(['123', 123], new GraphQLList(GraphQLID), '[123, 123]'); + // Invalid items create an invalid result + test(['FOO', 123], new GraphQLList(GraphQLString), undefined); + // Does not coerce items to list singletons + test('FOO', new GraphQLList(GraphQLString), '"FOO"'); + }); + + const inputObj = new GraphQLInputObjectType({ + name: 'MyInputObj', + fields: { + foo: { type: new GraphQLNonNull(GraphQLFloat) }, + bar: { type: GraphQLID }, + }, + }); + + it('converts input objects', () => { + test({ foo: 3, bar: '3' }, inputObj, '{ foo: 3, bar: 3 }'); + test({ foo: 3 }, inputObj, '{ foo: 3 }'); + + // Non-object is invalid + test('123', inputObj, undefined); + + // Invalid fields create an invalid result + test({ foo: '3' }, inputObj, undefined); + + // Missing required fields create an invalid result + test({ bar: 3 }, inputObj, undefined); + + // Additional fields create an invalid result + test({ foo: 3, unknown: 3 }, inputObj, undefined); + }); + + it('custom scalar types may define valueToLiteral', () => { + const customScalar = new GraphQLScalarType({ + name: 'CustomScalar', + valueToLiteral(value) { + if (typeof value === 'string' && value[0] === '#') { + return { kind: Kind.ENUM, value: value.slice(1) }; + } + }, + }); + + test('#FOO', customScalar, 'FOO'); + test('FOO', customScalar, undefined); + }); + + it('custom scalar types may throw errors from valueToLiteral', () => { + const customScalar = new GraphQLScalarType({ + name: 'CustomScalar', + valueToLiteral() { + throw new Error(); + }, + }); + + test('FOO', customScalar, undefined); + }); + + it('custom scalar types may fall back on default valueToLiteral', () => { + const customScalar = new GraphQLScalarType({ + name: 'CustomScalar', + }); + + test({ foo: 'bar' }, customScalar, '{ foo: "bar" }'); + }); + + describe('defaultScalarValueToLiteral', () => { + function testDefault(value: unknown, expected: string | undefined) { + return expect(defaultScalarValueToLiteral(value)).to.deep.equal( + expected && parseConstValue(expected, { noLocation: true }), + ); + } + + it('converts null values to Null ASTs', () => { + testDefault(null, 'null'); + testDefault(undefined, 'null'); + }); + + it('converts boolean values to Boolean ASTs', () => { + testDefault(true, 'true'); + testDefault(false, 'false'); + }); + + it('converts number values to Int/Float ASTs', () => { + testDefault(0, '0'); + testDefault(-1, '-1'); + testDefault(1099511627776, '1099511627776'); + testDefault(123.5, '123.5'); + testDefault(2e40, '2e+40'); + }); + + it('converts non-finite number values to Null ASTs', () => { + testDefault(NaN, 'null'); + testDefault(Infinity, 'null'); + }); + + it('converts String values to String ASTs', () => { + testDefault('hello world', '"hello world"'); + }); + + it('converts array values to List ASTs', () => { + testDefault(['abc', 123], '["abc", 123]'); + }); + + it('converts object values to Object ASTs', () => { + testDefault( + { foo: 'abc', bar: null, baz: undefined }, + '{ foo: "abc", bar: null }', + ); + }); + + it('throws on values it cannot convert', () => { + expect(() => defaultScalarValueToLiteral(Symbol())).to.throw( + 'Cannot convert value to AST: Symbol().', + ); + }); + }); +}); diff --git a/src/utilities/coerceInputValue.ts b/src/utilities/coerceInputValue.ts index 77aafbd900..af057d302a 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -31,6 +31,8 @@ import { Kind } from '../language/kinds'; import type { VariableValues } from '../execution/values'; +import { replaceVariables } from './replaceVariables'; + type OnErrorCB = ( path: ReadonlyArray, invalidValue: unknown, @@ -312,9 +314,10 @@ export function coerceInputLiteral( } const leafType = assertLeafType(type); + const constValueNode = replaceVariables(valueNode, variableValues); try { - return leafType.parseLiteral(valueNode, variableValues?.coerced); + return leafType.parseLiteral(constValueNode); } catch (_error) { // Invalid: ignore error and intentionally return no value. } diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 523860165a..a2246ad85c 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -70,6 +70,12 @@ export { astFromValue } from './astFromValue'; /** A helper to use within recursive-descent visitors which need to be aware of the GraphQL type system. */ export { TypeInfo, visitWithTypeInfo } from './TypeInfo'; +// Converts a value to a const value by replacing variables. +export { replaceVariables } from './replaceVariables'; + +// Create a GraphQL literal (AST) from a JavaScript input value. +export { valueToLiteral } from './valueToLiteral'; + export { /** Coerces a JavaScript value to a GraphQL type, or produces errors. */ coerceInputValue, diff --git a/src/utilities/replaceVariables.ts b/src/utilities/replaceVariables.ts new file mode 100644 index 0000000000..1ebe0ecad3 --- /dev/null +++ b/src/utilities/replaceVariables.ts @@ -0,0 +1,49 @@ +import type { Maybe } from '../jsutils/Maybe'; + +import type { ValueNode, ConstValueNode } from '../language/ast'; +import { Kind } from '../language/kinds'; +import { visit } from '../language/visitor'; + +import type { VariableValues } from '../execution/values'; + +import { valueToLiteral } from './valueToLiteral'; + +/** + * Replaces any Variables found within an AST Value literal with literals + * supplied from a map of variable values, or removed if no variable replacement + * exists, returning a constant value. + * + * Used primarily to ensure only complete constant values are used during input + * coercion of custom scalars which accept complex literals. + */ +export function replaceVariables( + valueNode: ValueNode, + variables?: Maybe, +): ConstValueNode { + return visit(valueNode, { + Variable(node) { + const variableSource = variables?.sources[node.name.value]; + if (!variableSource) { + return { kind: Kind.NULL }; + } + if ( + variableSource.value === undefined && + variableSource.variable.defaultValue + ) { + return variableSource.variable.defaultValue; + } + return valueToLiteral(variableSource.value, variableSource.type); + }, + ObjectValue(node) { + return { + ...node, + // Filter out any fields with a missing variable. + fields: node.fields.filter( + (field) => + field.value.kind !== Kind.VARIABLE || + variables?.sources[field.value.name.value], + ), + }; + }, + }) as ConstValueNode; +} diff --git a/src/utilities/valueToLiteral.ts b/src/utilities/valueToLiteral.ts new file mode 100644 index 0000000000..10a11670dd --- /dev/null +++ b/src/utilities/valueToLiteral.ts @@ -0,0 +1,168 @@ +import { hasOwnProperty } from '../jsutils/hasOwnProperty'; +import { inspect } from '../jsutils/inspect'; +import { isIterableObject } from '../jsutils/isIterableObject'; +import { isObjectLike } from '../jsutils/isObjectLike'; + +import { Kind } from '../language/kinds'; +import type { ConstValueNode, ConstObjectFieldNode } from '../language/ast'; + +import type { GraphQLInputType } from '../type/definition'; +import { + isNonNullType, + isListType, + isInputObjectType, + assertLeafType, + isRequiredInputField, +} from '../type/definition'; + +/** + * Produces a GraphQL Value AST given a JavaScript value and a GraphQL type. + * + * Scalar types are converted by calling the `valueToLiteral` method on that + * type, otherwise the default scalar `valueToLiteral` method is used, defined + * below. + * + * The provided value is an non-coerced "input" value. This function does not + * perform any coercion, however it does perform validation. Provided values + * which are invalid for the given type will result in an `undefined` return + * value. + */ +export function valueToLiteral( + value: unknown, + type: GraphQLInputType, +): ConstValueNode | undefined { + if (isNonNullType(type)) { + if (value == null) { + return; // Invalid: intentionally return no value. + } + return valueToLiteral(value, type.ofType); + } + + // Like JSON, a null literal is produced for both null and undefined. + if (value == null) { + return { kind: Kind.NULL }; + } + + if (isListType(type)) { + if (!isIterableObject(value)) { + return valueToLiteral(value, type.ofType); + } + const values: Array = []; + for (const itemValue of value) { + const itemNode = valueToLiteral(itemValue, type.ofType); + if (!itemNode) { + return; // Invalid: intentionally return no value. + } + values.push(itemNode); + } + return { kind: Kind.LIST, values }; + } + + if (isInputObjectType(type)) { + if (!isObjectLike(value)) { + return; // Invalid: intentionally return no value. + } + const fields: Array = []; + const fieldDefs = type.getFields(); + const hasUndefinedField = Object.keys(value).some( + (name) => !hasOwnProperty(fieldDefs, name), + ); + if (hasUndefinedField) { + return; // Invalid: intentionally return no value. + } + for (const field of Object.values(type.getFields())) { + const fieldValue = value[field.name]; + if (fieldValue === undefined) { + if (isRequiredInputField(field)) { + return; // Invalid: intentionally return no value. + } + } else { + const fieldNode = valueToLiteral(value[field.name], field.type); + if (!fieldNode) { + return; // Invalid: intentionally return no value. + } + fields.push({ + kind: Kind.OBJECT_FIELD, + name: { kind: Kind.NAME, value: field.name }, + value: fieldNode, + }); + } + } + return { kind: Kind.OBJECT, fields }; + } + + const leafType = assertLeafType(type); + + if (leafType.valueToLiteral) { + try { + return leafType.valueToLiteral(value); + } catch (_error) { + return; // Invalid: intentionally ignore error and return no value. + } + } + + return defaultScalarValueToLiteral(value); +} + +/** + * The default implementation to convert scalar values to literals. + * + * | JavaScript Value | GraphQL Value | + * | ----------------- | -------------------- | + * | Object | Input Object | + * | Array | List | + * | Boolean | Boolean | + * | String | String | + * | Number | Int / Float | + * | null / undefined | Null | + * + * @internal + */ +export function defaultScalarValueToLiteral(value: unknown): ConstValueNode { + // Like JSON, a null literal is produced for both null and undefined. + if (value == null) { + return { kind: Kind.NULL }; + } + + switch (typeof value) { + case 'boolean': + return { kind: Kind.BOOLEAN, value }; + case 'string': + return { kind: Kind.STRING, value, block: false }; + case 'number': { + if (!Number.isFinite(value)) { + // Like JSON, a null literal is produced for non-finite values. + return { kind: Kind.NULL }; + } + const stringValue = String(value); + // Will parse as an IntValue. + return /^-?(?:0|[1-9][0-9]*)$/.test(stringValue) + ? { kind: Kind.INT, value: stringValue } + : { kind: Kind.FLOAT, value: stringValue }; + } + case 'object': { + if (isIterableObject(value)) { + return { + kind: Kind.LIST, + values: Array.from(value, defaultScalarValueToLiteral), + }; + } + const objValue = value as { [prop: string]: unknown }; + const fields: Array = []; + for (const fieldName of Object.keys(objValue)) { + const fieldValue = objValue[fieldName]; + // Like JSON, undefined fields are not included in the literal result. + if (fieldValue !== undefined) { + fields.push({ + kind: Kind.OBJECT_FIELD, + name: { kind: Kind.NAME, value: fieldName }, + value: defaultScalarValueToLiteral(fieldValue), + }); + } + } + return { kind: Kind.OBJECT, fields }; + } + } + + throw new TypeError(`Cannot convert value to AST: ${inspect(value)}.`); +} diff --git a/src/validation/rules/ValuesOfCorrectTypeRule.ts b/src/validation/rules/ValuesOfCorrectTypeRule.ts index 23df08f430..d29530cffd 100644 --- a/src/validation/rules/ValuesOfCorrectTypeRule.ts +++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts @@ -19,6 +19,7 @@ import { } from '../../type/definition'; import type { ValidationContext } from '../ValidationContext'; +import { replaceVariables } from '../../utilities/replaceVariables'; /** * Value literals of correct type @@ -118,10 +119,12 @@ function isValidValueNode(context: ValidationContext, node: ValueNode): void { return; } + const constValueNode = replaceVariables(node); + // Scalars and Enums determine if a literal value is valid via parseLiteral(), - // which may throw or return an invalid value to indicate failure. + // which may throw or return undefined to indicate an invalid value. try { - const parseResult = type.parseLiteral(node, undefined /* variables */); + const parseResult = type.parseLiteral(constValueNode); if (parseResult === undefined) { context.reportError( new GraphQLError(