Skip to content

Commit d5fb50a

Browse files
committed
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: * 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`
1 parent 6fea731 commit d5fb50a

13 files changed

+652
-39
lines changed

src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,10 @@ export {
416416
/** A helper to use within recursive-descent visitors which need to be aware of the GraphQL type system. */
417417
TypeInfo,
418418
visitWithTypeInfo,
419+
/** Converts a value to a const value by replacing variables. */
420+
replaceVariables,
421+
/** Create a GraphQL literal (AST) from a JavaScript input value. */
422+
valueToLiteral,
419423
/** Coerces a JavaScript value to a GraphQL type, or produces errors. */
420424
coerceInputValue,
421425
/** Coerces a GraphQL literal (AST) to a GraphQL type, or returns undefined. */

src/type/__tests__/definition-test.ts

+14-6
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it } from 'mocha';
44
import { inspect } from '../../jsutils/inspect';
55
import { identityFunc } from '../../jsutils/identityFunc';
66

7-
import { parseValue } from '../../language/parser';
7+
import { parseConstValue } from '../../language/parser';
88

99
import type { GraphQLType, GraphQLNullableType } from '../definition';
1010
import {
@@ -83,15 +83,12 @@ describe('Type System: Scalars', () => {
8383
},
8484
});
8585

86-
expect(scalar.parseLiteral(parseValue('null'))).to.equal(
86+
expect(scalar.parseLiteral(parseConstValue('null'))).to.equal(
8787
'parseValue: null',
8888
);
89-
expect(scalar.parseLiteral(parseValue('{ foo: "bar" }'))).to.equal(
89+
expect(scalar.parseLiteral(parseConstValue('{ foo: "bar" }'))).to.equal(
9090
'parseValue: { foo: "bar" }',
9191
);
92-
expect(
93-
scalar.parseLiteral(parseValue('{ foo: { bar: $var } }'), { var: 'baz' }),
94-
).to.equal('parseValue: { foo: { bar: "baz" } }');
9592
});
9693

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

139+
it('rejects a Scalar type defining valueToLiteral with an incorrect type', () => {
140+
expect(
141+
() =>
142+
new GraphQLScalarType({
143+
name: 'SomeScalar',
144+
// @ts-expect-error
145+
valueToLiteral: {},
146+
}),
147+
).to.throw('SomeScalar must provide "valueToLiteral" as a function.');
148+
});
149+
142150
it('rejects a Scalar type defining specifiedByURL with an incorrect type', () => {
143151
expect(
144152
() =>

src/type/__tests__/scalars-test.ts

+6-21
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { expect } from 'chai';
22
import { describe, it } from 'mocha';
33

4-
import { parseValue as parseValueToAST } from '../../language/parser';
4+
import { parseConstValue } from '../../language/parser';
55

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

6767
it('parseLiteral', () => {
6868
function parseLiteral(str: string) {
69-
return GraphQLInt.parseLiteral(parseValueToAST(str), undefined);
69+
return GraphQLInt.parseLiteral(parseConstValue(str));
7070
}
7171

7272
expect(parseLiteral('1')).to.equal(1);
@@ -104,9 +104,6 @@ describe('Type System: Specified scalar types', () => {
104104
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
105105
'Int cannot represent non-integer value: ENUM_VALUE',
106106
);
107-
expect(() => parseLiteral('$var')).to.throw(
108-
'Int cannot represent non-integer value: $var',
109-
);
110107
});
111108

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

232229
it('parseLiteral', () => {
233230
function parseLiteral(str: string) {
234-
return GraphQLFloat.parseLiteral(parseValueToAST(str), undefined);
231+
return GraphQLFloat.parseLiteral(parseConstValue(str));
235232
}
236233

237234
expect(parseLiteral('1')).to.equal(1);
@@ -264,9 +261,6 @@ describe('Type System: Specified scalar types', () => {
264261
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
265262
'Float cannot represent non numeric value: ENUM_VALUE',
266263
);
267-
expect(() => parseLiteral('$var')).to.throw(
268-
'Float cannot represent non numeric value: $var',
269-
);
270264
});
271265

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

345339
it('parseLiteral', () => {
346340
function parseLiteral(str: string) {
347-
return GraphQLString.parseLiteral(parseValueToAST(str), undefined);
341+
return GraphQLString.parseLiteral(parseConstValue(str));
348342
}
349343

350344
expect(parseLiteral('"foo"')).to.equal('foo');
@@ -371,9 +365,6 @@ describe('Type System: Specified scalar types', () => {
371365
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
372366
'String cannot represent a non string value: ENUM_VALUE',
373367
);
374-
expect(() => parseLiteral('$var')).to.throw(
375-
'String cannot represent a non string value: $var',
376-
);
377368
});
378369

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

457448
it('parseLiteral', () => {
458449
function parseLiteral(str: string) {
459-
return GraphQLBoolean.parseLiteral(parseValueToAST(str), undefined);
450+
return GraphQLBoolean.parseLiteral(parseConstValue(str));
460451
}
461452

462453
expect(parseLiteral('true')).to.equal(true);
@@ -489,9 +480,6 @@ describe('Type System: Specified scalar types', () => {
489480
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
490481
'Boolean cannot represent a non boolean value: ENUM_VALUE',
491482
);
492-
expect(() => parseLiteral('$var')).to.throw(
493-
'Boolean cannot represent a non boolean value: $var',
494-
);
495483
});
496484

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

572560
it('parseLiteral', () => {
573561
function parseLiteral(str: string) {
574-
return GraphQLID.parseLiteral(parseValueToAST(str), undefined);
562+
return GraphQLID.parseLiteral(parseConstValue(str));
575563
}
576564

577565
expect(parseLiteral('""')).to.equal('');
@@ -604,9 +592,6 @@ describe('Type System: Specified scalar types', () => {
604592
expect(() => parseLiteral('ENUM_VALUE')).to.throw(
605593
'ID cannot represent a non-string and non-integer value: ENUM_VALUE',
606594
);
607-
expect(() => parseLiteral('$var')).to.throw(
608-
'ID cannot represent a non-string and non-integer value: $var',
609-
);
610595
});
611596

612597
it('serialize', () => {

src/type/definition.ts

+55-9
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { Kind } from '../language/kinds';
2020
import { print } from '../language/printer';
2121
import type {
2222
FieldNode,
23-
ValueNode,
2423
ConstValueNode,
2524
OperationDefinitionNode,
2625
FragmentDefinitionNode,
@@ -590,9 +589,39 @@ export interface GraphQLScalarTypeExtensions {
590589
* if (value % 2 === 1) {
591590
* return value;
592591
* }
592+
* },
593+
* parseValue(value) {
594+
* if (value % 2 === 1) {
595+
* return value;
596+
* }
597+
* }
598+
* valueToLiteral(value) {
599+
* if (value % 2 === 1) {
600+
* return parse(`${value}`);
601+
* }
593602
* }
594603
* });
595604
*
605+
* Custom scalars behavior is defined via the following functions:
606+
*
607+
* - serialize(value): Implements "Result Coercion". Given an internal value,
608+
* produces an external value valid for this type. Returns undefined or
609+
* throws an error to indicate invalid values.
610+
*
611+
* - parseValue(value): Implements "Input Coercion" for values. Given an
612+
* external value (for example, variable values), produces an internal value
613+
* valid for this type. Returns undefined or throws an error to indicate
614+
* invalid values.
615+
*
616+
* - parseLiteral(ast): Implements "Input Coercion" for literals. Given an
617+
* GraphQL literal (AST) (for example, an argument value), produces an
618+
* internal value valid for this type. Returns undefined or throws an error
619+
* to indicate invalid values.
620+
*
621+
* - valueToLiteral(value): Converts an external value to a GraphQL
622+
* literal (AST). Returns undefined or throws an error to indicate
623+
* invalid values.
624+
*
596625
*/
597626
export class GraphQLScalarType extends GraphQLSchemaElement {
598627
name: string;
@@ -601,6 +630,7 @@ export class GraphQLScalarType extends GraphQLSchemaElement {
601630
serialize: GraphQLScalarSerializer<unknown>;
602631
parseValue: GraphQLScalarValueParser<unknown>;
603632
parseLiteral: GraphQLScalarLiteralParser<unknown>;
633+
valueToLiteral: Maybe<GraphQLScalarValueToLiteral>;
604634
extensions: Maybe<Readonly<GraphQLScalarTypeExtensions>>;
605635
astNode: Maybe<ScalarTypeDefinitionNode>;
606636
extensionASTNodes: ReadonlyArray<ScalarTypeExtensionNode>;
@@ -614,8 +644,8 @@ export class GraphQLScalarType extends GraphQLSchemaElement {
614644
this.serialize = config.serialize ?? identityFunc;
615645
this.parseValue = parseValue;
616646
this.parseLiteral =
617-
config.parseLiteral ??
618-
((node, variables) => parseValue(valueFromASTUntyped(node, variables)));
647+
config.parseLiteral ?? ((node) => parseValue(valueFromASTUntyped(node)));
648+
this.valueToLiteral = config.valueToLiteral;
619649
this.extensions = config.extensions && toObjMap(config.extensions);
620650
this.astNode = config.astNode;
621651
this.extensionASTNodes = config.extensionASTNodes ?? [];
@@ -641,6 +671,13 @@ export class GraphQLScalarType extends GraphQLSchemaElement {
641671
`${this.name} must provide both "parseValue" and "parseLiteral" functions.`,
642672
);
643673
}
674+
675+
if (config.valueToLiteral) {
676+
devAssert(
677+
typeof config.valueToLiteral === 'function',
678+
`${this.name} must provide "valueToLiteral" as a function.`,
679+
);
680+
}
644681
}
645682

646683
toConfig(): GraphQLScalarTypeNormalizedConfig {
@@ -651,6 +688,7 @@ export class GraphQLScalarType extends GraphQLSchemaElement {
651688
serialize: this.serialize,
652689
parseValue: this.parseValue,
653690
parseLiteral: this.parseLiteral,
691+
valueToLiteral: this.valueToLiteral,
654692
extensions: this.extensions,
655693
astNode: this.astNode,
656694
extensionASTNodes: this.extensionASTNodes,
@@ -671,10 +709,13 @@ export type GraphQLScalarValueParser<TInternal> = (
671709
) => Maybe<TInternal>;
672710

673711
export type GraphQLScalarLiteralParser<TInternal> = (
674-
valueNode: ValueNode,
675-
variables?: Maybe<ObjMap<unknown>>,
712+
valueNode: ConstValueNode,
676713
) => Maybe<TInternal>;
677714

715+
export type GraphQLScalarValueToLiteral = (
716+
inputValue: unknown,
717+
) => ConstValueNode | undefined;
718+
678719
export interface GraphQLScalarTypeConfig<TInternal, TExternal> {
679720
name: string;
680721
description?: Maybe<string>;
@@ -685,6 +726,8 @@ export interface GraphQLScalarTypeConfig<TInternal, TExternal> {
685726
parseValue?: GraphQLScalarValueParser<TInternal>;
686727
/** Parses an externally provided literal value to use as an input. */
687728
parseLiteral?: GraphQLScalarLiteralParser<TInternal>;
729+
/** Translates an externally provided value to a literal (AST). */
730+
valueToLiteral?: Maybe<GraphQLScalarValueToLiteral>;
688731
extensions?: Maybe<Readonly<GraphQLScalarTypeExtensions>>;
689732
astNode?: Maybe<ScalarTypeDefinitionNode>;
690733
extensionASTNodes?: Maybe<ReadonlyArray<ScalarTypeExtensionNode>>;
@@ -1457,10 +1500,7 @@ export class GraphQLEnumType /* <T> */ extends GraphQLSchemaElement {
14571500
return enumValue.value;
14581501
}
14591502

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

1526+
valueToLiteral(value: unknown): ConstValueNode | undefined {
1527+
if (typeof value === 'string' && this.getValue(value)) {
1528+
return { kind: Kind.ENUM, value };
1529+
}
1530+
}
1531+
14861532
toConfig(): GraphQLEnumTypeNormalizedConfig {
14871533
return {
14881534
name: this.name,

src/type/scalars.ts

+40
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { print } from '../language/printer';
66

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

9+
import { defaultScalarValueToLiteral } from '../utilities/valueToLiteral';
10+
911
import type { GraphQLNamedType } from './definition';
1012
import { GraphQLScalarType } from './definition';
1113

@@ -79,6 +81,16 @@ export const GraphQLInt: GraphQLScalarType = new GraphQLScalarType({
7981
}
8082
return num;
8183
},
84+
valueToLiteral(value) {
85+
if (
86+
typeof value === 'number' &&
87+
Number.isInteger(value) &&
88+
value <= MAX_INT &&
89+
value >= MIN_INT
90+
) {
91+
return { kind: Kind.INT, value: String(value) };
92+
}
93+
},
8294
});
8395

8496
function serializeFloat(outputValue: unknown): number {
@@ -125,6 +137,12 @@ export const GraphQLFloat: GraphQLScalarType = new GraphQLScalarType({
125137
}
126138
return parseFloat(valueNode.value);
127139
},
140+
valueToLiteral(value) {
141+
const literal = defaultScalarValueToLiteral(value);
142+
if (literal.kind === Kind.FLOAT || literal.kind === Kind.INT) {
143+
return literal;
144+
}
145+
},
128146
});
129147

130148
// Support serializing objects with custom valueOf() or toJSON() functions -
@@ -188,6 +206,12 @@ export const GraphQLString: GraphQLScalarType = new GraphQLScalarType({
188206
}
189207
return valueNode.value;
190208
},
209+
valueToLiteral(value) {
210+
const literal = defaultScalarValueToLiteral(value);
211+
if (literal.kind === Kind.STRING) {
212+
return literal;
213+
}
214+
},
191215
});
192216

193217
function serializeBoolean(outputValue: unknown): boolean {
@@ -227,6 +251,12 @@ export const GraphQLBoolean: GraphQLScalarType = new GraphQLScalarType({
227251
}
228252
return valueNode.value;
229253
},
254+
valueToLiteral(value) {
255+
const literal = defaultScalarValueToLiteral(value);
256+
if (literal.kind === Kind.BOOLEAN) {
257+
return literal;
258+
}
259+
},
230260
});
231261

232262
function serializeID(outputValue: unknown): string {
@@ -267,6 +297,16 @@ export const GraphQLID: GraphQLScalarType = new GraphQLScalarType({
267297
}
268298
return valueNode.value;
269299
},
300+
valueToLiteral(value) {
301+
// ID types can use number values and Int literals.
302+
const stringValue = Number.isInteger(value) ? String(value) : value;
303+
if (typeof stringValue === 'string') {
304+
// Will parse as an IntValue.
305+
return /^-?(?:0|[1-9][0-9]*)$/.test(stringValue)
306+
? { kind: Kind.INT, value: stringValue }
307+
: { kind: Kind.STRING, value: stringValue, block: false };
308+
}
309+
},
270310
});
271311

272312
export const specifiedScalarTypes: ReadonlyArray<GraphQLScalarType> =

src/utilities/__tests__/coerceInputValue-test.ts

+7
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,13 @@ describe('coerceInputLiteral', () => {
523523
});
524524

525525
test('"value"', printScalar, '~~~"value"~~~');
526+
testWithVariables(
527+
'($var: String)',
528+
{ var: 'value' },
529+
'{ field: $var }',
530+
printScalar,
531+
'~~~{field: "value"}~~~',
532+
);
526533

527534
const throwScalar = new GraphQLScalarType({
528535
name: 'ThrowScalar',

0 commit comments

Comments
 (0)