From bc6af39ab5b4777b7e14c30b37072ed2bf5bb7f5 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: * Addition of `parseConstLiteral()` to leaf types which operates in parallel to `parseLiteral()` but take `ConstValueNode` instead of `ValueNode` -- 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()`. `parseLiteral()` is no longer used internally and has been marked for deprecation. --- integrationTests/ts/kitchenSink-test.ts | 2 +- src/execution/__tests__/variables-test.ts | 6 +- src/index.ts | 4 + src/type/__tests__/definition-test.ts | 30 ++- src/type/__tests__/scalars-test.ts | 173 +++++++------- src/type/definition.ts | 101 +++++++- src/type/scalars.ts | 50 +++- .../__tests__/coerceInputValue-test.ts | 17 +- .../__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 | 10 +- 15 files changed, 920 insertions(+), 132 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/integrationTests/ts/kitchenSink-test.ts b/integrationTests/ts/kitchenSink-test.ts index 8d27ec0e97..c182be287e 100644 --- a/integrationTests/ts/kitchenSink-test.ts +++ b/integrationTests/ts/kitchenSink-test.ts @@ -8,7 +8,7 @@ new GraphQLScalarType({ name: 'SomeScalar', serialize: undefined, parseValue: undefined, - parseLiteral: undefined, + parseConstLiteral: undefined, }); new GraphQLError('test', { nodes: undefined }); diff --git a/src/execution/__tests__/variables-test.ts b/src/execution/__tests__/variables-test.ts index 7fe5a8a10d..922a8b9e4f 100644 --- a/src/execution/__tests__/variables-test.ts +++ b/src/execution/__tests__/variables-test.ts @@ -47,7 +47,7 @@ const TestFaultyScalar = new GraphQLScalarType({ parseValue() { throw TestFaultyScalarGraphQLError; }, - parseLiteral() { + parseConstLiteral() { throw TestFaultyScalarGraphQLError; }, }); @@ -58,7 +58,7 @@ const TestComplexScalar = new GraphQLScalarType({ expect(value).to.equal('SerializedValue'); return 'DeserializedValue'; }, - parseLiteral(ast) { + parseConstLiteral(ast) { expect(ast).to.include({ kind: 'StringValue', value: 'SerializedValue' }); return 'DeserializedValue'; }, @@ -281,7 +281,7 @@ describe('Execute: Handles inputs', () => { }); }); - it('properly runs parseLiteral on complex scalar types', () => { + it('properly runs parseConstLiteral on complex scalar types', () => { const result = executeQuery(` { fieldWithObjectInput(input: {c: "foo", d: "SerializedValue"}) diff --git a/src/index.ts b/src/index.ts index e9f4c8f5a5..5d908fd81a 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 c5e6ebf38a..1e82312660 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 { @@ -55,13 +55,13 @@ describe('Type System: Scalars', () => { ).not.to.throw(); }); - it('accepts a Scalar type defining parseValue and parseLiteral', () => { + it('accepts a Scalar type defining parseValue and parseConstLiteral', () => { expect( () => new GraphQLScalarType({ name: 'SomeScalar', parseValue: dummyFunc, - parseLiteral: dummyFunc, + parseConstLiteral: dummyFunc, }), ).to.not.throw(); }); @@ -72,9 +72,10 @@ describe('Type System: Scalars', () => { expect(scalar.serialize).to.equal(identityFunc); expect(scalar.parseValue).to.equal(identityFunc); expect(scalar.parseLiteral).to.be.a('function'); + expect(scalar.parseConstLiteral).to.be.a('function'); }); - it('use parseValue for parsing literals if parseLiteral omitted', () => { + it('use parseValue for parsing literals if parseConstLiteral omitted', () => { const scalar = new GraphQLScalarType({ name: 'Foo', parseValue(value) { @@ -82,15 +83,12 @@ describe('Type System: Scalars', () => { }, }); - expect(scalar.parseLiteral(parseValue('null'))).to.equal( + expect(scalar.parseConstLiteral(parseConstValue('null'))).to.equal( 'parseValue: null', ); - expect(scalar.parseLiteral(parseValue('{ foo: "bar" }'))).to.equal( - 'parseValue: { foo: "bar" }', - ); expect( - scalar.parseLiteral(parseValue('{ foo: { bar: $var } }'), { var: 'baz' }), - ).to.equal('parseValue: { foo: { bar: "baz" } }'); + scalar.parseConstLiteral(parseConstValue('{ foo: "bar" }')), + ).to.equal('parseValue: { foo: "bar" }'); }); it('rejects a Scalar type defining parseLiteral but not parseValue', () => { @@ -104,6 +102,18 @@ describe('Type System: Scalars', () => { 'SomeScalar must provide both "parseValue" and "parseLiteral" functions.', ); }); + + it('rejects a Scalar type defining parseConstLiteral but not parseValue', () => { + expect( + () => + new GraphQLScalarType({ + name: 'SomeScalar', + parseConstLiteral: dummyFunc, + }), + ).to.throw( + 'SomeScalar must provide both "parseValue" and "parseConstLiteral" functions.', + ); + }); }); describe('Type System: Objects', () => { diff --git a/src/type/__tests__/scalars-test.ts b/src/type/__tests__/scalars-test.ts index 45f77523dc..eff300918d 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, @@ -64,49 +64,46 @@ describe('Type System: Specified scalar types', () => { ); }); - it('parseLiteral', () => { - function parseLiteral(str: string) { - return GraphQLInt.parseLiteral(parseValueToAST(str), undefined); + it('parseConstLiteral', () => { + function parseConstLiteral(str: string) { + return GraphQLInt.parseConstLiteral(parseConstValue(str)); } - expect(parseLiteral('1')).to.equal(1); - expect(parseLiteral('0')).to.equal(0); - expect(parseLiteral('-1')).to.equal(-1); + expect(parseConstLiteral('1')).to.equal(1); + expect(parseConstLiteral('0')).to.equal(0); + expect(parseConstLiteral('-1')).to.equal(-1); - expect(() => parseLiteral('9876504321')).to.throw( + expect(() => parseConstLiteral('9876504321')).to.throw( 'Int cannot represent non 32-bit signed integer value: 9876504321', ); - expect(() => parseLiteral('-9876504321')).to.throw( + expect(() => parseConstLiteral('-9876504321')).to.throw( 'Int cannot represent non 32-bit signed integer value: -9876504321', ); - expect(() => parseLiteral('1.0')).to.throw( + expect(() => parseConstLiteral('1.0')).to.throw( 'Int cannot represent non-integer value: 1.0', ); - expect(() => parseLiteral('null')).to.throw( + expect(() => parseConstLiteral('null')).to.throw( 'Int cannot represent non-integer value: null', ); - expect(() => parseLiteral('""')).to.throw( + expect(() => parseConstLiteral('""')).to.throw( 'Int cannot represent non-integer value: ""', ); - expect(() => parseLiteral('"123"')).to.throw( + expect(() => parseConstLiteral('"123"')).to.throw( 'Int cannot represent non-integer value: "123"', ); - expect(() => parseLiteral('false')).to.throw( + expect(() => parseConstLiteral('false')).to.throw( 'Int cannot represent non-integer value: false', ); - expect(() => parseLiteral('[1]')).to.throw( + expect(() => parseConstLiteral('[1]')).to.throw( 'Int cannot represent non-integer value: [1]', ); - expect(() => parseLiteral('{ value: 1 }')).to.throw( + expect(() => parseConstLiteral('{ value: 1 }')).to.throw( 'Int cannot represent non-integer value: { value: 1 }', ); - expect(() => parseLiteral('ENUM_VALUE')).to.throw( + expect(() => parseConstLiteral('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', () => { @@ -229,44 +226,41 @@ describe('Type System: Specified scalar types', () => { ); }); - it('parseLiteral', () => { - function parseLiteral(str: string) { - return GraphQLFloat.parseLiteral(parseValueToAST(str), undefined); + it('parseConstLiteral', () => { + function parseConstLiteral(str: string) { + return GraphQLFloat.parseConstLiteral(parseConstValue(str)); } - expect(parseLiteral('1')).to.equal(1); - expect(parseLiteral('0')).to.equal(0); - expect(parseLiteral('-1')).to.equal(-1); - expect(parseLiteral('0.1')).to.equal(0.1); - expect(parseLiteral(Math.PI.toString())).to.equal(Math.PI); + expect(parseConstLiteral('1')).to.equal(1); + expect(parseConstLiteral('0')).to.equal(0); + expect(parseConstLiteral('-1')).to.equal(-1); + expect(parseConstLiteral('0.1')).to.equal(0.1); + expect(parseConstLiteral(Math.PI.toString())).to.equal(Math.PI); - expect(() => parseLiteral('null')).to.throw( + expect(() => parseConstLiteral('null')).to.throw( 'Float cannot represent non numeric value: null', ); - expect(() => parseLiteral('""')).to.throw( + expect(() => parseConstLiteral('""')).to.throw( 'Float cannot represent non numeric value: ""', ); - expect(() => parseLiteral('"123"')).to.throw( + expect(() => parseConstLiteral('"123"')).to.throw( 'Float cannot represent non numeric value: "123"', ); - expect(() => parseLiteral('"123.5"')).to.throw( + expect(() => parseConstLiteral('"123.5"')).to.throw( 'Float cannot represent non numeric value: "123.5"', ); - expect(() => parseLiteral('false')).to.throw( + expect(() => parseConstLiteral('false')).to.throw( 'Float cannot represent non numeric value: false', ); - expect(() => parseLiteral('[0.1]')).to.throw( + expect(() => parseConstLiteral('[0.1]')).to.throw( 'Float cannot represent non numeric value: [0.1]', ); - expect(() => parseLiteral('{ value: 0.1 }')).to.throw( + expect(() => parseConstLiteral('{ value: 0.1 }')).to.throw( 'Float cannot represent non numeric value: { value: 0.1 }', ); - expect(() => parseLiteral('ENUM_VALUE')).to.throw( + expect(() => parseConstLiteral('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', () => { @@ -342,38 +336,35 @@ describe('Type System: Specified scalar types', () => { ); }); - it('parseLiteral', () => { - function parseLiteral(str: string) { - return GraphQLString.parseLiteral(parseValueToAST(str), undefined); + it('parseConstLiteral', () => { + function parseConstLiteral(str: string) { + return GraphQLString.parseConstLiteral(parseConstValue(str)); } - expect(parseLiteral('"foo"')).to.equal('foo'); - expect(parseLiteral('"""bar"""')).to.equal('bar'); + expect(parseConstLiteral('"foo"')).to.equal('foo'); + expect(parseConstLiteral('"""bar"""')).to.equal('bar'); - expect(() => parseLiteral('null')).to.throw( + expect(() => parseConstLiteral('null')).to.throw( 'String cannot represent a non string value: null', ); - expect(() => parseLiteral('1')).to.throw( + expect(() => parseConstLiteral('1')).to.throw( 'String cannot represent a non string value: 1', ); - expect(() => parseLiteral('0.1')).to.throw( + expect(() => parseConstLiteral('0.1')).to.throw( 'String cannot represent a non string value: 0.1', ); - expect(() => parseLiteral('false')).to.throw( + expect(() => parseConstLiteral('false')).to.throw( 'String cannot represent a non string value: false', ); - expect(() => parseLiteral('["foo"]')).to.throw( + expect(() => parseConstLiteral('["foo"]')).to.throw( 'String cannot represent a non string value: ["foo"]', ); - expect(() => parseLiteral('{ value: "foo" }')).to.throw( + expect(() => parseConstLiteral('{ value: "foo" }')).to.throw( 'String cannot represent a non string value: { value: "foo" }', ); - expect(() => parseLiteral('ENUM_VALUE')).to.throw( + expect(() => parseConstLiteral('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', () => { @@ -454,44 +445,41 @@ describe('Type System: Specified scalar types', () => { ); }); - it('parseLiteral', () => { - function parseLiteral(str: string) { - return GraphQLBoolean.parseLiteral(parseValueToAST(str), undefined); + it('parseConstLiteral', () => { + function parseConstLiteral(str: string) { + return GraphQLBoolean.parseConstLiteral(parseConstValue(str)); } - expect(parseLiteral('true')).to.equal(true); - expect(parseLiteral('false')).to.equal(false); + expect(parseConstLiteral('true')).to.equal(true); + expect(parseConstLiteral('false')).to.equal(false); - expect(() => parseLiteral('null')).to.throw( + expect(() => parseConstLiteral('null')).to.throw( 'Boolean cannot represent a non boolean value: null', ); - expect(() => parseLiteral('0')).to.throw( + expect(() => parseConstLiteral('0')).to.throw( 'Boolean cannot represent a non boolean value: 0', ); - expect(() => parseLiteral('1')).to.throw( + expect(() => parseConstLiteral('1')).to.throw( 'Boolean cannot represent a non boolean value: 1', ); - expect(() => parseLiteral('0.1')).to.throw( + expect(() => parseConstLiteral('0.1')).to.throw( 'Boolean cannot represent a non boolean value: 0.1', ); - expect(() => parseLiteral('""')).to.throw( + expect(() => parseConstLiteral('""')).to.throw( 'Boolean cannot represent a non boolean value: ""', ); - expect(() => parseLiteral('"false"')).to.throw( + expect(() => parseConstLiteral('"false"')).to.throw( 'Boolean cannot represent a non boolean value: "false"', ); - expect(() => parseLiteral('[false]')).to.throw( + expect(() => parseConstLiteral('[false]')).to.throw( 'Boolean cannot represent a non boolean value: [false]', ); - expect(() => parseLiteral('{ value: false }')).to.throw( + expect(() => parseConstLiteral('{ value: false }')).to.throw( 'Boolean cannot represent a non boolean value: { value: false }', ); - expect(() => parseLiteral('ENUM_VALUE')).to.throw( + expect(() => parseConstLiteral('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', () => { @@ -569,44 +557,45 @@ describe('Type System: Specified scalar types', () => { ); }); - it('parseLiteral', () => { - function parseLiteral(str: string) { - return GraphQLID.parseLiteral(parseValueToAST(str), undefined); + it('parseConstLiteral', () => { + function parseConstLiteral(str: string) { + return GraphQLID.parseConstLiteral(parseConstValue(str)); } - expect(parseLiteral('""')).to.equal(''); - expect(parseLiteral('"1"')).to.equal('1'); - expect(parseLiteral('"foo"')).to.equal('foo'); - expect(parseLiteral('"""foo"""')).to.equal('foo'); - expect(parseLiteral('1')).to.equal('1'); - expect(parseLiteral('0')).to.equal('0'); - expect(parseLiteral('-1')).to.equal('-1'); + expect(parseConstLiteral('""')).to.equal(''); + expect(parseConstLiteral('"1"')).to.equal('1'); + expect(parseConstLiteral('"foo"')).to.equal('foo'); + expect(parseConstLiteral('"""foo"""')).to.equal('foo'); + expect(parseConstLiteral('1')).to.equal('1'); + expect(parseConstLiteral('0')).to.equal('0'); + expect(parseConstLiteral('-1')).to.equal('-1'); // Support arbitrary long numbers even if they can't be represented in JS - expect(parseLiteral('90071992547409910')).to.equal('90071992547409910'); - expect(parseLiteral('-90071992547409910')).to.equal('-90071992547409910'); + expect(parseConstLiteral('90071992547409910')).to.equal( + '90071992547409910', + ); + expect(parseConstLiteral('-90071992547409910')).to.equal( + '-90071992547409910', + ); - expect(() => parseLiteral('null')).to.throw( + expect(() => parseConstLiteral('null')).to.throw( 'ID cannot represent a non-string and non-integer value: null', ); - expect(() => parseLiteral('0.1')).to.throw( + expect(() => parseConstLiteral('0.1')).to.throw( 'ID cannot represent a non-string and non-integer value: 0.1', ); - expect(() => parseLiteral('false')).to.throw( + expect(() => parseConstLiteral('false')).to.throw( 'ID cannot represent a non-string and non-integer value: false', ); - expect(() => parseLiteral('["1"]')).to.throw( + expect(() => parseConstLiteral('["1"]')).to.throw( 'ID cannot represent a non-string and non-integer value: ["1"]', ); - expect(() => parseLiteral('{ value: "1" }')).to.throw( + expect(() => parseConstLiteral('{ value: "1" }')).to.throw( 'ID cannot represent a non-string and non-integer value: { value: "1" }', ); - expect(() => parseLiteral('ENUM_VALUE')).to.throw( + expect(() => parseConstLiteral('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 6ce4115a87..15cda87d4b 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -528,22 +528,57 @@ 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 including + * non-specified replacement of variables embedded within complex scalars. + * This method will be removed in v18 favor of the combination of the + * `replaceVariables()` utility and the `parseConstLiteral()` method. + * + * - parseConstLiteral(ast): Implements "Input Coercion" for constant 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; @@ -551,7 +586,10 @@ export class GraphQLScalarType { specifiedByURL: Maybe; serialize: GraphQLScalarSerializer; parseValue: GraphQLScalarValueParser; + /** @deprecated use `replaceVariables()` and `parseConstLiteral()` instead, `parseLiteral()` will be deprecated in v18 */ parseLiteral: GraphQLScalarLiteralParser; + parseConstLiteral: GraphQLScalarConstLiteralParser; + valueToLiteral: GraphQLScalarValueToLiteral | undefined; extensions: Readonly; astNode: Maybe; extensionASTNodes: ReadonlyArray; @@ -570,6 +608,10 @@ export class GraphQLScalarType { this.parseLiteral = config.parseLiteral ?? ((node, variables) => parseValue(valueFromASTUntyped(node, variables))); + this.parseConstLiteral = + config.parseConstLiteral ?? + ((node) => parseValue(valueFromASTUntyped(node))); + this.valueToLiteral = config.valueToLiteral; this.extensions = toObjMap(config.extensions); this.astNode = config.astNode; this.extensionASTNodes = config.extensionASTNodes ?? []; @@ -581,6 +623,14 @@ export class GraphQLScalarType { `${this.name} must provide both "parseValue" and "parseLiteral" functions.`, ); } + + if (config.parseConstLiteral) { + devAssert( + typeof config.parseValue === 'function' && + typeof config.parseConstLiteral === 'function', + `${this.name} must provide both "parseValue" and "parseConstLiteral" functions.`, + ); + } } get [Symbol.toStringTag]() { @@ -595,6 +645,8 @@ export class GraphQLScalarType { serialize: this.serialize, parseValue: this.parseValue, parseLiteral: this.parseLiteral, + parseConstLiteral: this.parseConstLiteral, + valueToLiteral: this.valueToLiteral, extensions: this.extensions, astNode: this.astNode, extensionASTNodes: this.extensionASTNodes, @@ -620,8 +672,16 @@ export type GraphQLScalarValueParser = ( export type GraphQLScalarLiteralParser = ( valueNode: ValueNode, - variables?: Maybe>, -) => TInternal; + variables: Maybe>, +) => Maybe; + +export type GraphQLScalarConstLiteralParser = ( + valueNode: ConstValueNode, +) => Maybe; + +export type GraphQLScalarValueToLiteral = ( + inputValue: unknown, +) => ConstValueNode | undefined; export interface GraphQLScalarTypeConfig { name: string; @@ -632,7 +692,12 @@ export interface GraphQLScalarTypeConfig { /** Parses an externally provided value to use as an input. */ parseValue?: GraphQLScalarValueParser | undefined; /** Parses an externally provided literal value to use as an input. */ + /** @deprecated use `replaceVariables()` and `parseConstLiteral()` instead, `parseLiteral()` will be deprecated in v18 */ parseLiteral?: GraphQLScalarLiteralParser | undefined; + /** Parses an externally provided const literal value to use as an input. */ + parseConstLiteral?: GraphQLScalarConstLiteralParser | undefined; + /** Translates an externally provided value to a literal (AST). */ + valueToLiteral?: GraphQLScalarValueToLiteral | undefined; extensions?: Maybe>; astNode?: Maybe; extensionASTNodes?: Maybe>; @@ -643,6 +708,7 @@ interface GraphQLScalarTypeNormalizedConfig serialize: GraphQLScalarSerializer; parseValue: GraphQLScalarValueParser; parseLiteral: GraphQLScalarLiteralParser; + parseConstLiteral: GraphQLScalarConstLiteralParser; extensions: Readonly; extensionASTNodes: ReadonlyArray; } @@ -1377,11 +1443,16 @@ export class GraphQLEnumType /* */ { return enumValue.value; } + /** @deprecated use `parseConstLiteral()` instead, `parseLiteral()` will be deprecated in v18 */ parseLiteral( valueNode: ValueNode, _variables: Maybe>, ): Maybe /* T */ { // Note: variables will be resolved to a value before calling this function. + return this.parseConstLiteral(valueNode as ConstValueNode); + } + + parseConstLiteral(valueNode: ConstValueNode): Maybe /* T */ { if (valueNode.kind !== Kind.ENUM) { const valueStr = print(valueNode); throw new GraphQLError( @@ -1403,6 +1474,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..a9003f7d54 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'; @@ -66,7 +68,7 @@ export const GraphQLInt = new GraphQLScalarType({ return inputValue; }, - parseLiteral(valueNode) { + parseConstLiteral(valueNode) { if (valueNode.kind !== Kind.INT) { throw new GraphQLError( `Int cannot represent non-integer value: ${print(valueNode)}`, @@ -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({ @@ -118,7 +130,7 @@ export const GraphQLFloat = new GraphQLScalarType({ return inputValue; }, - parseLiteral(valueNode) { + parseConstLiteral(valueNode) { if (valueNode.kind !== Kind.FLOAT && valueNode.kind !== Kind.INT) { throw new GraphQLError( `Float cannot represent non numeric value: ${print(valueNode)}`, @@ -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({ @@ -162,7 +180,7 @@ export const GraphQLString = new GraphQLScalarType({ return inputValue; }, - parseLiteral(valueNode) { + parseConstLiteral(valueNode) { if (valueNode.kind !== Kind.STRING) { throw new GraphQLError( `String cannot represent a non string value: ${print(valueNode)}`, @@ -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({ @@ -200,7 +224,7 @@ export const GraphQLBoolean = new GraphQLScalarType({ return inputValue; }, - parseLiteral(valueNode) { + parseConstLiteral(valueNode) { if (valueNode.kind !== Kind.BOOLEAN) { throw new GraphQLError( `Boolean cannot represent a non boolean value: ${print(valueNode)}`, @@ -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({ @@ -240,7 +270,7 @@ export const GraphQLID = new GraphQLScalarType({ throw new GraphQLError(`ID cannot represent value: ${inspect(inputValue)}`); }, - parseLiteral(valueNode) { + parseConstLiteral(valueNode) { if (valueNode.kind !== Kind.STRING && valueNode.kind !== Kind.INT) { throw new GraphQLError( 'ID cannot represent a non-string and non-integer value: ' + @@ -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..c6d10c3b9a 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -610,10 +610,10 @@ describe('coerceInputLiteral', () => { test('123.456', GraphQLID, undefined); }); - it('convert using parseLiteral from a custom scalar type', () => { + it('convert using parseConstLiteral from a custom scalar type', () => { const passthroughScalar = new GraphQLScalarType({ name: 'PassthroughScalar', - parseLiteral(node) { + parseConstLiteral(node) { invariant(node.kind === 'StringValue'); return node.value; }, @@ -624,17 +624,24 @@ describe('coerceInputLiteral', () => { const printScalar = new GraphQLScalarType({ name: 'PrintScalar', - parseLiteral(node) { + parseConstLiteral(node) { return `~~~${print(node)}~~~`; }, parseValue: identityFunc, }); test('"value"', printScalar, '~~~"value"~~~'); + testWithVariables( + '($var: String)', + { var: 'value' }, + '{ field: $var }', + printScalar, + '~~~{ field: "value" }~~~', + ); const throwScalar = new GraphQLScalarType({ name: 'ThrowScalar', - parseLiteral() { + parseConstLiteral() { throw new Error('Test'); }, parseValue: identityFunc, @@ -644,7 +651,7 @@ describe('coerceInputLiteral', () => { const returnUndefinedScalar = new GraphQLScalarType({ name: 'ReturnUndefinedScalar', - parseLiteral() { + parseConstLiteral() { return undefined; }, parseValue: identityFunc, 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..757121b3ae 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.parseConstLiteral(constValueNode); } catch (_error) { // Invalid: ignore error and intentionally return no value. } diff --git a/src/utilities/index.ts b/src/utilities/index.ts index dc678adf95..12dba542dc 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -71,6 +71,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..d2b566823d 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; } - // Scalars and Enums determine if a literal value is valid via parseLiteral(), - // which may throw or return an invalid value to indicate failure. + const constValueNode = replaceVariables(node); + + // Scalars and Enums determine if a literal value is valid via parseConstLiteral(), + // which may throw or return undefined to indicate an invalid value. try { - const parseResult = type.parseLiteral(node, undefined /* variables */); + const parseResult = type.parseConstLiteral(constValueNode); if (parseResult === undefined) { const typeStr = inspect(locationType); context.reportError(