Skip to content

Commit

Permalink
Add valueToLiteral()
Browse files Browse the repository at this point in the history
* Adds `valueToLiteral()` which takes an external value and translates it to a literal, allowing for custom scalars to define this behavior.

This also adds important changes to Input Coercion, especially for custom scalars:

* The value provided to `parseLiteral` is now `ConstValueNode` and the second `variables` argument has been removed. For all built-in scalars this has no effect, but any custom scalars which use complex literals no longer need to do variable reconciliation manually (in fact most do not -- this has been an easy subtle bug to miss).

  This behavior is possible with the addition of `replaceASTVariables`
  • Loading branch information
leebyron committed Jun 1, 2021
1 parent cbf56f0 commit 30deb95
Show file tree
Hide file tree
Showing 13 changed files with 652 additions and 39 deletions.
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,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. */
Expand Down
20 changes: 14 additions & 6 deletions src/type/__tests__/definition-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, it } from 'mocha';
import { inspect } from '../../jsutils/inspect';
import { identityFunc } from '../../jsutils/identityFunc';

import { parseValue } from '../../language/parser';
import { parseConstValue } from '../../language/parser';

import type { GraphQLType, GraphQLNullableType } from '../definition';
import {
Expand Down Expand Up @@ -83,15 +83,12 @@ describe('Type System: Scalars', () => {
},
});

expect(scalar.parseLiteral(parseValue('null'))).to.equal(
expect(scalar.parseLiteral(parseConstValue('null'))).to.equal(
'parseValue: null',
);
expect(scalar.parseLiteral(parseValue('{ foo: "bar" }'))).to.equal(
expect(scalar.parseLiteral(parseConstValue('{ foo: "bar" }'))).to.equal(
'parseValue: { foo: "bar" }',
);
expect(
scalar.parseLiteral(parseValue('{ foo: { bar: $var } }'), { var: 'baz' }),
).to.equal('parseValue: { foo: { bar: "baz" } }');
});

