Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1dafb51

Browse files
leebyronyaacovCR
authored andcommittedFeb 6, 2023
Schema Coordinates
Implements graphql/graphql-spec#794 Adds: * DOT punctuator in lexer * Improvements to lexer errors around misuse of `.` * Minor improvement to parser core which simplified this addition * `SchemaCoordinate` node and `isSchemaCoodinate()` predicate * Support in `print()` and `visit()` * Added function `parseSchemaCoordinate()` since it is a parser entry point. * Added function `resolveSchemaCoordinate()` and `resolveASTSchemaCoordinate()` which implement the semantics (name mirrored from `buildASTSchema`) as well as the return type `ResolvedSchemaElement`
1 parent b5eb498 commit 1dafb51

16 files changed

+695
-7
lines changed
 

‎src/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export {
218218
parseValue,
219219
parseConstValue,
220220
parseType,
221+
parseSchemaCoordinate,
221222
// Print
222223
print,
223224
// Visit
@@ -239,6 +240,7 @@ export {
239240
isTypeDefinitionNode,
240241
isTypeSystemExtensionNode,
241242
isTypeExtensionNode,
243+
isSchemaCoordinateNode,
242244
} from './language/index.js';
243245

244246
export type {
@@ -314,6 +316,7 @@ export type {
314316
UnionTypeExtensionNode,
315317
EnumTypeExtensionNode,
316318
InputObjectTypeExtensionNode,
319+
SchemaCoordinateNode,
317320
} from './language/index.js';
318321

319322
// Execute GraphQL queries.
@@ -459,6 +462,8 @@ export {
459462
DangerousChangeType,
460463
findBreakingChanges,
461464
findDangerousChanges,
465+
resolveSchemaCoordinate,
466+
resolveASTSchemaCoordinate,
462467
} from './utilities/index.js';
463468

464469
export type {
@@ -488,4 +493,5 @@ export type {
488493
BreakingChange,
489494
DangerousChange,
490495
TypedQueryDocumentNode,
496+
ResolvedSchemaElement,
491497
} from './utilities/index.js';

‎src/language/__tests__/lexer-test.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -852,7 +852,8 @@ describe('Lexer', () => {
852852
});
853853

854854
expectSyntaxError('.123').to.deep.equal({
855-
message: 'Syntax Error: Unexpected character: ".".',
855+
message:
856+
'Syntax Error: Invalid number, expected digit before ".", did you mean "0.123"?',
856857
locations: [{ line: 1, column: 1 }],
857858
});
858859

@@ -964,6 +965,13 @@ describe('Lexer', () => {
964965
value: undefined,
965966
});
966967

968+
expect(lexOne('.')).to.contain({
969+
kind: TokenKind.DOT,
970+
start: 0,
971+
end: 1,
972+
value: undefined,
973+
});
974+
967975
expect(lexOne('...')).to.contain({
968976
kind: TokenKind.SPREAD,
969977
start: 0,
@@ -1030,7 +1038,7 @@ describe('Lexer', () => {
10301038

10311039
it('lex reports useful unknown character error', () => {
10321040
expectSyntaxError('..').to.deep.equal({
1033-
message: 'Syntax Error: Unexpected character: ".".',
1041+
message: 'Syntax Error: Unexpected "..", did you mean "..."?',
10341042
locations: [{ line: 1, column: 1 }],
10351043
});
10361044

‎src/language/__tests__/parser-test.ts

+132-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js';
1111
import { inspect } from '../../jsutils/inspect.js';
1212

1313
import { Kind } from '../kinds.js';
14-
import { parse, parseConstValue, parseType, parseValue } from '../parser.js';
14+
import {
15+
parse,
16+
parseConstValue,
17+
parseSchemaCoordinate,
18+
parseType,
19+
parseValue,
20+
} from '../parser.js';
1521
import { Source } from '../source.js';
1622
import { TokenKind } from '../tokenKind.js';
1723

@@ -864,4 +870,129 @@ describe('Parser', () => {
864870
});
865871
});
866872
});
873+
874+
describe('parseSchemaCoordinate', () => {
875+
it('parses Name', () => {
876+
const result = parseSchemaCoordinate('MyType');
877+
expectJSON(result).toDeepEqual({
878+
kind: Kind.SCHEMA_COORDINATE,
879+
loc: { start: 0, end: 6 },
880+
ofDirective: false,
881+
name: {
882+
kind: Kind.NAME,
883+
loc: { start: 0, end: 6 },
884+
value: 'MyType',
885+
},
886+
memberName: undefined,
887+
argumentName: undefined,
888+
});
889+
});
890+
891+
it('parses Name . Name', () => {
892+
const result = parseSchemaCoordinate('MyType.field');
893+
expectJSON(result).toDeepEqual({
894+
kind: Kind.SCHEMA_COORDINATE,
895+
loc: { start: 0, end: 12 },
896+
ofDirective: false,
897+
name: {
898+
kind: Kind.NAME,
899+
loc: { start: 0, end: 6 },
900+
value: 'MyType',
901+
},
902+
memberName: {
903+
kind: Kind.NAME,
904+
loc: { start: 7, end: 12 },
905+
value: 'field',
906+
},
907+
argumentName: undefined,
908+
});
909+
});
910+
911+
it('rejects Name . Name . Name', () => {
912+
expectToThrowJSON(() =>
913+
parseSchemaCoordinate('MyType.field.deep'),
914+
).to.deep.equal({
915+
message: 'Syntax Error: Expected <EOF>, found ".".',
916+
locations: [{ line: 1, column: 13 }],
917+
});
918+
});
919+
920+
it('parses Name . Name ( Name : )', () => {
921+
const result = parseSchemaCoordinate('MyType.field(arg:)');
922+
expectJSON(result).toDeepEqual({
923+
kind: Kind.SCHEMA_COORDINATE,
924+
loc: { start: 0, end: 18 },
925+
ofDirective: false,
926+
name: {
927+
kind: Kind.NAME,
928+
loc: { start: 0, end: 6 },
929+
value: 'MyType',
930+
},
931+
memberName: {
932+
kind: Kind.NAME,
933+
loc: { start: 7, end: 12 },
934+
value: 'field',
935+
},
936+
argumentName: {
937+
kind: Kind.NAME,
938+
loc: { start: 13, end: 16 },
939+
value: 'arg',
940+
},
941+
});
942+
});
943+
944+
it('rejects Name . Name ( Name : Name )', () => {
945+
expectToThrowJSON(() =>
946+
parseSchemaCoordinate('MyType.field(arg: value)'),
947+
).to.deep.equal({
948+
message: 'Syntax Error: Expected ")", found Name "value".',
949+
locations: [{ line: 1, column: 19 }],
950+
});
951+
});
952+
953+
it('parses @ Name', () => {
954+
const result = parseSchemaCoordinate('@myDirective');
955+
expectJSON(result).toDeepEqual({
956+
kind: Kind.SCHEMA_COORDINATE,
957+
loc: { start: 0, end: 12 },
958+
ofDirective: true,
959+
name: {
960+
kind: Kind.NAME,
961+
loc: { start: 1, end: 12 },
962+
value: 'myDirective',
963+
},
964+
memberName: undefined,
965+
argumentName: undefined,
966+
});
967+
});
968+
969+
it('parses @ Name ( Name : )', () => {
970+
const result = parseSchemaCoordinate('@myDirective(arg:)');
971+
expectJSON(result).toDeepEqual({
972+
kind: Kind.SCHEMA_COORDINATE,
973+
loc: { start: 0, end: 18 },
974+
ofDirective: true,
975+
name: {
976+
kind: Kind.NAME,
977+
loc: { start: 1, end: 12 },
978+
value: 'myDirective',
979+
},
980+
memberName: undefined,
981+
argumentName: {
982+
kind: Kind.NAME,
983+
loc: { start: 13, end: 16 },
984+
value: 'arg',
985+
},
986+
});
987+
});
988+
989+
it('rejects @ Name . Name', () => {
990+
expectToThrowJSON(() =>
991+
parseSchemaCoordinate('@myDirective.field'),
992+
).to.deep.equal({
993+
message: 'Syntax Error: Expected <EOF>, found ".".',
994+
locations: [{ line: 1, column: 13 }],
995+
});
996+
});
997+
});
867998
});

‎src/language/__tests__/predicates-test.ts

+7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
isDefinitionNode,
1010
isExecutableDefinitionNode,
1111
isNullabilityAssertionNode,
12+
isSchemaCoordinateNode,
1213
isSelectionNode,
1314
isTypeDefinitionNode,
1415
isTypeExtensionNode,
@@ -150,4 +151,10 @@ describe('AST node predicates', () => {
150151
'InputObjectTypeExtension',
151152
]);
152153
});
154+
155+
it('isSchemaCoordinateNode', () => {
156+
expect(filterNodes(isSchemaCoordinateNode)).to.deep.equal([
157+
'SchemaCoordinate',
158+
]);
159+
});
153160
});

