From e740389743256f941a8dcd8f727a3de7a3a44116 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 `replaceASTVariables` --- 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 | 78 +++++++ .../__tests__/valueToLiteral-test.ts | 217 ++++++++++++++++++ src/utilities/coerceInputValue.ts | 5 +- src/utilities/index.ts | 6 + src/utilities/replaceVariables.ts | 49 ++++ src/utilities/valueToLiteral.ts | 169 ++++++++++++++ .../rules/ValuesOfCorrectTypeRule.ts | 8 +- 13 files changed, 648 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 89c8eeac66c..702b7c2e98d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -445,6 +445,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 0ff22cb9135..aa7089f0a1f 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 45f77523dc5..14afcc2beeb 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 d202cb9fa4c..51da44baee7 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'; @@ -551,22 +550,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< TInternal = unknown, @@ -578,6 +607,7 @@ export class GraphQLScalarType< serialize: GraphQLScalarSerializer; parseValue: GraphQLScalarValueParser; parseLiteral: GraphQLScalarLiteralParser; + valueToLiteral: GraphQLScalarValueToLiteral | undefined; extensions: Readonly; astNode: Maybe; extensionASTNodes: ReadonlyArray; @@ -595,8 +625,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 ?? []; @@ -622,6 +652,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, @@ -638,9 +669,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; @@ -652,6 +686,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>; @@ -1381,10 +1417,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); @@ -1407,6 +1440,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 d2c9c9bee2f..05688743873 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 d61c4150e1e..d203c83b8c2 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -525,6 +525,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 00000000000..b81b9272c0e --- /dev/null +++ b/src/utilities/__tests__/replaceVariables-test.ts @@ -0,0 +1,78 @@ +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', () => { + 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 00000000000..40d3f10b82b --- /dev/null +++ b/src/utilities/__tests__/valueToLiteral-test.ts @@ -0,0 +1,217 @@ +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 && 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 | 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 02018a8234c..61ee518124d 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -31,6 +31,8 @@ import { import type { VariableValues } from '../execution/values.js'; +import { replaceVariables } from './replaceVariables.js'; + type OnErrorCB = ( path: ReadonlyArray, invalidValue: unknown, @@ -306,9 +308,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 ad0cdabc4e6..be6ee3e0c9c 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -64,6 +64,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 00000000000..3852f1dda67 --- /dev/null +++ b/src/utilities/replaceVariables.ts @@ -0,0 +1,49 @@ +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, + 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 00000000000..e227da040e3 --- /dev/null +++ b/src/utilities/valueToLiteral.ts @@ -0,0 +1,169 @@ +import { hasOwnProperty } from '../jsutils/hasOwnProperty.js'; +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) => !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 }; + } + + // 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 47de1c38e89..246d0ab9291 100644 --- a/src/validation/rules/ValuesOfCorrectTypeRule.ts +++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts @@ -18,6 +18,8 @@ import { isRequiredInputField, } from '../../type/definition.js'; +import { replaceVariables } from '../../utilities/replaceVariables.js'; + import type { ValidationContext } from '../ValidationContext.js'; /** @@ -120,10 +122,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(