it('rejects a Scalar type without name', () => {
Expand Down Expand Up @@ -139,6 +136,17 @@ describe('Type System: Scalars', () => {
);
});

it('rejects a Scalar type defining valueToLiteral with an incorrect type', () => {
expect(
() =>
new GraphQLScalarType({
name: 'SomeScalar',
// @ts-expect-error
valueToLiteral: {},
}),
).to.throw('SomeScalar must provide "valueToLiteral" as a function.');
});

it('rejects a Scalar type defining specifiedByURL with an incorrect type', () => {
expect(
() =>
Expand Down
27 changes: 6 additions & 21 deletions src/type/__tests__/scalars-test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';

import { parseValue as parseValueToAST } from '../../language/parser';
import { parseConstValue } from '../../language/parser';

import {
GraphQLID,
Expand Down Expand Up @@ -66,7 +66,7 @@ describe('Type System: Specified scalar types', () => {

it('parseLiteral', () => {
function parseLiteral(str: string) {
return GraphQLInt.parseLiteral(parseValueToAST(str), undefined);
return GraphQLInt.parseLiteral(parseConstValue(str));
}

expect(parseLiteral('1')).to.equal(1);
Expand Down Expand Up @@ -104,9 +104,6 @@ describe('Type System: Specified scalar types', () => {
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
'Int cannot represent non-integer value: ENUM_VALUE',
);
expect(() => parseLiteral('$var')).to.throw(
'Int cannot represent non-integer value: $var',
);
});

it('serialize', () => {
Expand Down Expand Up @@ -231,7 +228,7 @@ describe('Type System: Specified scalar types', () => {

it('parseLiteral', () => {
function parseLiteral(str: string) {
return GraphQLFloat.parseLiteral(parseValueToAST(str), undefined);
return GraphQLFloat.parseLiteral(parseConstValue(str));
}

expect(parseLiteral('1')).to.equal(1);
Expand Down Expand Up @@ -264,9 +261,6 @@ describe('Type System: Specified scalar types', () => {
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
'Float cannot represent non numeric value: ENUM_VALUE',
);
expect(() => parseLiteral('$var')).to.throw(
'Float cannot represent non numeric value: $var',
);
});

it('serialize', () => {
Expand Down Expand Up @@ -344,7 +338,7 @@ describe('Type System: Specified scalar types', () => {

it('parseLiteral', () => {
function parseLiteral(str: string) {
return GraphQLString.parseLiteral(parseValueToAST(str), undefined);
return GraphQLString.parseLiteral(parseConstValue(str));
}

expect(parseLiteral('"foo"')).to.equal('foo');
Expand All @@ -371,9 +365,6 @@ describe('Type System: Specified scalar types', () => {
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
'String cannot represent a non string value: ENUM_VALUE',
);
expect(() => parseLiteral('$var')).to.throw(
'String cannot represent a non string value: $var',
);
});

it('serialize', () => {
Expand Down Expand Up @@ -456,7 +447,7 @@ describe('Type System: Specified scalar types', () => {

it('parseLiteral', () => {
function parseLiteral(str: string) {
return GraphQLBoolean.parseLiteral(parseValueToAST(str), undefined);
return GraphQLBoolean.parseLiteral(parseConstValue(str));
}

expect(parseLiteral('true')).to.equal(true);
Expand Down Expand Up @@ -489,9 +480,6 @@ describe('Type System: Specified scalar types', () => {
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
'Boolean cannot represent a non boolean value: ENUM_VALUE',
);
expect(() => parseLiteral('$var')).to.throw(
'Boolean cannot represent a non boolean value: $var',
);
});

it('serialize', () => {
Expand Down Expand Up @@ -571,7 +559,7 @@ describe('Type System: Specified scalar types', () => {

it('parseLiteral', () => {
function parseLiteral(str: string) {
return GraphQLID.parseLiteral(parseValueToAST(str), undefined);
return GraphQLID.parseLiteral(parseConstValue(str));
}

expect(parseLiteral('""')).to.equal('');
Expand Down Expand Up @@ -604,9 +592,6 @@ describe('Type System: Specified scalar types', () => {
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
'ID cannot represent a non-string and non-integer value: ENUM_VALUE',
);
expect(() => parseLiteral('$var')).to.throw(
'ID cannot represent a non-string and non-integer value: $var',
);
});

it('serialize', () => {
Expand Down
64 changes: 55 additions & 9 deletions src/type/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { Kind } from '../language/kinds';
import { print } from '../language/printer';
import type {
FieldNode,
ValueNode,
ConstValueNode,
OperationDefinitionNode,
FragmentDefinitionNode,
Expand Down Expand Up @@ -590,9 +589,39 @@ export interface GraphQLScalarTypeExtensions {
* if (value % 2 === 1) {
* return value;
* }
* },
* parseValue(value) {
* if (value % 2 === 1) {
* return value;
* }
* }
* valueToLiteral(value) {
* if (value % 2 === 1) {
* return parse(`${value}`);
* }
* }
* });
*
* Custom scalars behavior is defined via the following functions:
*
* - serialize(value): Implements "Result Coercion". Given an internal value,
* produces an external value valid for this type. Returns undefined or
* throws an error to indicate invalid values.
*
* - parseValue(value): Implements "Input Coercion" for values. Given an
* external value (for example, variable values), produces an internal value
* valid for this type. Returns undefined or throws an error to indicate
* invalid values.
*
* - parseLiteral(ast): Implements "Input Coercion" for literals. Given an
* GraphQL literal (AST) (for example, an argument value), produces an
* internal value valid for this type. Returns undefined or throws an error
* to indicate invalid values.
*
* - valueToLiteral(value): Converts an external value to a GraphQL
* literal (AST). Returns undefined or throws an error to indicate
* invalid values.
*
*/
export class GraphQLScalarType extends GraphQLSchemaElement {
name: string;
Expand All @@ -601,6 +630,7 @@ export class GraphQLScalarType extends GraphQLSchemaElement {
serialize: GraphQLScalarSerializer<unknown>;
parseValue: GraphQLScalarValueParser<unknown>;
parseLiteral: GraphQLScalarLiteralParser<unknown>;
valueToLiteral: Maybe<GraphQLScalarValueToLiteral>;
extensions: Maybe<Readonly<GraphQLScalarTypeExtensions>>;
astNode: Maybe<ScalarTypeDefinitionNode>;
extensionASTNodes: ReadonlyArray<ScalarTypeExtensionNode>;
Expand All @@ -614,8 +644,8 @@ export class GraphQLScalarType extends GraphQLSchemaElement {
this.serialize = config.serialize ?? identityFunc;
this.parseValue = parseValue;
this.parseLiteral =
config.parseLiteral ??
((node, variables) => parseValue(valueFromASTUntyped(node, variables)));
config.parseLiteral ?? ((node) => parseValue(valueFromASTUntyped(node)));
this.valueToLiteral = config.valueToLiteral;
this.extensions = config.extensions && toObjMap(config.extensions);
this.astNode = config.astNode;
this.extensionASTNodes = config.extensionASTNodes ?? [];
Expand All @@ -641,6 +671,13 @@ export class GraphQLScalarType extends GraphQLSchemaElement {
`${this.name} must provide both "parseValue" and "parseLiteral" functions.`,
);
}

if (config.valueToLiteral) {
devAssert(
typeof config.valueToLiteral === 'function',
`${this.name} must provide "valueToLiteral" as a function.`,
);
}
}

toConfig(): GraphQLScalarTypeNormalizedConfig {
Expand All @@ -651,6 +688,7 @@ export class GraphQLScalarType extends GraphQLSchemaElement {
serialize: this.serialize,
parseValue: this.parseValue,
parseLiteral: this.parseLiteral,
valueToLiteral: this.valueToLiteral,
extensions: this.extensions,
astNode: this.astNode,
extensionASTNodes: this.extensionASTNodes,
Expand All @@ -671,10 +709,13 @@ export type GraphQLScalarValueParser<TInternal> = (
) => Maybe<TInternal>;

export type GraphQLScalarLiteralParser<TInternal> = (
valueNode: ValueNode,
variables?: Maybe<ObjMap<unknown>>,
valueNode: ConstValueNode,
) => Maybe<TInternal>;

export type GraphQLScalarValueToLiteral = (
inputValue: unknown,
) => ConstValueNode | undefined;

export interface GraphQLScalarTypeConfig<TInternal, TExternal> {
name: string;
description?: Maybe<string>;
Expand All @@ -685,6 +726,8 @@ export interface GraphQLScalarTypeConfig<TInternal, TExternal> {
parseValue?: GraphQLScalarValueParser<TInternal>;
/** Parses an externally provided literal value to use as an input. */
parseLiteral?: GraphQLScalarLiteralParser<TInternal>;
/** Translates an externally provided value to a literal (AST). */
valueToLiteral?: Maybe<GraphQLScalarValueToLiteral>;
extensions?: Maybe<Readonly<GraphQLScalarTypeExtensions>>;
astNode?: Maybe<ScalarTypeDefinitionNode>;
extensionASTNodes?: Maybe<ReadonlyArray<ScalarTypeExtensionNode>>;
Expand Down Expand Up @@ -1457,10 +1500,7 @@ export class GraphQLEnumType /* <T> */ extends GraphQLSchemaElement {
return enumValue.value;
}

parseLiteral(
valueNode: ValueNode,
_variables: Maybe<ObjMap<unknown>>,
): Maybe<any> /* T */ {
parseLiteral(valueNode: ConstValueNode): Maybe<any> /* T */ {
// Note: variables will be resolved to a value before calling this function.
if (valueNode.kind !== Kind.ENUM) {
const valueStr = print(valueNode);
Expand All @@ -1483,6 +1523,12 @@ export class GraphQLEnumType /* <T> */ extends GraphQLSchemaElement {
return enumValue.value;
}

valueToLiteral(value: unknown): ConstValueNode | undefined {
if (typeof value === 'string' && this.getValue(value)) {
return { kind: Kind.ENUM, value };
}
}

toConfig(): GraphQLEnumTypeNormalizedConfig {
return {
name: this.name,
Expand Down
40 changes: 40 additions & 0 deletions src/type/scalars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { print } from '../language/printer';

import { GraphQLError } from '../error/GraphQLError';

import { defaultScalarValueToLiteral } from '../utilities/valueToLiteral';

import type { GraphQLNamedType } from './definition';
import { GraphQLScalarType } from './definition';

Expand Down Expand Up @@ -79,6 +81,16 @@ export const GraphQLInt: GraphQLScalarType = new GraphQLScalarType({
}
return num;
},
valueToLiteral(value) {
if (
typeof value === 'number' &&
Number.isInteger(value) &&
value <= MAX_INT &&
value >= MIN_INT
) {
return { kind: Kind.INT, value: String(value) };
}
},
});

function serializeFloat(outputValue: unknown): number {
Expand Down Expand Up @@ -125,6 +137,12 @@ export const GraphQLFloat: GraphQLScalarType = new GraphQLScalarType({
}
return parseFloat(valueNode.value);
},
valueToLiteral(value) {
const literal = defaultScalarValueToLiteral(value);
if (literal.kind === Kind.FLOAT || literal.kind === Kind.INT) {
return literal;
}
},
});

// Support serializing objects with custom valueOf() or toJSON() functions -
Expand Down Expand Up @@ -188,6 +206,12 @@ export const GraphQLString: GraphQLScalarType = new GraphQLScalarType({
}
return valueNode.value;
},
valueToLiteral(value) {
const literal = defaultScalarValueToLiteral(value);
if (literal.kind === Kind.STRING) {
return literal;
}
},
});

function serializeBoolean(outputValue: unknown): boolean {
Expand Down Expand Up @@ -227,6 +251,12 @@ export const GraphQLBoolean: GraphQLScalarType = new GraphQLScalarType({
}
return valueNode.value;
},
valueToLiteral(value) {
const literal = defaultScalarValueToLiteral(value);
if (literal.kind === Kind.BOOLEAN) {
return literal;
}
},
});

function serializeID(outputValue: unknown): string {
Expand Down Expand Up @@ -267,6 +297,16 @@ export const GraphQLID: GraphQLScalarType = 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<GraphQLScalarType> =
Expand Down
7 changes: 7 additions & 0 deletions src/utilities/__tests__/coerceInputValue-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,13 @@ describe('coerceInputLiteral', () => {
});

test('"value"', printScalar, '~~~"value"~~~');
testWithVariables(
'($var: String)',
{ var: 'value' },
'{ field: $var }',
printScalar,
'~~~{field: "value"}~~~',
);

const throwScalar = new GraphQLScalarType({
name: 'ThrowScalar',
Expand Down
Loading

0 comments on commit 30deb95

Please sign in to comment.