‎src/language/__tests__/printer-test.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { dedent, dedentString } from '../../__testUtils__/dedent.js';
55
import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js';
66

77
import { Kind } from '../kinds.js';
8-
import { parse } from '../parser.js';
8+
import { parse, parseSchemaCoordinate } from '../parser.js';
99
import { print } from '../printer.js';
1010

1111
describe('Printer: Query document', () => {
@@ -232,4 +232,18 @@ describe('Printer: Query document', () => {
232232
`),
233233
);
234234
});
235+
236+
it('prints schema coordinates', () => {
237+
expect(print(parseSchemaCoordinate(' Name '))).to.equal('Name');
238+
expect(print(parseSchemaCoordinate(' Name . field '))).to.equal(
239+
'Name.field',
240+
);
241+
expect(print(parseSchemaCoordinate(' Name . field ( arg: )'))).to.equal(
242+
'Name.field(arg:)',
243+
);
244+
expect(print(parseSchemaCoordinate(' @ name '))).to.equal('@name');
245+
expect(print(parseSchemaCoordinate(' @ name (arg:) '))).to.equal(
246+
'@name(arg:)',
247+
);
248+
});
235249
});

‎src/language/ast.ts

+14
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export type ASTNode =
181181
| UnionTypeExtensionNode
182182
| EnumTypeExtensionNode
183183
| InputObjectTypeExtensionNode
184+
| SchemaCoordinateNode
184185
| NonNullAssertionNode
185186
| ErrorBoundaryNode
186187
| ListNullabilityOperatorNode;
@@ -295,6 +296,8 @@ export const QueryDocumentKeys: {
295296
UnionTypeExtension: ['name', 'directives', 'types'],
296297
EnumTypeExtension: ['name', 'directives', 'values'],
297298
InputObjectTypeExtension: ['name', 'directives', 'fields'],
299+
300+
SchemaCoordinate: ['name', 'memberName', 'argumentName'],
298301
};
299302

300303
const kindValues = new Set<string>(Object.keys(QueryDocumentKeys));
@@ -785,3 +788,14 @@ export interface InputObjectTypeExtensionNode {
785788
readonly directives?: ReadonlyArray<ConstDirectiveNode> | undefined;
786789
readonly fields?: ReadonlyArray<InputValueDefinitionNode> | undefined;
787790
}
791+
792+
/** Schema Coordinates */
793+
794+
export interface SchemaCoordinateNode {
795+
readonly kind: Kind.SCHEMA_COORDINATE;
796+
readonly loc?: Location | undefined;
797+
readonly ofDirective: boolean;
798+
readonly name: NameNode;
799+
readonly memberName?: NameNode | undefined;
800+
readonly argumentName?: NameNode | undefined;
801+
}

‎src/language/index.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ export { TokenKind } from './tokenKind.js';
1111

1212
export { Lexer } from './lexer.js';
1313

14-
export { parse, parseValue, parseConstValue, parseType } from './parser.js';
14+
export {
15+
parse,
16+
parseValue,
17+
parseConstValue,
18+
parseType,
19+
parseSchemaCoordinate,
20+
} from './parser.js';
1521
export type { ParseOptions } from './parser.js';
1622

1723
export { print } from './printer.js';
@@ -91,6 +97,7 @@ export type {
9197
UnionTypeExtensionNode,
9298
EnumTypeExtensionNode,
9399
InputObjectTypeExtensionNode,
100+
SchemaCoordinateNode,
94101
} from './ast.js';
95102

96103
export {
@@ -105,6 +112,7 @@ export {
105112
isTypeDefinitionNode,
106113
isTypeSystemExtensionNode,
107114
isTypeExtensionNode,
115+
isSchemaCoordinateNode,
108116
} from './predicates.js';
109117

110118
export { DirectiveLocation } from './directiveLocation.js';

‎src/language/kinds.ts

+3
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,7 @@ export enum Kind {
7171
UNION_TYPE_EXTENSION = 'UnionTypeExtension',
7272
ENUM_TYPE_EXTENSION = 'EnumTypeExtension',
7373
INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension',
74+
75+
/** Schema Coordinates */
76+
SCHEMA_COORDINATE = 'SchemaCoordinate',
7477
}

‎src/language/lexer.ts

+38-2
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function isPunctuatorTokenKind(kind: TokenKind): boolean {
9696
kind === TokenKind.AMP ||
9797
kind === TokenKind.PAREN_L ||
9898
kind === TokenKind.PAREN_R ||
99+
kind === TokenKind.DOT ||
99100
kind === TokenKind.SPREAD ||
100101
kind === TokenKind.COLON ||
101102
kind === TokenKind.EQUALS ||
@@ -247,7 +248,11 @@ function readNextToken(lexer: Lexer, start: number): Token {
247248
// - FloatValue
248249
// - StringValue
249250
//
250-
// Punctuator :: one of ! $ & ( ) ... : = @ [ ] { | }
251+
// Punctuator ::
252+
// - DotPunctuator
253+
// - OtherPunctuator
254+
//
255+
// OtherPunctuator :: one of ! $ & ( ) ... : = @ [ ] { | }
251256
case 0x0021: // !
252257
return createToken(lexer, TokenKind.BANG, position, position + 1);
253258
case 0x0024: // $
@@ -265,7 +270,7 @@ function readNextToken(lexer: Lexer, start: number): Token {
265270
) {
266271
return createToken(lexer, TokenKind.SPREAD, position, position + 3);
267272
}
268-
break;
273+
return readDot(lexer, position);
269274
case 0x003a: // :
270275
return createToken(lexer, TokenKind.COLON, position, position + 1);
271276
case 0x003d: // =
@@ -324,6 +329,37 @@ function readNextToken(lexer: Lexer, start: number): Token {
324329
return createToken(lexer, TokenKind.EOF, bodyLength, bodyLength);
325330
}
326331

332+
/**
333+
* Reads a dot token with helpful messages for negative lookahead.
334+
*
335+
* ```
336+
* DotPunctuator :: `.` [lookahead != {`.`, Digit}]
337+
* ```
338+
*/
339+
function readDot(lexer: Lexer, start: number): Token {
340+
const nextCode = lexer.source.body.charCodeAt(start + 1);
341+
// Full Stop (.)
342+
if (nextCode === 0x002e) {
343+
throw syntaxError(
344+
lexer.source,
345+
start,
346+
'Unexpected "..", did you mean "..."?',
347+
);
348+
}
349+
if (isDigit(nextCode)) {
350+
const digits = lexer.source.body.slice(
351+
start + 1,
352+
readDigits(lexer, start + 1, nextCode),
353+
);
354+
throw syntaxError(
355+
lexer.source,
356+
start,
357+
`Invalid number, expected digit before ".", did you mean "0.${digits}"?`,
358+
);
359+
}
360+
return createToken(lexer, TokenKind.DOT, start, start + 1);
361+
}
362+
327363
/**
328364
* Reads a comment token from the source file.
329365
*

‎src/language/parser.ts

+59
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import type {
5050
OperationTypeDefinitionNode,
5151
ScalarTypeDefinitionNode,
5252
ScalarTypeExtensionNode,
53+
SchemaCoordinateNode,
5354
SchemaDefinitionNode,
5455
SchemaExtensionNode,
5556
SelectionNode,
@@ -201,6 +202,26 @@ export function parseType(
201202
return type;
202203
}
203204

205+
/**
206+
* Given a string containing a GraphQL Schema Coordinate (ex. `Type.field`),
207+
* parse the AST for that schema coordinate.
208+
* Throws GraphQLError if a syntax error is encountered.
209+
*
210+
* Consider providing the results to the utility function:
211+
* resolveASTSchemaCoordinate(). Or calling resolveSchemaCoordinate() directly
212+
* with an unparsed source.
213+
*/
214+
export function parseSchemaCoordinate(
215+
source: string | Source,
216+
options?: ParseOptions,
217+
): SchemaCoordinateNode {
218+
const parser = new Parser(source, options);
219+
parser.expectToken(TokenKind.SOF);
220+
const type = parser.parseSchemaCoordinate();
221+
parser.expectToken(TokenKind.EOF);
222+
return type;
223+
}
224+
204225
/**
205226
* This class is exported only to assist people in implementing their own parsers
206227
* without duplicating too much code and should be used only as last resort for cases
@@ -1455,6 +1476,44 @@ export class Parser {
14551476
throw this.unexpected(start);
14561477
}
14571478

1479+
// Schema Coordinates
1480+
1481+
/**
1482+
* ```
1483+
* SchemaCoordinate :
1484+
* - Name
1485+
* - Name . Name
1486+
* - Name . Name ( Name : )
1487+
* - @ Name
1488+
* - @ Name ( Name : )
1489+
* ```
1490+
*/
1491+
parseSchemaCoordinate(): SchemaCoordinateNode {
1492+
const start = this._lexer.token;
1493+
const ofDirective = this.expectOptionalToken(TokenKind.AT);
1494+
const name = this.parseName();
1495+
let memberName;
1496+
if (!ofDirective && this.expectOptionalToken(TokenKind.DOT)) {
1497+
memberName = this.parseName();
1498+
}
1499+
let argumentName;
1500+
if (
1501+
(ofDirective || memberName) &&
1502+
this.expectOptionalToken(TokenKind.PAREN_L)
1503+
) {
1504+
argumentName = this.parseName();
1505+
this.expectToken(TokenKind.COLON);
1506+
this.expectToken(TokenKind.PAREN_R);
1507+
}
1508+
return this.node<SchemaCoordinateNode>(start, {
1509+
kind: Kind.SCHEMA_COORDINATE,
1510+
ofDirective,
1511+
name,
1512+
memberName,
1513+
argumentName,
1514+
});
1515+
}
1516+
14581517
// Core parsing utility functions
14591518

14601519
/**

‎src/language/predicates.ts

+7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
DefinitionNode,
55
ExecutableDefinitionNode,
66
NullabilityAssertionNode,
7+
SchemaCoordinateNode,
78
SelectionNode,
89
TypeDefinitionNode,
910
TypeExtensionNode,
@@ -121,3 +122,9 @@ export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode {
121122
node.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION
122123
);
123124
}
125+
126+
export function isSchemaCoordinateNode(
127+
node: ASTNode,
128+
): node is SchemaCoordinateNode {
129+
return node.kind === Kind.SCHEMA_COORDINATE;
130+
}

‎src/language/printer.ts

+12
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,18 @@ const printDocASTReducer: ASTReducer<string> = {
336336
leave: ({ name, directives, fields }) =>
337337
join(['extend input', name, join(directives, ' '), block(fields)], ' '),
338338
},
339+
340+
// Schema Coordinate
341+
342+
SchemaCoordinate: {
343+
leave: ({ ofDirective, name, memberName, argumentName }) =>
344+
join([
345+
ofDirective && '@',
346+
name,
347+
wrap('.', memberName),
348+
wrap('(', argumentName, ':)'),
349+
]),
350+
},
339351
};
340352

341353
/**

‎src/language/tokenKind.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export enum TokenKind {
1111
AMP = '&',
1212
PAREN_L = '(',
1313
PAREN_R = ')',
14+
DOT = '.',
1415
SPREAD = '...',
1516
COLON = ':',
1617
EQUALS = '=',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import type {
5+
GraphQLEnumType,
6+
GraphQLInputObjectType,
7+
GraphQLObjectType,
8+
} from '../../type/definition.js';
9+
import type { GraphQLDirective } from '../../type/directives.js';
10+
11+
import { buildSchema } from '../buildASTSchema.js';
12+
import { resolveSchemaCoordinate } from '../resolveSchemaCoordinate.js';
13+
14+
describe('resolveSchemaCoordinate', () => {
15+
const schema = buildSchema(`
16+
type Query {
17+
searchBusiness(criteria: SearchCriteria!): [Business]
18+
}
19+
20+
input SearchCriteria {
21+
name: String
22+
filter: SearchFilter
23+
}
24+
25+
enum SearchFilter {
26+
OPEN_NOW
27+
DELIVERS_TAKEOUT
28+
VEGETARIAN_MENU
29+
}
30+
31+
type Business {
32+
id: ID
33+
name: String
34+
email: String @private(scope: "loggedIn")
35+
}
36+
37+
directive @private(scope: String!) on FIELD_DEFINITION
38+
`);
39+
40+
it('resolves a Named Type', () => {
41+
expect(resolveSchemaCoordinate(schema, 'Business')).to.deep.equal({
42+
kind: 'NamedType',
43+
type: schema.getType('Business'),
44+
});
45+
46+
expect(resolveSchemaCoordinate(schema, 'String')).to.deep.equal({
47+
kind: 'NamedType',
48+
type: schema.getType('String'),
49+
});
50+
51+
expect(resolveSchemaCoordinate(schema, 'private')).to.deep.equal(undefined);
52+
53+
expect(resolveSchemaCoordinate(schema, 'Unknown')).to.deep.equal(undefined);
54+
});
55+
56+
it('resolves a Type Field', () => {
57+
const type = schema.getType('Business') as GraphQLObjectType;
58+
const field = type.getFields().name;
59+
expect(resolveSchemaCoordinate(schema, 'Business.name')).to.deep.equal({
60+
kind: 'Field',
61+
type,
62+
field,
63+
});
64+
65+
expect(resolveSchemaCoordinate(schema, 'Business.unknown')).to.deep.equal(
66+
undefined,
67+
);
68+
69+
expect(resolveSchemaCoordinate(schema, 'Unknown.field')).to.deep.equal(
70+
undefined,
71+
);
72+
73+
expect(resolveSchemaCoordinate(schema, 'String.field')).to.deep.equal(
74+
undefined,
75+
);
76+
});
77+
78+
it('does not resolve meta-fields', () => {
79+
expect(
80+
resolveSchemaCoordinate(schema, 'Business.__typename'),
81+
).to.deep.equal(undefined);
82+
});
83+
84+
it('resolves a Input Field', () => {
85+
const type = schema.getType('SearchCriteria') as GraphQLInputObjectType;
86+
const inputField = type.getFields().filter;
87+
expect(
88+
resolveSchemaCoordinate(schema, 'SearchCriteria.filter'),
89+
).to.deep.equal({
90+
kind: 'InputField',
91+
type,
92+
inputField,
93+
});
94+
95+
expect(
96+
resolveSchemaCoordinate(schema, 'SearchCriteria.unknown'),
97+
).to.deep.equal(undefined);
98+
});
99+
100+
it('resolves a Enum Value', () => {
101+
const type = schema.getType('SearchFilter') as GraphQLEnumType;
102+
const enumValue = type.getValue('OPEN_NOW');
103+
expect(
104+
resolveSchemaCoordinate(schema, 'SearchFilter.OPEN_NOW'),
105+
).to.deep.equal({
106+
kind: 'EnumValue',
107+
type,
108+
enumValue,
109+
});
110+
111+
expect(
112+
resolveSchemaCoordinate(schema, 'SearchFilter.UNKNOWN'),
113+
).to.deep.equal(undefined);
114+
});
115+
116+
it('resolves a Field Argument', () => {
117+
const type = schema.getType('Query') as GraphQLObjectType;
118+
const field = type.getFields().searchBusiness;
119+
const fieldArgument = field.args.find((arg) => arg.name === 'criteria');
120+
expect(
121+
resolveSchemaCoordinate(schema, 'Query.searchBusiness(criteria:)'),
122+
).to.deep.equal({
123+
kind: 'FieldArgument',
124+
type,
125+
field,
126+
fieldArgument,
127+
});
128+
129+
expect(
130+
resolveSchemaCoordinate(schema, 'Business.name(unknown:)'),
131+
).to.deep.equal(undefined);
132+
133+
expect(
134+
resolveSchemaCoordinate(schema, 'Unknown.field(arg:)'),
135+
).to.deep.equal(undefined);
136+
137+
expect(
138+
resolveSchemaCoordinate(schema, 'Business.unknown(arg:)'),
139+
).to.deep.equal(undefined);
140+
141+
expect(
142+
resolveSchemaCoordinate(schema, 'SearchCriteria.name(arg:)'),
143+
).to.deep.equal(undefined);
144+
});
145+
146+
it('resolves a Directive', () => {
147+
expect(resolveSchemaCoordinate(schema, '@private')).to.deep.equal({
148+
kind: 'Directive',
149+
directive: schema.getDirective('private'),
150+
});
151+
152+
expect(resolveSchemaCoordinate(schema, '@deprecated')).to.deep.equal({
153+
kind: 'Directive',
154+
directive: schema.getDirective('deprecated'),
155+
});
156+
157+
expect(resolveSchemaCoordinate(schema, '@unknown')).to.deep.equal(
158+
undefined,
159+
);
160+
161+
expect(resolveSchemaCoordinate(schema, '@Business')).to.deep.equal(
162+
undefined,
163+
);
164+
});
165+
166+
it('resolves a Directive Argument', () => {
167+
const directive = schema.getDirective('private') as GraphQLDirective;
168+
const directiveArgument = directive.args.find(
169+
(arg) => arg.name === 'scope',
170+
);
171+
expect(resolveSchemaCoordinate(schema, '@private(scope:)')).to.deep.equal({
172+
kind: 'DirectiveArgument',
173+
directive,
174+
directiveArgument,
175+
});
176+
177+
expect(resolveSchemaCoordinate(schema, '@private(unknown:)')).to.deep.equal(
178+
undefined,
179+
);
180+
181+
expect(resolveSchemaCoordinate(schema, '@unknown(arg:)')).to.deep.equal(
182+
undefined,
183+
);
184+
});
185+
});

‎src/utilities/index.ts

+7
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,10 @@ export type { BreakingChange, DangerousChange } from './findBreakingChanges.js';
9797

9898
// Wrapper type that contains DocumentNode and types that can be deduced from it.
9999
export type { TypedQueryDocumentNode } from './typedQueryDocumentNode.js';
100+
101+
// Schema coordinates
102+
export {
103+
resolveSchemaCoordinate,
104+
resolveASTSchemaCoordinate,
105+
} from './resolveSchemaCoordinate.js';
106+
export type { ResolvedSchemaElement } from './resolveSchemaCoordinate.js';
+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import type { SchemaCoordinateNode } from '../language/ast.js';
2+
import { parseSchemaCoordinate } from '../language/parser.js';
3+
import type { Source } from '../language/source.js';
4+
5+
import type {
6+
GraphQLArgument,
7+
GraphQLEnumValue,
8+
GraphQLField,
9+
GraphQLInputField,
10+
GraphQLNamedType,
11+
} from '../type/definition.js';
12+
import {
13+
isEnumType,
14+
isInputObjectType,
15+
isInterfaceType,
16+
isObjectType,
17+
} from '../type/definition.js';
18+
import type { GraphQLDirective } from '../type/directives.js';
19+
import type { GraphQLSchema } from '../type/schema.js';
20+
21+
/**
22+
* A resolved schema element may be one of the following kinds:
23+
*/
24+
export type ResolvedSchemaElement =
25+
| {
26+
readonly kind: 'NamedType';
27+
readonly type: GraphQLNamedType;
28+
}
29+
| {
30+
readonly kind: 'Field';
31+
readonly type: GraphQLNamedType;
32+
readonly field: GraphQLField<unknown, unknown>;
33+
}
34+
| {
35+
readonly kind: 'InputField';
36+
readonly type: GraphQLNamedType;
37+
readonly inputField: GraphQLInputField;
38+
}
39+
| {
40+
readonly kind: 'EnumValue';
41+
readonly type: GraphQLNamedType;
42+
readonly enumValue: GraphQLEnumValue;
43+
}
44+
| {
45+
readonly kind: 'FieldArgument';
46+
readonly type: GraphQLNamedType;
47+
readonly field: GraphQLField<unknown, unknown>;
48+
readonly fieldArgument: GraphQLArgument;
49+
}
50+
| {
51+
readonly kind: 'Directive';
52+
readonly directive: GraphQLDirective;
53+
}
54+
| {
55+
readonly kind: 'DirectiveArgument';
56+
readonly directive: GraphQLDirective;
57+
readonly directiveArgument: GraphQLArgument;
58+
};
59+
60+
/**
61+
* A schema coordinate is resolved in the context of a GraphQL schema to
62+
* uniquely identifies a schema element. It returns undefined if the schema
63+
* coordinate does not resolve to a schema element.
64+
*
65+
* https://spec.graphql.org/draft/#sec-Schema-Coordinates.Semantics
66+
*/
67+
export function resolveSchemaCoordinate(
68+
schema: GraphQLSchema,
69+
schemaCoordinate: string | Source,
70+
): ResolvedSchemaElement | undefined {
71+
return resolveASTSchemaCoordinate(
72+
schema,
73+
parseSchemaCoordinate(schemaCoordinate),
74+
);
75+
}
76+
77+
/**
78+
* Resolves schema coordinate from a parsed SchemaCoordinate node.
79+
*/
80+
export function resolveASTSchemaCoordinate(
81+
schema: GraphQLSchema,
82+
schemaCoordinate: SchemaCoordinateNode,
83+
): ResolvedSchemaElement | undefined {
84+
const { ofDirective, name, memberName, argumentName } = schemaCoordinate;
85+
if (ofDirective) {
86+
// SchemaCoordinate :
87+
// - @ Name
88+
// - @ Name ( Name : )
89+
// Let {directiveName} be the value of the first {Name}.
90+
// Let {directive} be the directive in the {schema} named {directiveName}.
91+
const directive = schema.getDirective(name.value);
92+
if (!argumentName) {
93+
// SchemaCoordinate : @ Name
94+
// Return the directive in the {schema} named {directiveName}.
95+
if (!directive) {
96+
return;
97+
}
98+
return { kind: 'Directive', directive };
99+
}
100+
101+
// SchemaCoordinate : @ Name ( Name : )
102+
// Assert {directive} must exist.
103+
if (!directive) {
104+
return;
105+
}
106+
// Let {directiveArgumentName} be the value of the second {Name}.
107+
// Return the argument of {directive} named {directiveArgumentName}.
108+
const directiveArgument = directive.args.find(
109+
(arg) => arg.name === argumentName.value,
110+
);
111+
if (!directiveArgument) {
112+
return;
113+
}
114+
return { kind: 'DirectiveArgument', directive, directiveArgument };
115+
}
116+
117+
// SchemaCoordinate :
118+
// - Name
119+
// - Name . Name
120+
// - Name . Name ( Name : )
121+
// Let {typeName} be the value of the first {Name}.
122+
// Let {type} be the type in the {schema} named {typeName}.
123+
const type = schema.getType(name.value);
124+
if (!memberName) {
125+
// SchemaCoordinate : Name
126+
// Return the type in the {schema} named {typeName}.
127+
if (!type) {
128+
return;
129+
}
130+
return { kind: 'NamedType', type };
131+
}
132+
133+
if (!argumentName) {
134+
// SchemaCoordinate : Name . Name
135+
// If {type} is an Enum type:
136+
if (isEnumType(type)) {
137+
// Let {enumValueName} be the value of the second {Name}.
138+
// Return the enum value of {type} named {enumValueName}.
139+
const enumValue = type.getValue(memberName.value);
140+
if (!enumValue) {
141+
return;
142+
}
143+
return { kind: 'EnumValue', type, enumValue };
144+
}
145+
// Otherwise if {type} is an Input Object type:
146+
if (isInputObjectType(type)) {
147+
// Let {inputFieldName} be the value of the second {Name}.
148+
// Return the input field of {type} named {inputFieldName}.
149+
const inputField = type.getFields()[memberName.value];
150+
if (!inputField) {
151+
return;
152+
}
153+
return { kind: 'InputField', type, inputField };
154+
}
155+
// Otherwise:
156+
// Assert {type} must be an Object or Interface type.
157+
if (!isObjectType(type) && !isInterfaceType(type)) {
158+
return;
159+
}
160+
// Let {fieldName} be the value of the second {Name}.
161+
// Return the field of {type} named {fieldName}.
162+
const field = type.getFields()[memberName.value];
163+
if (!field) {
164+
return;
165+
}
166+
return { kind: 'Field', type, field };
167+
}
168+
169+
// SchemaCoordinate : Name . Name ( Name : )
170+
// Assert {type} must be an Object or Interface type.
171+
if (!isObjectType(type) && !isInterfaceType(type)) {
172+
return;
173+
}
174+
// Let {fieldName} be the value of the second {Name}.
175+
// Let {field} be the field of {type} named {fieldName}.
176+
const field = type.getFields()[memberName.value];
177+
// Assert {field} must exist.
178+
if (!field) {
179+
return;
180+
}
181+
// Let {fieldArgumentName} be the value of the third {Name}.
182+
// Return the argument of {field} named {fieldArgumentName}.
183+
const fieldArgument = field.args.find(
184+
(arg) => arg.name === argumentName.value,
185+
);
186+
if (!fieldArgument) {
187+
return;
188+
}
189+
return { kind: 'FieldArgument', type, field, fieldArgument };
190+
}

0 commit comments

Comments
 (0)
Please sign in to comment.