diff --git a/src/utilities/__tests__/astFromValue-test.ts b/src/utilities/__tests__/astFromValue-test.ts index 0f9d474256..642f85fcc3 100644 --- a/src/utilities/__tests__/astFromValue-test.ts +++ b/src/utilities/__tests__/astFromValue-test.ts @@ -230,9 +230,28 @@ 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', + 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', () => { 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 bb03baf232..14cde1e15c 100644 --- a/src/utilities/astFromValue.ts +++ b/src/utilities/astFromValue.ts @@ -17,6 +17,11 @@ 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,19 +110,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. if (isEnumType(type)) { @@ -135,16 +127,9 @@ 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..90876a07ae --- /dev/null +++ b/src/utilities/astFromValueUntyped.ts @@ -0,0 +1,88 @@ +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'; +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 (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 }; + } + + 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]*)$/; 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';