From 495f5b593a3d99f42b5df40613138078f69d3e82 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 8 May 2024 17:13:22 +0300 Subject: [PATCH 1/3] astFromValue fails with a custom scalar serializing to an object value --- src/utilities/__tests__/astFromValue-test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/utilities/__tests__/astFromValue-test.ts b/src/utilities/__tests__/astFromValue-test.ts index 0f9d474256..195dfc153f 100644 --- a/src/utilities/__tests__/astFromValue-test.ts +++ b/src/utilities/__tests__/astFromValue-test.ts @@ -233,6 +233,24 @@ describe('astFromValue', () => { expect(() => astFromValue('value', returnCustomClassScalar)).to.throw( 'Cannot convert value to AST: {}.', ); + + const returnObjectScalar = new GraphQLScalarType({ + name: 'ReturnObjectScalar', + serialize() { + return { some: 'data' }; + }, + }); + + expect(astFromValue('value', returnObjectScalar)).to.deep.equal({ + kind: 'ObjectValue', + fields: [ + { + kind: 'ObjectField', + name: { kind: 'Name', value: 'some' }, + value: { kind: 'StringValue', value: 'data' }, + }, + ], + }); }); it('does not converts NonNull values to NullValue', () => { From 8ae6e7cfccf270ba7c7f9ae07e0786e41e885c73 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 8 May 2024 17:32:39 +0300 Subject: [PATCH 2/3] Fix astFromValue to handle custom scalars with object values --- src/utilities/__tests__/astFromValue-test.ts | 7 +- src/utilities/astFromValue.ts | 22 +---- src/utilities/astFromValueUntyped.ts | 88 ++++++++++++++++++++ 3 files changed, 95 insertions(+), 22 deletions(-) create mode 100644 src/utilities/astFromValueUntyped.ts diff --git a/src/utilities/__tests__/astFromValue-test.ts b/src/utilities/__tests__/astFromValue-test.ts index 195dfc153f..642f85fcc3 100644 --- a/src/utilities/__tests__/astFromValue-test.ts +++ b/src/utilities/__tests__/astFromValue-test.ts @@ -230,9 +230,10 @@ describe('astFromValue', () => { }, }); - expect(() => astFromValue('value', returnCustomClassScalar)).to.throw( - 'Cannot convert value to AST: {}.', - ); + expect(astFromValue('value', returnCustomClassScalar)).to.deep.equal({ + kind: 'ObjectValue', + fields: [], + }); const returnObjectScalar = new GraphQLScalarType({ name: 'ReturnObjectScalar', diff --git a/src/utilities/astFromValue.ts b/src/utilities/astFromValue.ts index bb03baf232..d72896c33f 100644 --- a/src/utilities/astFromValue.ts +++ b/src/utilities/astFromValue.ts @@ -17,6 +17,8 @@ import { } from '../type/definition.js'; import { GraphQLID } from '../type/scalars.js'; +import { astFromValueUntyped, integerStringRegExp } from './astFromValueUntyped.js'; + /** * Produces a GraphQL Value AST given a JavaScript object. * Function will match JavaScript/JSON values to GraphQL AST schema format @@ -105,18 +107,6 @@ export function astFromValue( return null; } - // Others serialize based on their corresponding JavaScript scalar types. - if (typeof serialized === 'boolean') { - return { kind: Kind.BOOLEAN, value: serialized }; - } - - // JavaScript numbers can be Int or Float values. - if (typeof serialized === 'number' && Number.isFinite(serialized)) { - const stringNum = String(serialized); - return integerStringRegExp.test(stringNum) - ? { kind: Kind.INT, value: stringNum } - : { kind: Kind.FLOAT, value: stringNum }; - } if (typeof serialized === 'string') { // Enum types use Enum literals. @@ -135,16 +125,10 @@ export function astFromValue( }; } - throw new TypeError(`Cannot convert value to AST: ${inspect(serialized)}.`); + return astFromValueUntyped(serialized); } /* c8 ignore next 3 */ // Not reachable, all possible types have been considered. invariant(false, 'Unexpected input type: ' + inspect(type)); } -/** - * IntValue: - * - NegativeSign? 0 - * - NegativeSign? NonZeroDigit ( Digit+ )? - */ -const integerStringRegExp = /^-?(?:0|[1-9][0-9]*)$/; diff --git a/src/utilities/astFromValueUntyped.ts b/src/utilities/astFromValueUntyped.ts new file mode 100644 index 0000000000..94cfe4a614 --- /dev/null +++ b/src/utilities/astFromValueUntyped.ts @@ -0,0 +1,88 @@ +import { inspect } from '../jsutils/inspect.js'; +import type { Maybe } from '../jsutils/Maybe.js'; + +import type { ConstObjectFieldNode, ConstValueNode } from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; + +/** + * Produces a GraphQL Value AST given a JavaScript object. + * Function will match JavaScript/JSON values to GraphQL AST schema format + * by using the following mapping. + * + * | JSON Value | GraphQL Value | + * | ------------- | -------------------- | + * | Object | Input Object | + * | Array | List | + * | Boolean | Boolean | + * | String | String | + * | Number | Int / Float | + * | null | NullValue | + * + */ +export function astFromValueUntyped(value: any): Maybe { + // only explicit null, not undefined, NaN + if (value === null) { + return { kind: Kind.NULL }; + } + + // undefined + if (value === undefined) { + return null; + } + + // Convert JavaScript array to GraphQL list. If the GraphQLType is a list, but + // the value is not an array, convert the value using the list's item type. + if (Array.isArray(value)) { + const valuesNodes: Array = []; + for (const item of value) { + const itemNode = astFromValueUntyped(item); + if (itemNode != null) { + valuesNodes.push(itemNode); + } + } + return { kind: Kind.LIST, values: valuesNodes }; + } + + if (typeof value === 'object') { + const fieldNodes: Array = []; + for (const fieldName of Object.getOwnPropertyNames(value)) { + const fieldValue = value[fieldName]; + const ast = astFromValueUntyped(fieldValue); + if (ast) { + fieldNodes.push({ + kind: Kind.OBJECT_FIELD, + name: { kind: Kind.NAME, value: fieldName }, + value: ast, + }); + } + } + return { kind: Kind.OBJECT, fields: fieldNodes }; + } + + // Others serialize based on their corresponding JavaScript scalar types. + if (typeof value === 'boolean') { + return { kind: Kind.BOOLEAN, value }; + } + + // JavaScript numbers can be Int or Float values. + if (typeof value === 'number' && isFinite(value)) { + const stringNum = String(value); + return integerStringRegExp.test(stringNum) + ? { kind: Kind.INT, value: stringNum } + : { kind: Kind.FLOAT, value: stringNum }; + } + + if (typeof value === 'string') { + return { kind: Kind.STRING, value }; + } + + + throw new TypeError(`Cannot convert value to AST: ${inspect(value)}.`); +} + +/** + * IntValue: + * - NegativeSign? 0 + * - NegativeSign? NonZeroDigit ( Digit+ )? + */ +export const integerStringRegExp = /^-?(?:0|[1-9][0-9]*)$/; From fae69d467b0ff8f9607a6b76b3f41b11ee90200d Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 8 May 2024 17:41:50 +0300 Subject: [PATCH 3/3] Introduce astFromValueUntyped --- .../__tests__/astFromValueUntyped-test.ts | 178 ++++++++++++++++++ src/utilities/astFromValue.ts | 7 +- src/utilities/astFromValueUntyped.ts | 98 +++++----- src/utilities/index.ts | 5 +- 4 files changed, 235 insertions(+), 53 deletions(-) create mode 100644 src/utilities/__tests__/astFromValueUntyped-test.ts diff --git a/src/utilities/__tests__/astFromValueUntyped-test.ts b/src/utilities/__tests__/astFromValueUntyped-test.ts new file mode 100644 index 0000000000..3ab80d9ffa --- /dev/null +++ b/src/utilities/__tests__/astFromValueUntyped-test.ts @@ -0,0 +1,178 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { astFromValueUntyped } from '../astFromValueUntyped.js'; + +describe('astFromValue', () => { + it('converts boolean values to ASTs', () => { + expect(astFromValueUntyped(true)).to.deep.equal({ + kind: 'BooleanValue', + value: true, + }); + + expect(astFromValueUntyped(false)).to.deep.equal({ + kind: 'BooleanValue', + value: false, + }); + }); + + it('converts Int values to Int ASTs', () => { + expect(astFromValueUntyped(-1)).to.deep.equal({ + kind: 'IntValue', + value: '-1', + }); + + expect(astFromValueUntyped(123.0)).to.deep.equal({ + kind: 'IntValue', + value: '123', + }); + + expect(astFromValueUntyped(1e4)).to.deep.equal({ + kind: 'IntValue', + value: '10000', + }); + }); + + it('converts Float values to Int/Float ASTs', () => { + expect(astFromValueUntyped(-1)).to.deep.equal({ + kind: 'IntValue', + value: '-1', + }); + + expect(astFromValueUntyped(123.0)).to.deep.equal({ + kind: 'IntValue', + value: '123', + }); + + expect(astFromValueUntyped(123.5)).to.deep.equal({ + kind: 'FloatValue', + value: '123.5', + }); + + expect(astFromValueUntyped(1e4)).to.deep.equal({ + kind: 'IntValue', + value: '10000', + }); + + expect(astFromValueUntyped(1e40)).to.deep.equal({ + kind: 'FloatValue', + value: '1e+40', + }); + }); + + it('converts String values to String ASTs', () => { + expect(astFromValueUntyped('hello')).to.deep.equal({ + kind: 'StringValue', + value: 'hello', + }); + + expect(astFromValueUntyped('VALUE')).to.deep.equal({ + kind: 'StringValue', + value: 'VALUE', + }); + + expect(astFromValueUntyped('VA\nLUE')).to.deep.equal({ + kind: 'StringValue', + value: 'VA\nLUE', + }); + + expect(astFromValueUntyped(undefined)).to.deep.equal(null); + }); + + it('converts ID values to Int/String ASTs', () => { + expect(astFromValueUntyped('hello')).to.deep.equal({ + kind: 'StringValue', + value: 'hello', + }); + + expect(astFromValueUntyped('VALUE')).to.deep.equal({ + kind: 'StringValue', + value: 'VALUE', + }); + + // Note: EnumValues cannot contain non-identifier characters + expect(astFromValueUntyped('VA\nLUE')).to.deep.equal({ + kind: 'StringValue', + value: 'VA\nLUE', + }); + + // Note: IntValues are used when possible. + expect(astFromValueUntyped(-1)).to.deep.equal({ + kind: 'IntValue', + value: '-1', + }); + + expect(astFromValueUntyped(123)).to.deep.equal({ + kind: 'IntValue', + value: '123', + }); + + expect(astFromValueUntyped('01')).to.deep.equal({ + kind: 'StringValue', + value: '01', + }); + }); + + it('converts array values to List ASTs', () => { + expect(astFromValueUntyped(['FOO', 'BAR'])).to.deep.equal({ + kind: 'ListValue', + values: [ + { kind: 'StringValue', value: 'FOO' }, + { kind: 'StringValue', value: 'BAR' }, + ], + }); + + function* listGenerator() { + yield 1; + yield 2; + yield 3; + } + + expect(astFromValueUntyped(listGenerator())).to.deep.equal({ + kind: 'ListValue', + values: [ + { kind: 'IntValue', value: '1' }, + { kind: 'IntValue', value: '2' }, + { kind: 'IntValue', value: '3' }, + ], + }); + }); + + it('converts list singletons', () => { + expect(astFromValueUntyped('FOO')).to.deep.equal({ + kind: 'StringValue', + value: 'FOO', + }); + }); + + it('converts objects', () => { + expect(astFromValueUntyped({ foo: 3, bar: 'HELLO' })).to.deep.equal({ + kind: 'ObjectValue', + fields: [ + { + kind: 'ObjectField', + name: { kind: 'Name', value: 'foo' }, + value: { kind: 'IntValue', value: '3' }, + }, + { + kind: 'ObjectField', + name: { kind: 'Name', value: 'bar' }, + value: { kind: 'StringValue', value: 'HELLO' }, + }, + ], + }); + }); + + it('converts objects with explicit nulls', () => { + expect(astFromValueUntyped({ foo: null })).to.deep.equal({ + kind: 'ObjectValue', + fields: [ + { + kind: 'ObjectField', + name: { kind: 'Name', value: 'foo' }, + value: { kind: 'NullValue' }, + }, + ], + }); + }); +}); diff --git a/src/utilities/astFromValue.ts b/src/utilities/astFromValue.ts index d72896c33f..14cde1e15c 100644 --- a/src/utilities/astFromValue.ts +++ b/src/utilities/astFromValue.ts @@ -17,7 +17,10 @@ import { } from '../type/definition.js'; import { GraphQLID } from '../type/scalars.js'; -import { astFromValueUntyped, integerStringRegExp } from './astFromValueUntyped.js'; +import { + astFromValueUntyped, + integerStringRegExp, +} from './astFromValueUntyped.js'; /** * Produces a GraphQL Value AST given a JavaScript object. @@ -107,7 +110,6 @@ export function astFromValue( return null; } - if (typeof serialized === 'string') { // Enum types use Enum literals. if (isEnumType(type)) { @@ -131,4 +133,3 @@ export function astFromValue( // Not reachable, all possible types have been considered. invariant(false, 'Unexpected input type: ' + inspect(type)); } - diff --git a/src/utilities/astFromValueUntyped.ts b/src/utilities/astFromValueUntyped.ts index 94cfe4a614..90876a07ae 100644 --- a/src/utilities/astFromValueUntyped.ts +++ b/src/utilities/astFromValueUntyped.ts @@ -1,4 +1,5 @@ import { inspect } from '../jsutils/inspect.js'; +import { isIterableObject } from '../jsutils/isIterableObject.js'; import type { Maybe } from '../jsutils/Maybe.js'; import type { ConstObjectFieldNode, ConstValueNode } from '../language/ast.js'; @@ -20,64 +21,63 @@ import { Kind } from '../language/kinds.js'; * */ export function astFromValueUntyped(value: any): Maybe { - // only explicit null, not undefined, NaN - if (value === null) { - return { kind: Kind.NULL }; - } - - // undefined - if (value === undefined) { - return null; - } + // only explicit null, not undefined, NaN + if (value === null) { + return { kind: Kind.NULL }; + } - // Convert JavaScript array to GraphQL list. If the GraphQLType is a list, but - // the value is not an array, convert the value using the list's item type. - if (Array.isArray(value)) { - const valuesNodes: Array = []; - for (const item of value) { - const itemNode = astFromValueUntyped(item); - if (itemNode != null) { - valuesNodes.push(itemNode); - } - } - return { kind: Kind.LIST, values: valuesNodes }; - } + // undefined + if (value === undefined) { + return null; + } - if (typeof value === 'object') { - const fieldNodes: Array = []; - for (const fieldName of Object.getOwnPropertyNames(value)) { - const fieldValue = value[fieldName]; - const ast = astFromValueUntyped(fieldValue); - if (ast) { - fieldNodes.push({ - kind: Kind.OBJECT_FIELD, - name: { kind: Kind.NAME, value: fieldName }, - value: ast, - }); - } - } - return { kind: Kind.OBJECT, fields: fieldNodes }; + // Convert JavaScript array to GraphQL list. If the GraphQLType is a list, but + // the value is not an array, convert the value using the list's item type. + if (isIterableObject(value)) { + const valuesNodes: Array = []; + for (const item of value) { + const itemNode = astFromValueUntyped(item); + if (itemNode != null) { + valuesNodes.push(itemNode); + } } + return { kind: Kind.LIST, values: valuesNodes }; + } - // Others serialize based on their corresponding JavaScript scalar types. - if (typeof value === 'boolean') { - return { kind: Kind.BOOLEAN, value }; + if (typeof value === 'object') { + const fieldNodes: Array = []; + for (const fieldName of Object.getOwnPropertyNames(value)) { + const fieldValue = value[fieldName]; + const ast = astFromValueUntyped(fieldValue); + if (ast) { + fieldNodes.push({ + kind: Kind.OBJECT_FIELD, + name: { kind: Kind.NAME, value: fieldName }, + value: ast, + }); + } } + return { kind: Kind.OBJECT, fields: fieldNodes }; + } - // JavaScript numbers can be Int or Float values. - if (typeof value === 'number' && isFinite(value)) { - const stringNum = String(value); - return integerStringRegExp.test(stringNum) - ? { kind: Kind.INT, value: stringNum } - : { kind: Kind.FLOAT, value: stringNum }; - } + // Others serialize based on their corresponding JavaScript scalar types. + if (typeof value === 'boolean') { + return { kind: Kind.BOOLEAN, value }; + } - if (typeof value === 'string') { - return { kind: Kind.STRING, value }; - } + // JavaScript numbers can be Int or Float values. + if (typeof value === 'number' && isFinite(value)) { + const stringNum = String(value); + return integerStringRegExp.test(stringNum) + ? { kind: Kind.INT, value: stringNum } + : { kind: Kind.FLOAT, value: stringNum }; + } + if (typeof value === 'string') { + return { kind: Kind.STRING, value }; + } - throw new TypeError(`Cannot convert value to AST: ${inspect(value)}.`); + throw new TypeError(`Cannot convert value to AST: ${inspect(value)}.`); } /** diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 6968dca4d3..d821961786 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -62,9 +62,12 @@ export { valueFromAST } from './valueFromAST.js'; // Create a JavaScript value from a GraphQL language AST without a type. export { valueFromASTUntyped } from './valueFromASTUntyped.js'; -// Create a GraphQL language AST from a JavaScript value. +// Create a GraphQL language AST from a JavaScript value with a type. export { astFromValue } from './astFromValue.js'; +// Create a GraphQL language AST from a JavaScript value without a type. +export { astFromValueUntyped } from './astFromValueUntyped.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';