From 9a30b6fcfaf09d38d48f2d4525c1a4936bee3eef Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Thu, 6 May 2021 00:47:39 -0700 Subject: [PATCH] Add valueToLiteral() * Adds `valueToLiteral()` which takes an external value and translates it to a literal, allowing for custom scalars to define this behavior. This also adds important changes to Input Coercion, especially for custom scalars: * The value provided to `parseLiteral` is now `ConstValueNode` and the second `variables` argument has been removed. For all built-in scalars this has no effect, but any custom scalars which use complex literals no longer need to do variable reconciliation manually (in fact most do not -- this has been an easy subtle bug to miss). This behavior is possible with the addition of `replaceVariables` --- src/index.ts | 4 + src/type/__tests__/definition-test.ts | 9 +- src/type/__tests__/scalars-test.ts | 27 +-- src/type/definition.ts | 79 +++++-- src/type/scalars.ts | 40 ++++ .../__tests__/coerceInputValue-test.ts | 7 + .../__tests__/replaceVariables-test.ts | 183 +++++++++++++++ .../__tests__/valueToLiteral-test.ts | 222 ++++++++++++++++++ src/utilities/coerceInputValue.ts | 9 +- src/utilities/index.ts | 6 + src/utilities/replaceVariables.ts | 71 ++++++ src/utilities/valueToLiteral.ts | 168 +++++++++++++ .../rules/ValuesOfCorrectTypeRule.ts | 8 +- 13 files changed, 783 insertions(+), 50 deletions(-) create mode 100644 src/utilities/__tests__/replaceVariables-test.ts create mode 100644 src/utilities/__tests__/valueToLiteral-test.ts create mode 100644 src/utilities/replaceVariables.ts create mode 100644 src/utilities/valueToLiteral.ts diff --git a/src/index.ts b/src/index.ts index 60eb3c749b..0684c4456e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -447,6 +447,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 c5e6ebf38a..1f1d1999d3 100644 --- a/src/type/__tests__/definition-test.ts +++ b/src/type/__tests__/definition-test.ts @@ -5,7 +5,7 @@ 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 { parseConstValue } from '../../language/parser.js'; import type { GraphQLNullableType, GraphQLType } from '../definition.js'; import { @@ -82,15 +82,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 defining parseLiteral but not parseValue', () => { diff --git a/src/type/__tests__/scalars-test.ts b/src/type/__tests__/scalars-test.ts index 45f77523dc..14afcc2bee 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.js'; +import { parseConstValue } from '../../language/parser.js'; import { GraphQLBoolean, @@ -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 9318eb3d41..176e934399 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -35,7 +35,6 @@ import type { ScalarTypeExtensionNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, - ValueNode, } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; import { print } from '../language/printer.js'; @@ -526,22 +525,52 @@ export interface GraphQLScalarTypeExtensions { * Example: * * ```ts + * function ensureOdd(value) { + * if (!Number.isFinite(value)) { + * throw new Error( + * `Scalar "Odd" cannot represent "${value}" since it is not a finite number.`, + * ); + * } + * + * if (value % 2 === 0) { + * throw new Error(`Scalar "Odd" cannot represent "${value}" since it is even.`); + * } + * } + * * const OddType = new GraphQLScalarType({ * name: 'Odd', * serialize(value) { - * if (!Number.isFinite(value)) { - * throw new Error( - * `Scalar "Odd" cannot represent "${value}" since it is not a finite number.`, - * ); - * } - * - * if (value % 2 === 0) { - * throw new Error(`Scalar "Odd" cannot represent "${value}" since it is even.`); - * } - * return value; + * return ensureOdd(value); + * }, + * parseValue(value) { + * return ensureOdd(value); + * } + * valueToLiteral(value) { + * return parse(`${ensureOdd(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 { name: string; @@ -550,6 +579,7 @@ export class GraphQLScalarType { serialize: GraphQLScalarSerializer; parseValue: GraphQLScalarValueParser; parseLiteral: GraphQLScalarLiteralParser; + valueToLiteral: GraphQLScalarValueToLiteral | undefined; extensions: Readonly; astNode: Maybe; extensionASTNodes: ReadonlyArray; @@ -566,8 +596,8 @@ export class GraphQLScalarType { config.serialize ?? (identityFunc as GraphQLScalarSerializer); 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 = toObjMap(config.extensions); this.astNode = config.astNode; this.extensionASTNodes = config.extensionASTNodes ?? []; @@ -593,6 +623,7 @@ export class GraphQLScalarType { serialize: this.serialize, parseValue: this.parseValue, parseLiteral: this.parseLiteral, + valueToLiteral: this.valueToLiteral, extensions: this.extensions, astNode: this.astNode, extensionASTNodes: this.extensionASTNodes, @@ -617,9 +648,12 @@ export type GraphQLScalarValueParser = ( ) => TInternal; export type GraphQLScalarLiteralParser = ( - valueNode: ValueNode, - variables?: Maybe>, -) => TInternal; + valueNode: ConstValueNode, +) => Maybe; + +export type GraphQLScalarValueToLiteral = ( + inputValue: unknown, +) => ConstValueNode | undefined; export interface GraphQLScalarTypeConfig { name: string; @@ -631,6 +665,8 @@ export interface GraphQLScalarTypeConfig { parseValue?: GraphQLScalarValueParser | undefined; /** Parses an externally provided literal value to use as an input. */ parseLiteral?: GraphQLScalarLiteralParser | undefined; + /** Translates an externally provided value to a literal (AST). */ + valueToLiteral?: GraphQLScalarValueToLiteral | undefined; extensions?: Maybe>; astNode?: Maybe; extensionASTNodes?: Maybe>; @@ -1374,10 +1410,7 @@ export class GraphQLEnumType /* */ { 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); @@ -1400,6 +1433,12 @@ export class GraphQLEnumType /* */ { return enumValue.value; } + valueToLiteral(value: unknown): ConstValueNode | undefined { + if (typeof value === 'string' && this.getValue(value)) { + return { kind: Kind.ENUM, value }; + } + } + toConfig(): GraphQLEnumTypeNormalizedConfig { const values = keyValMap( this.getValues(), diff --git a/src/type/scalars.ts b/src/type/scalars.ts index d2c9c9bee2..0568874387 100644 --- a/src/type/scalars.ts +++ b/src/type/scalars.ts @@ -6,6 +6,8 @@ import { GraphQLError } from '../error/GraphQLError.js'; import { Kind } from '../language/kinds.js'; import { print } from '../language/printer.js'; +import { defaultScalarValueToLiteral } from '../utilities/valueToLiteral.js'; + import type { GraphQLNamedType } from './definition.js'; import { GraphQLScalarType } from './definition.js'; @@ -82,6 +84,16 @@ export const GraphQLInt = new GraphQLScalarType({ } return num; }, + valueToLiteral(value) { + if ( + typeof value === 'number' && + Number.isInteger(value) && + value <= GRAPHQL_MAX_INT && + value >= GRAPHQL_MIN_INT + ) { + return { kind: Kind.INT, value: String(value) }; + } + }, }); export const GraphQLFloat = new GraphQLScalarType({ @@ -127,6 +139,12 @@ export const GraphQLFloat = new GraphQLScalarType({ } return parseFloat(valueNode.value); }, + valueToLiteral(value) { + const literal = defaultScalarValueToLiteral(value); + if (literal.kind === Kind.FLOAT || literal.kind === Kind.INT) { + return literal; + } + }, }); export const GraphQLString = new GraphQLScalarType({ @@ -171,6 +189,12 @@ export const GraphQLString = new GraphQLScalarType({ } return valueNode.value; }, + valueToLiteral(value) { + const literal = defaultScalarValueToLiteral(value); + if (literal.kind === Kind.STRING) { + return literal; + } + }, }); export const GraphQLBoolean = new GraphQLScalarType({ @@ -209,6 +233,12 @@ export const GraphQLBoolean = new GraphQLScalarType({ } return valueNode.value; }, + valueToLiteral(value) { + const literal = defaultScalarValueToLiteral(value); + if (literal.kind === Kind.BOOLEAN) { + return literal; + } + }, }); export const GraphQLID = new GraphQLScalarType({ @@ -250,6 +280,16 @@ export const GraphQLID = 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 0a71e39cba..1bc2eabe25 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -631,6 +631,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..df6293fc78 --- /dev/null +++ b/src/utilities/__tests__/replaceVariables-test.ts @@ -0,0 +1,183 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { invariant } from '../../jsutils/invariant.js'; +import type { ReadOnlyObjMap } from '../../jsutils/ObjMap.js'; + +import type { ValueNode } from '../../language/ast.js'; +import { Parser, parseValue as _parseValue } from '../../language/parser.js'; +import { TokenKind } from '../../language/tokenKind.js'; + +import { GraphQLInt } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { getVariableValues } from '../../execution/values.js'; + +import { replaceVariables } from '../replaceVariables.js'; + +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(TokenKind.SOF); + const variableValuesOrErrors = getVariableValues( + new GraphQLSchema({ types: [GraphQLInt] }), + parser.parseVariableDefinitions(), + inputs, + ); + invariant(variableValuesOrErrors.variableValues !== undefined); + return variableValuesOrErrors.variableValues; +} + +describe('replaceVariables', () => { + describe('Operation Variables', () => { + 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 }'), + ); + }); + + describe('Fragment Variables', () => { + it('replaces simple Fragment Variables', () => { + const ast = parseValue('$var'); + const fragmentVars = testVariables('($var: Int)', { var: 123 }); + expect(replaceVariables(ast, undefined, fragmentVars)).to.deep.equal( + parseValue('123'), + ); + }); + + it('replaces simple Fragment Variables even when overlapping with Operation Variables', () => { + const ast = parseValue('$var'); + const operationVars = testVariables('($var: Int)', { var: 123 }); + const fragmentVars = testVariables('($var: Int)', { var: 456 }); + expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal( + parseValue('456'), + ); + }); + + it('replaces Fragment Variables with default values', () => { + const ast = parseValue('$var'); + const fragmentVars = testVariables('($var: Int = 123)', {}); + expect(replaceVariables(ast, undefined, fragmentVars)).to.deep.equal( + parseValue('123'), + ); + }); + + it('replaces Fragment Variables with default values even when overlapping with Operation Variables', () => { + const ast = parseValue('$var'); + const operationVars = testVariables('($var: Int = 123)', {}); + const fragmentVars = testVariables('($var: Int = 456)', {}); + expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal( + parseValue('456'), + ); + }); + + it('replaces nested Fragment Variables', () => { + const ast = parseValue('{ foo: [ $var ], bar: $var }'); + const fragmentVars = testVariables('($var: Int)', { var: 123 }); + expect(replaceVariables(ast, undefined, fragmentVars)).to.deep.equal( + parseValue('{ foo: [ 123 ], bar: 123 }'), + ); + }); + + it('replaces nested Fragment Variables even when overlapping with Operation Variables', () => { + const ast = parseValue('{ foo: [ $var ], bar: $var }'); + const operationVars = testVariables('($var: Int)', { var: 123 }); + const fragmentVars = testVariables('($var: Int)', { var: 456 }); + expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal( + parseValue('{ foo: [ 456 ], bar: 456 }'), + ); + }); + + it('replaces missing Fragment Variables with null', () => { + const ast = parseValue('$var'); + expect(replaceVariables(ast, undefined, undefined)).to.deep.equal( + parseValue('null'), + ); + }); + + it('replaces missing Fragment Variables with null even when overlapping with Operation Variables', () => { + const ast = parseValue('$var'); + const operationVars = testVariables('($var: Int)', { var: 123 }); + const fragmentVars = testVariables('($var: Int)', {}); + expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal( + parseValue('null'), + ); + }); + + it('replaces missing Fragment Variables in lists with null', () => { + const ast = parseValue('[1, $var]'); + expect(replaceVariables(ast, undefined, undefined)).to.deep.equal( + parseValue('[1, null]'), + ); + }); + + it('replaces missing Fragment Variables in lists with null even when overlapping with Operation Variables', () => { + const ast = parseValue('[1, $var]'); + const operationVars = testVariables('($var: Int)', { var: 123 }); + const fragmentVars = testVariables('($var: Int)', {}); + expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal( + parseValue('[1, null]'), + ); + }); + + it('omits missing Fragment Variables from objects', () => { + const ast = parseValue('{ foo: 1, bar: $var }'); + expect(replaceVariables(ast, undefined, undefined)).to.deep.equal( + parseValue('{ foo: 1 }'), + ); + }); + + it('omits missing Fragment Variables from objects even when overlapping with Operation Variables', () => { + const ast = parseValue('{ foo: 1, bar: $var }'); + const operationVars = testVariables('($var: Int)', { var: 123 }); + const fragmentVars = testVariables('($var: Int)', {}); + expect(replaceVariables(ast, operationVars, fragmentVars)).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..8db4f869f8 --- /dev/null +++ b/src/utilities/__tests__/valueToLiteral-test.ts @@ -0,0 +1,222 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { Kind } from '../../language/kinds.js'; +import { parseConstValue } from '../../language/parser.js'; + +import type { GraphQLInputType } from '../../type/definition.js'; +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLList, + GraphQLNonNull, + GraphQLScalarType, +} from '../../type/definition.js'; +import { + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLString, +} from '../../type/scalars.js'; + +import { + defaultScalarValueToLiteral, + valueToLiteral, +} from '../valueToLiteral.js'; + +describe('valueToLiteral', () => { + function test( + value: unknown, + type: GraphQLInputType, + expected: string | undefined, + ) { + return expect(valueToLiteral(value, type)).to.deep.equal( + expected == null + ? undefined + : 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.startsWith('#')) { + 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) { + return expect(defaultScalarValueToLiteral(value)).to.deep.equal( + 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 3122edb2ab..52e19acf5e 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -29,6 +29,8 @@ import { import type { VariableValues } from '../execution/values.js'; +import { replaceVariables } from './replaceVariables.js'; + type OnErrorCB = ( path: ReadonlyArray, invalidValue: unknown, @@ -369,9 +371,14 @@ export function coerceInputLiteral( } const leafType = assertLeafType(type); + const constValueNode = replaceVariables( + valueNode, + variableValues, + fragmentVariableValues, + ); 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 a60371f454..6732f352a2 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -65,6 +65,12 @@ export { astFromValue } from './astFromValue.js'; // A helper to use within recursive-descent visitors which need to be aware of the GraphQL type system. export { TypeInfo, visitWithTypeInfo } from './TypeInfo.js'; +// Converts a value to a const value by replacing variables. +export { replaceVariables } from './replaceVariables.js'; + +// Create a GraphQL literal (AST) from a JavaScript input value. +export { valueToLiteral } from './valueToLiteral.js'; + 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..747931fab7 --- /dev/null +++ b/src/utilities/replaceVariables.ts @@ -0,0 +1,71 @@ +import type { Maybe } from '../jsutils/Maybe.js'; + +import type { ConstValueNode, ValueNode } from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; +import { visit } from '../language/visitor.js'; + +import type { VariableValues } from '../execution/values.js'; + +import { valueToLiteral } from './valueToLiteral.js'; + +/** + * 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, + variableValues?: Maybe, + fragmentVariableValues?: Maybe, +): ConstValueNode { + return visit(valueNode, { + Variable(node) { + const varName = node.name.value; + const scopedVariableValues = fragmentVariableValues?.sources[varName] + ? fragmentVariableValues + : variableValues; + + if (scopedVariableValues == null) { + return { kind: Kind.NULL }; + } + + const scopedVariableSource = scopedVariableValues.sources[varName]; + if (scopedVariableSource.value === undefined) { + const defaultValue = scopedVariableSource.signature.defaultValue; + if (defaultValue !== undefined) { + return defaultValue.literal; + } + } + + return valueToLiteral( + scopedVariableSource.value, + scopedVariableSource.signature.type, + ); + }, + ObjectValue(node) { + return { + ...node, + // Filter out any fields with a missing variable. + fields: node.fields.filter((field) => { + if (field.value.kind !== Kind.VARIABLE) { + return true; + } + const scopedVariableSource = + fragmentVariableValues?.sources[field.value.name.value] ?? + variableValues?.sources[field.value.name.value]; + + if ( + scopedVariableSource?.value === undefined && + scopedVariableSource?.signature.defaultValue === undefined + ) { + return false; + } + return true; + }), + }; + }, + }) as ConstValueNode; +} diff --git a/src/utilities/valueToLiteral.ts b/src/utilities/valueToLiteral.ts new file mode 100644 index 0000000000..bb61b41ca5 --- /dev/null +++ b/src/utilities/valueToLiteral.ts @@ -0,0 +1,168 @@ +import { inspect } from '../jsutils/inspect.js'; +import { isIterableObject } from '../jsutils/isIterableObject.js'; +import { isObjectLike } from '../jsutils/isObjectLike.js'; + +import type { ConstObjectFieldNode, ConstValueNode } from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; + +import type { GraphQLInputType } from '../type/definition.js'; +import { + assertLeafType, + isInputObjectType, + isListType, + isNonNullType, + isRequiredInputField, +} from '../type/definition.js'; + +/** + * 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) => !Object.hasOwn(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 }; + } + + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check + 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 a86a66bd47..50d51b5ffa 100644 --- a/src/validation/rules/ValuesOfCorrectTypeRule.ts +++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts @@ -25,6 +25,8 @@ import { isRequiredInputField, } from '../../type/definition.js'; +import { replaceVariables } from '../../utilities/replaceVariables.js'; + import type { ValidationContext } from '../ValidationContext.js'; /** @@ -151,10 +153,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) { const typeStr = inspect(locationType); context.reportError(