From b80ff6de9deacb11794f84043a3b230953a661ef Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 16 Sep 2024 12:41:02 +0300 Subject: [PATCH] bring back valueFromAST but deprecate it --- src/index.ts | 3 + src/utilities/__tests__/valueFromAST-test.ts | 289 +++++++++++++++++++ src/utilities/index.ts | 6 + src/utilities/valueFromAST.ts | 175 +++++++++++ 4 files changed, 473 insertions(+) create mode 100644 src/utilities/__tests__/valueFromAST-test.ts create mode 100644 src/utilities/valueFromAST.ts diff --git a/src/index.ts b/src/index.ts index 310f47c64f..cffd892db5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -439,6 +439,9 @@ export { printIntrospectionSchema, // Create a GraphQLType from a GraphQL language AST. typeFromAST, + // Create a JavaScript value from a GraphQL language AST with a Type. + /** @deprecated use `coerceInputLiteral()` instead - will be removed in v18 */ + valueFromAST, // Create a JavaScript value from a GraphQL language AST without a Type. valueFromASTUntyped, // Create a GraphQL language AST from a JavaScript value. diff --git a/src/utilities/__tests__/valueFromAST-test.ts b/src/utilities/__tests__/valueFromAST-test.ts new file mode 100644 index 0000000000..2bed756925 --- /dev/null +++ b/src/utilities/__tests__/valueFromAST-test.ts @@ -0,0 +1,289 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { identityFunc } from '../../jsutils/identityFunc.js'; +import type { ObjMap } from '../../jsutils/ObjMap.js'; + +import { parseValue } 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 { valueFromAST } from '../valueFromAST.js'; + +/** @deprecated use `coerceInputLiteral()` instead - will be removed in v18 */ +describe('valueFromAST', () => { + function expectValueFrom( + valueText: string, + type: GraphQLInputType, + variables?: ObjMap, + ) { + const ast = parseValue(valueText); + const value = valueFromAST(ast, type, variables); + return expect(value); + } + + it('rejects empty input', () => { + expect(valueFromAST(null, GraphQLBoolean)).to.deep.equal(undefined); + }); + + it('converts according to input coercion rules', () => { + expectValueFrom('true', GraphQLBoolean).to.equal(true); + expectValueFrom('false', GraphQLBoolean).to.equal(false); + expectValueFrom('123', GraphQLInt).to.equal(123); + expectValueFrom('123', GraphQLFloat).to.equal(123); + expectValueFrom('123.456', GraphQLFloat).to.equal(123.456); + expectValueFrom('"abc123"', GraphQLString).to.equal('abc123'); + expectValueFrom('123456', GraphQLID).to.equal('123456'); + expectValueFrom('"123456"', GraphQLID).to.equal('123456'); + }); + + it('does not convert when input coercion rules reject a value', () => { + expectValueFrom('123', GraphQLBoolean).to.equal(undefined); + expectValueFrom('123.456', GraphQLInt).to.equal(undefined); + expectValueFrom('true', GraphQLInt).to.equal(undefined); + expectValueFrom('"123"', GraphQLInt).to.equal(undefined); + expectValueFrom('"123"', GraphQLFloat).to.equal(undefined); + expectValueFrom('123', GraphQLString).to.equal(undefined); + expectValueFrom('true', GraphQLString).to.equal(undefined); + expectValueFrom('123.456', GraphQLString).to.equal(undefined); + }); + + it('convert using parseLiteral from a custom scalar type', () => { + const passthroughScalar = new GraphQLScalarType({ + name: 'PassthroughScalar', + parseLiteral(node) { + assert(node.kind === 'StringValue'); + return node.value; + }, + parseValue: identityFunc, + }); + + expectValueFrom('"value"', passthroughScalar).to.equal('value'); + + const throwScalar = new GraphQLScalarType({ + name: 'ThrowScalar', + parseLiteral() { + throw new Error('Test'); + }, + parseValue: identityFunc, + }); + + expectValueFrom('value', throwScalar).to.equal(undefined); + + const returnUndefinedScalar = new GraphQLScalarType({ + name: 'ReturnUndefinedScalar', + parseLiteral() { + return undefined; + }, + parseValue: identityFunc, + }); + + expectValueFrom('value', returnUndefinedScalar).to.equal(undefined); + }); + + it('converts enum values according to input coercion rules', () => { + const testEnum = new GraphQLEnumType({ + name: 'TestColor', + values: { + RED: { value: 1 }, + GREEN: { value: 2 }, + BLUE: { value: 3 }, + NULL: { value: null }, + NAN: { value: NaN }, + NO_CUSTOM_VALUE: { value: undefined }, + }, + }); + + expectValueFrom('RED', testEnum).to.equal(1); + expectValueFrom('BLUE', testEnum).to.equal(3); + expectValueFrom('3', testEnum).to.equal(undefined); + expectValueFrom('"BLUE"', testEnum).to.equal(undefined); + expectValueFrom('null', testEnum).to.equal(null); + expectValueFrom('NULL', testEnum).to.equal(null); + expectValueFrom('NULL', new GraphQLNonNull(testEnum)).to.equal(null); + expectValueFrom('NAN', testEnum).to.deep.equal(NaN); + expectValueFrom('NO_CUSTOM_VALUE', testEnum).to.equal('NO_CUSTOM_VALUE'); + }); + + // Boolean! + const nonNullBool = new GraphQLNonNull(GraphQLBoolean); + // [Boolean] + const listOfBool = new GraphQLList(GraphQLBoolean); + // [Boolean!] + const listOfNonNullBool = new GraphQLList(nonNullBool); + // [Boolean]! + const nonNullListOfBool = new GraphQLNonNull(listOfBool); + // [Boolean!]! + const nonNullListOfNonNullBool = new GraphQLNonNull(listOfNonNullBool); + + it('coerces to null unless non-null', () => { + expectValueFrom('null', GraphQLBoolean).to.equal(null); + expectValueFrom('null', nonNullBool).to.equal(undefined); + }); + + it('coerces lists of values', () => { + expectValueFrom('true', listOfBool).to.deep.equal([true]); + expectValueFrom('123', listOfBool).to.equal(undefined); + expectValueFrom('null', listOfBool).to.equal(null); + expectValueFrom('[true, false]', listOfBool).to.deep.equal([true, false]); + expectValueFrom('[true, 123]', listOfBool).to.equal(undefined); + expectValueFrom('[true, null]', listOfBool).to.deep.equal([true, null]); + expectValueFrom('{ true: true }', listOfBool).to.equal(undefined); + }); + + it('coerces non-null lists of values', () => { + expectValueFrom('true', nonNullListOfBool).to.deep.equal([true]); + expectValueFrom('123', nonNullListOfBool).to.equal(undefined); + expectValueFrom('null', nonNullListOfBool).to.equal(undefined); + expectValueFrom('[true, false]', nonNullListOfBool).to.deep.equal([ + true, + false, + ]); + expectValueFrom('[true, 123]', nonNullListOfBool).to.equal(undefined); + expectValueFrom('[true, null]', nonNullListOfBool).to.deep.equal([ + true, + null, + ]); + }); + + it('coerces lists of non-null values', () => { + expectValueFrom('true', listOfNonNullBool).to.deep.equal([true]); + expectValueFrom('123', listOfNonNullBool).to.equal(undefined); + expectValueFrom('null', listOfNonNullBool).to.equal(null); + expectValueFrom('[true, false]', listOfNonNullBool).to.deep.equal([ + true, + false, + ]); + expectValueFrom('[true, 123]', listOfNonNullBool).to.equal(undefined); + expectValueFrom('[true, null]', listOfNonNullBool).to.equal(undefined); + }); + + it('coerces non-null lists of non-null values', () => { + expectValueFrom('true', nonNullListOfNonNullBool).to.deep.equal([true]); + expectValueFrom('123', nonNullListOfNonNullBool).to.equal(undefined); + expectValueFrom('null', nonNullListOfNonNullBool).to.equal(undefined); + expectValueFrom('[true, false]', nonNullListOfNonNullBool).to.deep.equal([ + true, + false, + ]); + expectValueFrom('[true, 123]', nonNullListOfNonNullBool).to.equal( + undefined, + ); + expectValueFrom('[true, null]', nonNullListOfNonNullBool).to.equal( + undefined, + ); + }); + + const testInputObj = new GraphQLInputObjectType({ + name: 'TestInput', + fields: { + int: { type: GraphQLInt, defaultValue: 42 }, + bool: { type: GraphQLBoolean }, + requiredBool: { type: nonNullBool }, + }, + }); + const testOneOfInputObj = new GraphQLInputObjectType({ + name: 'TestOneOfInput', + fields: { + a: { type: GraphQLString }, + b: { type: GraphQLString }, + }, + isOneOf: true, + }); + + it('coerces input objects according to input coercion rules', () => { + expectValueFrom('null', testInputObj).to.equal(null); + expectValueFrom('123', testInputObj).to.equal(undefined); + expectValueFrom('[]', testInputObj).to.equal(undefined); + expectValueFrom( + '{ int: 123, requiredBool: false }', + testInputObj, + ).to.deep.equal({ + int: 123, + requiredBool: false, + }); + expectValueFrom( + '{ bool: true, requiredBool: false }', + testInputObj, + ).to.deep.equal({ + int: 42, + bool: true, + requiredBool: false, + }); + expectValueFrom('{ int: true, requiredBool: true }', testInputObj).to.equal( + undefined, + ); + expectValueFrom('{ requiredBool: null }', testInputObj).to.equal(undefined); + expectValueFrom('{ bool: true }', testInputObj).to.equal(undefined); + expectValueFrom('{ a: "abc" }', testOneOfInputObj).to.deep.equal({ + a: 'abc', + }); + expectValueFrom('{ b: "def" }', testOneOfInputObj).to.deep.equal({ + b: 'def', + }); + expectValueFrom('{ a: "abc", b: null }', testOneOfInputObj).to.deep.equal( + undefined, + ); + expectValueFrom('{ a: null }', testOneOfInputObj).to.equal(undefined); + expectValueFrom('{ a: 1 }', testOneOfInputObj).to.equal(undefined); + expectValueFrom('{ a: "abc", b: "def" }', testOneOfInputObj).to.equal( + undefined, + ); + expectValueFrom('{}', testOneOfInputObj).to.equal(undefined); + expectValueFrom('{ c: "abc" }', testOneOfInputObj).to.equal(undefined); + }); + + it('accepts variable values assuming already coerced', () => { + expectValueFrom('$var', GraphQLBoolean, {}).to.equal(undefined); + expectValueFrom('$var', GraphQLBoolean, { var: true }).to.equal(true); + expectValueFrom('$var', GraphQLBoolean, { var: null }).to.equal(null); + expectValueFrom('$var', nonNullBool, { var: null }).to.equal(undefined); + }); + + it('asserts variables are provided as items in lists', () => { + expectValueFrom('[ $foo ]', listOfBool, {}).to.deep.equal([null]); + expectValueFrom('[ $foo ]', listOfNonNullBool, {}).to.equal(undefined); + expectValueFrom('[ $foo ]', listOfNonNullBool, { + foo: true, + }).to.deep.equal([true]); + // Note: variables are expected to have already been coerced, so we + // do not expect the singleton wrapping behavior for variables. + expectValueFrom('$foo', listOfNonNullBool, { foo: true }).to.equal(true); + expectValueFrom('$foo', listOfNonNullBool, { foo: [true] }).to.deep.equal([ + true, + ]); + }); + + it('omits input object fields for unprovided variables', () => { + expectValueFrom( + '{ int: $foo, bool: $foo, requiredBool: true }', + testInputObj, + {}, + ).to.deep.equal({ int: 42, requiredBool: true }); + + expectValueFrom('{ requiredBool: $foo }', testInputObj, {}).to.equal( + undefined, + ); + + expectValueFrom('{ requiredBool: $foo }', testInputObj, { + foo: true, + }).to.deep.equal({ + int: 42, + requiredBool: true, + }); + }); +}); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index a60371f454..dc678adf95 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -56,6 +56,12 @@ export { // Create a GraphQLType from a GraphQL language AST. export { typeFromAST } from './typeFromAST.js'; +// Create a JavaScript value from a GraphQL language AST with a type. +export { + /** @deprecated use `coerceInputLiteral()` instead - will be removed in v18 */ + valueFromAST, +} from './valueFromAST.js'; + // Create a JavaScript value from a GraphQL language AST without a type. export { valueFromASTUntyped } from './valueFromASTUntyped.js'; diff --git a/src/utilities/valueFromAST.ts b/src/utilities/valueFromAST.ts new file mode 100644 index 0000000000..add9153680 --- /dev/null +++ b/src/utilities/valueFromAST.ts @@ -0,0 +1,175 @@ +import { inspect } from '../jsutils/inspect.js'; +import { invariant } from '../jsutils/invariant.js'; +import type { Maybe } from '../jsutils/Maybe.js'; +import type { ObjMap } from '../jsutils/ObjMap.js'; + +import type { ValueNode } from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; + +import type { GraphQLInputType } from '../type/definition.js'; +import { + isInputObjectType, + isLeafType, + isListType, + isNonNullType, +} from '../type/definition.js'; + +/** + * Produces a JavaScript value given a GraphQL Value AST. + * + * A GraphQL type must be provided, which will be used to interpret different + * GraphQL Value literals. + * + * Returns `undefined` when the value could not be validly coerced according to + * the provided type. + * + * | GraphQL Value | JSON Value | + * | -------------------- | ------------- | + * | Input Object | Object | + * | List | Array | + * | Boolean | Boolean | + * | String | String | + * | Int / Float | Number | + * | Enum Value | Unknown | + * | NullValue | null | + * + * @deprecated use `coerceInputLiteral()` instead - will be removed in v18 + */ +export function valueFromAST( + valueNode: Maybe, + type: GraphQLInputType, + variables?: Maybe>, +): unknown { + if (!valueNode) { + // When there is no node, then there is also no value. + // Importantly, this is different from returning the value null. + return; + } + + if (valueNode.kind === Kind.VARIABLE) { + const variableName = valueNode.name.value; + const variableValue = variables?.[variableName]; + if (variableValue === undefined) { + // No valid return value. + return; + } + if (variableValue === null && isNonNullType(type)) { + return; // Invalid: intentionally return no value. + } + // Note: This does no further checking that this variable is correct. + // This assumes that this query has been validated and the variable + // usage here is of the correct type. + return variableValue; + } + + if (isNonNullType(type)) { + if (valueNode.kind === Kind.NULL) { + return; // Invalid: intentionally return no value. + } + return valueFromAST(valueNode, type.ofType, variables); + } + + if (valueNode.kind === Kind.NULL) { + // This is explicitly returning the value null. + return null; + } + + if (isListType(type)) { + const itemType = type.ofType; + if (valueNode.kind === Kind.LIST) { + const coercedValues = []; + for (const itemNode of valueNode.values) { + if (isMissingVariable(itemNode, variables)) { + // If an array contains a missing variable, it is either coerced to + // null or if the item type is non-null, it considered invalid. + if (isNonNullType(itemType)) { + return; // Invalid: intentionally return no value. + } + coercedValues.push(null); + } else { + const itemValue = valueFromAST(itemNode, itemType, variables); + if (itemValue === undefined) { + return; // Invalid: intentionally return no value. + } + coercedValues.push(itemValue); + } + } + return coercedValues; + } + const coercedValue = valueFromAST(valueNode, itemType, variables); + if (coercedValue === undefined) { + return; // Invalid: intentionally return no value. + } + return [coercedValue]; + } + + if (isInputObjectType(type)) { + if (valueNode.kind !== Kind.OBJECT) { + return; // Invalid: intentionally return no value. + } + const coercedObj = Object.create(null); + const fieldNodes = new Map( + valueNode.fields.map((field) => [field.name.value, field]), + ); + for (const field of Object.values(type.getFields())) { + const fieldNode = fieldNodes.get(field.name); + if (fieldNode == null || isMissingVariable(fieldNode.value, variables)) { + if (field.defaultValue !== undefined) { + coercedObj[field.name] = field.defaultValue; + } else if (isNonNullType(field.type)) { + return; // Invalid: intentionally return no value. + } + continue; + } + const fieldValue = valueFromAST(fieldNode.value, field.type, variables); + if (fieldValue === undefined) { + return; // Invalid: intentionally return no value. + } + coercedObj[field.name] = fieldValue; + } + + if (type.isOneOf) { + const keys = Object.keys(coercedObj); + if (keys.length !== 1) { + return; // Invalid: not exactly one key, intentionally return no value. + } + + if (coercedObj[keys[0]] === null) { + return; // Invalid: value not non-null, intentionally return no value. + } + } + + return coercedObj; + } + + if (isLeafType(type)) { + // Scalars and Enums fulfill parsing a literal value via parseLiteral(). + // Invalid values represent a failure to parse correctly, in which case + // no value is returned. + let result; + try { + result = type.parseLiteral(valueNode, variables); + } catch (_error) { + return; // Invalid: intentionally return no value. + } + if (result === undefined) { + return; // Invalid: intentionally return no value. + } + return result; + } + /* c8 ignore next 3 */ + // Not reachable, all possible input types have been considered. + invariant(false, 'Unexpected input type: ' + inspect(type)); +} + +// Returns true if the provided valueNode is a variable which is not defined +// in the set of variables. +function isMissingVariable( + valueNode: ValueNode, + variables: Maybe>, +): boolean { + return ( + valueNode.kind === Kind.VARIABLE && + (variables == null || variables[valueNode.name.value] === undefined) + ); +}