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 `replaceVariables`
  • Loading branch information
leebyron authored and yaacovCR committed Sep 16, 2024
1 parent e13091d commit 1b56a83
Show file tree
Hide file tree
Showing 13 changed files with 783 additions and 50 deletions.
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,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
9 changes: 3 additions & 6 deletions src/type/__tests__/definition-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { identityFunc } from '../../jsutils/identityFunc.js';
import { inspect } from '../../jsutils/inspect.js';

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

import type { GraphQLNullableType, GraphQLType } from '../definition.js';
import {
Expand Down Expand Up @@ -82,15 +82,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 defining parseLiteral but not parseValue', () => {
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.js';
import { parseConstValue } from '../../language/parser.js';

import {
GraphQLBoolean,
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
79 changes: 59 additions & 20 deletions src/type/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import type {
ScalarTypeExtensionNode,
UnionTypeDefinitionNode,
UnionTypeExtensionNode,
ValueNode,
} from '../language/ast.js';
import { Kind } from '../language/kinds.js';
import { print } from '../language/printer.js';
Expand Down Expand Up @@ -526,22 +525,52 @@ export interface GraphQLScalarTypeExtensions {
* Example:
*
* ```ts
* function ensureOdd(value) {
* if (!Number.isFinite(value)) {
* throw new Error(
* `Scalar "Odd" cannot represent "${value}" since it is not a finite number.`,
* );
* }
*
* if (value % 2 === 0) {
* throw new Error(`Scalar "Odd" cannot represent "${value}" since it is even.`);
* }
* }
*
* const OddType = new GraphQLScalarType({
* name: 'Odd',
* serialize(value) {
* if (!Number.isFinite(value)) {
* throw new Error(
* `Scalar "Odd" cannot represent "${value}" since it is not a finite number.`,
* );
* }
*
* if (value % 2 === 0) {
* throw new Error(`Scalar "Odd" cannot represent "${value}" since it is even.`);
* }
* return value;
* return ensureOdd(value);
* },
* parseValue(value) {
* return ensureOdd(value);
* }
* valueToLiteral(value) {
* return parse(`${ensureOdd(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<TInternal = unknown, TExternal = TInternal> {
name: string;
Expand All @@ -550,6 +579,7 @@ export class GraphQLScalarType<TInternal = unknown, TExternal = TInternal> {
serialize: GraphQLScalarSerializer<TExternal>;
parseValue: GraphQLScalarValueParser<TInternal>;
parseLiteral: GraphQLScalarLiteralParser<TInternal>;
valueToLiteral: GraphQLScalarValueToLiteral | undefined;
extensions: Readonly<GraphQLScalarTypeExtensions>;
astNode: Maybe<ScalarTypeDefinitionNode>;
extensionASTNodes: ReadonlyArray<ScalarTypeExtensionNode>;
Expand All @@ -566,8 +596,8 @@ export class GraphQLScalarType<TInternal = unknown, TExternal = TInternal> {
config.serialize ?? (identityFunc as GraphQLScalarSerializer<TExternal>);
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 = toObjMap(config.extensions);
this.astNode = config.astNode;
this.extensionASTNodes = config.extensionASTNodes ?? [];
Expand All @@ -593,6 +623,7 @@ export class GraphQLScalarType<TInternal = unknown, TExternal = TInternal> {
serialize: this.serialize,
parseValue: this.parseValue,
parseLiteral: this.parseLiteral,
valueToLiteral: this.valueToLiteral,
extensions: this.extensions,
astNode: this.astNode,
extensionASTNodes: this.extensionASTNodes,
Expand All @@ -617,9 +648,12 @@ export type GraphQLScalarValueParser<TInternal> = (
) => TInternal;

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

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

export interface GraphQLScalarTypeConfig<TInternal, TExternal> {
name: string;
Expand All @@ -631,6 +665,8 @@ export interface GraphQLScalarTypeConfig<TInternal, TExternal> {
parseValue?: GraphQLScalarValueParser<TInternal> | undefined;
/** Parses an externally provided literal value to use as an input. */
parseLiteral?: GraphQLScalarLiteralParser<TInternal> | undefined;
/** Translates an externally provided value to a literal (AST). */
valueToLiteral?: GraphQLScalarValueToLiteral | undefined;
extensions?: Maybe<Readonly<GraphQLScalarTypeExtensions>>;
astNode?: Maybe<ScalarTypeDefinitionNode>;
extensionASTNodes?: Maybe<ReadonlyArray<ScalarTypeExtensionNode>>;
Expand Down Expand Up @@ -1374,10 +1410,7 @@ export class GraphQLEnumType /* <T> */ {
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 @@ -1400,6 +1433,12 @@ export class GraphQLEnumType /* <T> */ {
return enumValue.value;
}

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

toConfig(): GraphQLEnumTypeNormalizedConfig {
const values = keyValMap(
this.getValues(),
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 { GraphQLError } from '../error/GraphQLError.js';
import { Kind } from '../language/kinds.js';
import { print } from '../language/printer.js';

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

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

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

export const GraphQLFloat = new GraphQLScalarType<number>({
Expand Down Expand Up @@ -127,6 +139,12 @@ export const GraphQLFloat = new GraphQLScalarType<number>({
}
return parseFloat(valueNode.value);
},
valueToLiteral(value) {
const literal = defaultScalarValueToLiteral(value);
if (literal.kind === Kind.FLOAT || literal.kind === Kind.INT) {
return literal;
}
},
});

export const GraphQLString = new GraphQLScalarType<string>({
Expand Down Expand Up @@ -171,6 +189,12 @@ export const GraphQLString = new GraphQLScalarType<string>({
}
return valueNode.value;
},
valueToLiteral(value) {
const literal = defaultScalarValueToLiteral(value);
if (literal.kind === Kind.STRING) {
return literal;
}
},
});

export const GraphQLBoolean = new GraphQLScalarType<boolean>({
Expand Down Expand Up @@ -209,6 +233,12 @@ export const GraphQLBoolean = new GraphQLScalarType<boolean>({
}
return valueNode.value;
},
valueToLiteral(value) {
const literal = defaultScalarValueToLiteral(value);
if (literal.kind === Kind.BOOLEAN) {
return literal;
}
},
});

export const GraphQLID = new GraphQLScalarType<string>({
Expand Down Expand Up @@ -250,6 +280,16 @@ export const GraphQLID = new GraphQLScalarType<string>({
}
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 @@ -631,6 +631,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 1b56a83

Please sign in to comment.