Skip to content

Commit a0cf9b2

Browse files
leebyronIvanGoncharov
authored andcommitted
RFC: Define custom scalars in terms of built-in scalars.
This proposes an additive change which allows custom scalars to be defined in terms of the built-in scalars. The motivation is for client-side code generators to understand how to map between the GraphQL type system and a native type system. As an example, a `URL` custom type may be defined in terms of the built-in scalar `String`. It could define additional serialization and parsing logic, however client tools can know to treat `URL` values as `String`. Presently, we do this by defining these mappings manually on the client, which is difficult to scale, or by giving up and making no assumptions of how the custom types serialize. Another real use case of giving client tools this information is GraphiQL: this change will allow GraphiQL to show more useful errors when a literal of an incorrect kind is provided to a custom scalar. Currently GraphiQL simply accepts all values. To accomplish this, this proposes adding the following: * A new property when defining `GraphQLScalarType` (`ofType`) which asserts that only built-in scalar types are provided. * A second type coercion to guarantee to a client that the serialized values match the `ofType`. * Delegating the `parseLiteral` and `parseValue` functions to those in `ofType` (this enables downstream validation / GraphiQL features) * Exposing `ofType` in the introspection system, and consuming that introspection in `buildClientSchema`. * Adding optional syntax to the SDL, and consuming that in `buildASTSchema` and `extendSchema` as well as in `schemaPrinter`. * Adding a case to `findBreakingChanges` which looks for a scalar's ofType changing.
1 parent 3789a87 commit a0cf9b2

17 files changed

+122
-23
lines changed

src/language/__tests__/schema-kitchen-sink.graphql

+2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ scalar CustomScalar
7575

7676
scalar AnnotatedScalar @onScalar
7777

78+
scalar StringEncodedCustomScalar as String
79+
7880
extend scalar CustomScalar @onScalar
7981

8082
enum Site {

src/language/__tests__/schema-parser-test.js

+1
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ type Hello {
754754
kind: 'ScalarTypeDefinition',
755755
name: nameNode('Hello', { start: 7, end: 12 }),
756756
description: undefined,
757+
type: undefined,
757758
directives: [],
758759
loc: { start: 0, end: 12 },
759760
},

src/language/__tests__/schema-printer-test.js

+2
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ describe('Printer: SDL document', () => {
119119
120120
scalar AnnotatedScalar @onScalar
121121
122+
scalar StringEncodedCustomScalar as String
123+
122124
extend scalar CustomScalar @onScalar
123125
124126
enum Site {

src/language/ast.js

+1
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ export type ScalarTypeDefinitionNode = {
423423
+loc?: Location,
424424
+description?: StringValueNode,
425425
+name: NameNode,
426+
+type?: NamedTypeNode,
426427
+directives?: $ReadOnlyArray<DirectiveNode>,
427428
};
428429

src/language/parser.js

+24-2
Original file line numberDiff line numberDiff line change
@@ -864,18 +864,26 @@ function parseOperationTypeDefinition(
864864
}
865865

866866
/**
867-
* ScalarTypeDefinition : Description? scalar Name Directives[Const]?
867+
* ScalarTypeDefinition :
868+
* - Description? scalar Name ScalarOfType? Directives[Const]?
869+
*
870+
* ScalarOfType : as NamedType
868871
*/
869872
function parseScalarTypeDefinition(lexer: Lexer<*>): ScalarTypeDefinitionNode {
870873
const start = lexer.token;
871874
const description = parseDescription(lexer);
872875
expectKeyword(lexer, 'scalar');
873876
const name = parseName(lexer);
877+
let type;
878+
if (skipKeyword(lexer, 'as')) {
879+
type = parseNamedType(lexer);
880+
}
874881
const directives = parseDirectives(lexer, true);
875882
return {
876883
kind: Kind.SCALAR_TYPE_DEFINITION,
877884
description,
878885
name,
886+
type,
879887
directives,
880888
loc: loc(lexer, start),
881889
};
@@ -1512,10 +1520,24 @@ function expect(lexer: Lexer<*>, kind: TokenKindEnum): Token {
15121520
}
15131521

15141522
/**
1515-
* If the next token is a keyword with the given value, return that token after
1523+
* If the next token is a keyword with the given value, return true after
15161524
* advancing the lexer. Otherwise, do not change the parser state and return
15171525
* false.
15181526
*/
1527+
function skipKeyword(lexer: Lexer<*>, value: string): boolean {
1528+
const token = lexer.token;
1529+
const match = token.kind === TokenKind.NAME && token.value === value;
1530+
if (match) {
1531+
lexer.advance();
1532+
}
1533+
return match;
1534+
}
1535+
1536+
/**
1537+
* If the next token is a keyword with the given value, return that token after
1538+
* advancing the lexer. Otherwise, do not change the parser state and throw
1539+
* an error.
1540+
*/
15191541
function expectKeyword(lexer: Lexer<*>, value: string): Token {
15201542
const token = lexer.token;
15211543
if (token.kind === TokenKind.NAME && token.value === value) {

src/language/printer.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ const printDocASTReducer = {
113113

114114
OperationTypeDefinition: ({ operation, type }) => operation + ': ' + type,
115115

116-
ScalarTypeDefinition: addDescription(({ name, directives }) =>
117-
join(['scalar', name, join(directives, ' ')], ' '),
116+
ScalarTypeDefinition: addDescription(({ name, type, directives }) =>
117+
join(['scalar', name, wrap('as ', type), join(directives, ' ')], ' '),
118118
),
119119

120120
ObjectTypeDefinition: addDescription(

src/language/visitor.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export const QueryDocumentKeys = {
102102
SchemaDefinition: ['directives', 'operationTypes'],
103103
OperationTypeDefinition: ['type'],
104104

105-
ScalarTypeDefinition: ['description', 'name', 'directives'],
105+
ScalarTypeDefinition: ['description', 'name', 'type', 'directives'],
106106
ObjectTypeDefinition: [
107107
'description',
108108
'name',

src/type/__tests__/introspection-test.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -1355,7 +1355,9 @@ describe('Introspection', () => {
13551355
'An enum describing what kind of type a given `__Type` is.',
13561356
enumValues: [
13571357
{
1358-
description: 'Indicates this type is a scalar.',
1358+
description:
1359+
'Indicates this type is a scalar. ' +
1360+
'`ofType` may represent how this scalar is serialized.',
13591361
name: 'SCALAR',
13601362
},
13611363
{

src/type/definition.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -538,17 +538,26 @@ export class GraphQLScalarType {
538538
serialize: GraphQLScalarSerializer<*>;
539539
parseValue: GraphQLScalarValueParser<*>;
540540
parseLiteral: GraphQLScalarLiteralParser<*>;
541+
ofType: ?GraphQLScalarType;
541542
astNode: ?ScalarTypeDefinitionNode;
542543
extensionASTNodes: ?$ReadOnlyArray<ScalarTypeExtensionNode>;
543544

544545
constructor(config: GraphQLScalarTypeConfig<*, *>): void {
545546
this.name = config.name;
546547
this.description = config.description;
547548
this.serialize = config.serialize;
548-
this.parseValue = config.parseValue || (value => value);
549-
this.parseLiteral = config.parseLiteral || valueFromASTUntyped;
549+
this.ofType = config.ofType || null;
550+
this.parseValue =
551+
config.parseValue ||
552+
(this.ofType && this.ofType.parseValue) ||
553+
(value => value);
554+
this.parseLiteral =
555+
config.parseLiteral ||
556+
(this.ofType && this.ofType.parseLiteral) ||
557+
valueFromASTUntyped;
550558
this.astNode = config.astNode;
551559
this.extensionASTNodes = config.extensionASTNodes;
560+
552561
invariant(typeof config.name === 'string', 'Must provide name.');
553562
invariant(
554563
typeof config.serialize === 'function',
@@ -591,6 +600,7 @@ export type GraphQLScalarTypeConfig<TInternal, TExternal> = {|
591600
parseValue?: GraphQLScalarValueParser<TInternal>,
592601
// Parses an externally provided literal value to use as an input.
593602
parseLiteral?: GraphQLScalarLiteralParser<TInternal>,
603+
ofType?: ?GraphQLScalarType,
594604
astNode?: ?ScalarTypeDefinitionNode,
595605
extensionASTNodes?: ?$ReadOnlyArray<ScalarTypeExtensionNode>,
596606
|};

src/type/introspection.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,8 @@ export const __Type = new GraphQLObjectType({
192192
'The fundamental unit of any GraphQL Schema is the type. There are ' +
193193
'many kinds of types in GraphQL as represented by the `__TypeKind` enum.' +
194194
'\n\nDepending on the kind of a type, certain fields describe ' +
195-
'information about that type. Scalar types provide no information ' +
196-
'beyond a name and description, while Enum types provide their values. ' +
195+
'information about that type. Scalar types provide a name, description ' +
196+
'and how they serialize, while Enum types provide their possible values. ' +
197197
'Object and Interface types provide the fields they describe. Abstract ' +
198198
'types, Union and Interface, provide the Object types possible ' +
199199
'at runtime. List and NonNull types compose other types.',
@@ -399,7 +399,9 @@ export const __TypeKind = new GraphQLEnumType({
399399
values: {
400400
SCALAR: {
401401
value: TypeKind.SCALAR,
402-
description: 'Indicates this type is a scalar.',
402+
description:
403+
'Indicates this type is a scalar. ' +
404+
'`ofType` may represent how this scalar is serialized.',
403405
},
404406
OBJECT: {
405407
value: TypeKind.OBJECT,

src/type/validate.js

+21-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import {
11+
isScalarType,
1112
isObjectType,
1213
isInterfaceType,
1314
isUnionType,
@@ -19,6 +20,7 @@ import {
1920
isRequiredArgument,
2021
} from './definition';
2122
import type {
23+
GraphQLScalarType,
2224
GraphQLObjectType,
2325
GraphQLInterfaceType,
2426
GraphQLUnionType,
@@ -28,6 +30,7 @@ import type {
2830
import { isDirective } from './directives';
2931
import type { GraphQLDirective } from './directives';
3032
import { isIntrospectionType } from './introspection';
33+
import { isSpecifiedScalarType } from './scalars';
3134
import { isSchema } from './schema';
3235
import type { GraphQLSchema } from './schema';
3336
import inspect from '../jsutils/inspect';
@@ -244,7 +247,10 @@ function validateTypes(context: SchemaValidationContext): void {
244247
validateName(context, type);
245248
}
246249

247-
if (isObjectType(type)) {
250+
if (isScalarType(type)) {
251+
// Ensure Scalars can serialize as expected.
252+
validateScalarSerialization(context, type);
253+
} else if (isObjectType(type)) {
248254
// Ensure fields are valid
249255
validateFields(context, type);
250256

@@ -266,6 +272,20 @@ function validateTypes(context: SchemaValidationContext): void {
266272
}
267273
}
268274

275+
function validateScalarSerialization(
276+
context: SchemaValidationContext,
277+
scalarType: GraphQLScalarType,
278+
): void {
279+
if (scalarType.ofType && !isSpecifiedScalarType(scalarType.ofType)) {
280+
context.reportError(
281+
`Scalar type ${scalarType.name} may only be described in terms of a ` +
282+
`spec-defined scalar type. However ${String(scalarType.ofType)} is ` +
283+
'not a built-in scalar type.',
284+
scalarType.astNode && scalarType.astNode.type,
285+
);
286+
}
287+
}
288+
269289
function validateFields(
270290
context: SchemaValidationContext,
271291
type: GraphQLObjectType | GraphQLInterfaceType,

src/utilities/__tests__/schemaPrinter-test.js

+25-10
Original file line numberDiff line numberDiff line change
@@ -461,8 +461,17 @@ describe('Type System Printer', () => {
461461
});
462462

463463
it('Custom Scalar', () => {
464+
const EvenType = new GraphQLScalarType({
465+
name: 'Even',
466+
ofType: GraphQLInt,
467+
serialize(value) {
468+
return value % 2 === 1 ? value : null;
469+
},
470+
});
471+
464472
const OddType = new GraphQLScalarType({
465473
name: 'Odd',
474+
// No ofType in this test case.
466475
serialize(value) {
467476
return value % 2 === 1 ? value : null;
468477
},
@@ -471,16 +480,20 @@ describe('Type System Printer', () => {
471480
const Query = new GraphQLObjectType({
472481
name: 'Query',
473482
fields: {
483+
even: { type: EvenType },
474484
odd: { type: OddType },
475485
},
476486
});
477487

478488
const Schema = new GraphQLSchema({ query: Query });
479489
const output = printForTest(Schema);
480490
expect(output).to.equal(dedent`
491+
scalar Even as Int
492+
481493
scalar Odd
482494
483495
type Query {
496+
even: Even
484497
odd: Odd
485498
}
486499
`);
@@ -785,10 +798,10 @@ describe('Type System Printer', () => {
785798
types in GraphQL as represented by the \`__TypeKind\` enum.
786799
787800
Depending on the kind of a type, certain fields describe information about that
788-
type. Scalar types provide no information beyond a name and description, while
789-
Enum types provide their values. Object and Interface types provide the fields
790-
they describe. Abstract types, Union and Interface, provide the Object types
791-
possible at runtime. List and NonNull types compose other types.
801+
type. Scalar types provide a name, description and how they serialize, while
802+
Enum types provide their possible values. Object and Interface types provide the
803+
fields they describe. Abstract types, Union and Interface, provide the Object
804+
types possible at runtime. List and NonNull types compose other types.
792805
"""
793806
type __Type {
794807
kind: __TypeKind!
@@ -804,7 +817,9 @@ describe('Type System Printer', () => {
804817
805818
"""An enum describing what kind of type a given \`__Type\` is."""
806819
enum __TypeKind {
807-
"""Indicates this type is a scalar."""
820+
"""
821+
Indicates this type is a scalar. \`ofType\` may represent how this scalar is serialized.
822+
"""
808823
SCALAR
809824
810825
"""
@@ -1001,10 +1016,10 @@ describe('Type System Printer', () => {
10011016
# types in GraphQL as represented by the \`__TypeKind\` enum.
10021017
#
10031018
# Depending on the kind of a type, certain fields describe information about that
1004-
# type. Scalar types provide no information beyond a name and description, while
1005-
# Enum types provide their values. Object and Interface types provide the fields
1006-
# they describe. Abstract types, Union and Interface, provide the Object types
1007-
# possible at runtime. List and NonNull types compose other types.
1019+
# type. Scalar types provide a name, description and how they serialize, while
1020+
# Enum types provide their possible values. Object and Interface types provide the
1021+
# fields they describe. Abstract types, Union and Interface, provide the Object
1022+
# types possible at runtime. List and NonNull types compose other types.
10081023
type __Type {
10091024
kind: __TypeKind!
10101025
name: String
@@ -1019,7 +1034,7 @@ describe('Type System Printer', () => {
10191034
10201035
# An enum describing what kind of type a given \`__Type\` is.
10211036
enum __TypeKind {
1022-
# Indicates this type is a scalar.
1037+
# Indicates this type is a scalar. \`ofType\` may represent how this scalar is serialized.
10231038
SCALAR
10241039
10251040
# Indicates this type is an object. \`fields\` and \`interfaces\` are valid fields.

src/utilities/buildASTSchema.js

+4
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,10 @@ export class ASTDefinitionBuilder {
421421
return new GraphQLScalarType({
422422
name: def.name.value,
423423
description: getDescription(def, this._options),
424+
// Note: While this could make assertions to get the correctly typed
425+
// values below, that would throw immediately while type system
426+
// validation with validateSchema() will produce more actionable results.
427+
ofType: def.type && (this.buildType(def.type): any),
424428
astNode: def,
425429
serialize: value => value,
426430
});

src/utilities/buildClientSchema.js

+4
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,13 @@ export function buildClientSchema(
202202
function buildScalarDef(
203203
scalarIntrospection: IntrospectionScalarType,
204204
): GraphQLScalarType {
205+
const ofType = scalarIntrospection.ofType
206+
? (getType(scalarIntrospection.ofType): any)
207+
: undefined;
205208
return new GraphQLScalarType({
206209
name: scalarIntrospection.name,
207210
description: scalarIntrospection.description,
211+
ofType,
208212
serialize: value => value,
209213
});
210214
}

src/utilities/findBreakingChanges.js

+12
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,18 @@ export function findTypesThatChangedKind(
163163
`${typeName} changed from ` +
164164
`${typeKindName(oldType)} to ${typeKindName(newType)}.`,
165165
});
166+
} else if (isScalarType(oldType) && isScalarType(newType)) {
167+
const oldOfType = oldType.ofType;
168+
const newOfType = newType.ofType;
169+
if (oldOfType && newOfType && oldOfType !== newOfType) {
170+
breakingChanges.push({
171+
type: BreakingChangeType.TYPE_CHANGED_KIND,
172+
description:
173+
`${typeName} changed from ` +
174+
`${typeKindName(oldType)} serialized as ${oldOfType.name} ` +
175+
`to ${typeKindName(newType)} serialized as ${newOfType.name}.`,
176+
});
177+
}
166178
}
167179
}
168180
return breakingChanges;

src/utilities/introspectionQuery.js

+1
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export type IntrospectionScalarType = {
155155
+kind: 'SCALAR',
156156
+name: string,
157157
+description?: ?string,
158+
+ofType?: ?IntrospectionNamedTypeRef<IntrospectionScalarType>,
158159
};
159160

160161
export type IntrospectionObjectType = {

src/utilities/schemaPrinter.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ export function printType(type: GraphQLNamedType, options?: Options): string {
180180
}
181181

182182
function printScalar(type: GraphQLScalarType, options): string {
183-
return printDescription(options, type) + `scalar ${type.name}`;
183+
const ofType = type.ofType ? ` as ${type.ofType.name}` : '';
184+
return printDescription(options, type) + `scalar ${type.name}${ofType}`;
184185
}
185186

186187
function printObject(type: GraphQLObjectType, options): string {

0 commit comments

Comments
 (0)