From c7b87e21ef6aaaaa86868f7d85ce5c27c2c5063f Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Fri, 16 Apr 2021 02:11:50 -0700 Subject: [PATCH] Schema Coordinates Implements https://github.com/graphql/graphql-spec/pull/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 `resolveASTSchemeCoordinate()` which implement the semantics (name mirrored from `buildASTSchema`) as well as the return type `GraphQLSchemaElement` --- src/index.d.ts | 6 + src/index.js | 6 + src/language/__tests__/lexer-test.js | 11 +- src/language/__tests__/parser-test.js | 127 ++++++++++++++- src/language/__tests__/predicates-test.js | 7 + src/language/__tests__/printer-test.js | 16 +- src/language/ast.d.ts | 15 +- src/language/ast.js | 15 +- src/language/index.d.ts | 10 +- src/language/index.js | 4 +- src/language/kinds.d.ts | 3 + src/language/kinds.js | 3 + src/language/lexer.js | 35 ++++- src/language/parser.d.ts | 23 ++- src/language/parser.js | 62 +++++++- src/language/predicates.d.ts | 5 + src/language/predicates.js | 4 + src/language/printer.js | 12 ++ src/language/tokenKind.d.ts | 1 + src/language/tokenKind.js | 1 + src/language/visitor.js | 2 + src/type/element.d.ts | 16 ++ src/type/element.js | 16 ++ src/type/index.d.ts | 3 + src/type/index.js | 3 + .../__tests__/resolveSchemaCoordinate-test.js | 147 ++++++++++++++++++ src/utilities/index.d.ts | 6 + src/utilities/index.js | 6 + src/utilities/resolveSchemaCoordinate.d.ts | 22 +++ src/utilities/resolveSchemaCoordinate.js | 109 +++++++++++++ 30 files changed, 679 insertions(+), 17 deletions(-) create mode 100644 src/type/element.d.ts create mode 100644 src/type/element.js create mode 100644 src/utilities/__tests__/resolveSchemaCoordinate-test.js create mode 100644 src/utilities/resolveSchemaCoordinate.d.ts create mode 100644 src/utilities/resolveSchemaCoordinate.js diff --git a/src/index.d.ts b/src/index.d.ts index e408ae67cb2..659bd51db57 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -181,6 +181,7 @@ export { GraphQLScalarSerializer, GraphQLScalarValueParser, GraphQLScalarLiteralParser, + GraphQLSchemaElement, } from './type/index'; // Parse and operate on GraphQL language source files. @@ -199,6 +200,7 @@ export { parse, parseValue, parseType, + parseSchemaCoordinate, // Print print, // Visit @@ -218,6 +220,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './language/index'; export { @@ -286,6 +289,7 @@ export { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './language/index'; // Execute GraphQL queries. @@ -427,6 +431,8 @@ export { DangerousChangeType, findBreakingChanges, findDangerousChanges, + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, } from './utilities/index'; export { diff --git a/src/index.js b/src/index.js index cd446b7fd52..712b6af67fa 100644 --- a/src/index.js +++ b/src/index.js @@ -168,6 +168,7 @@ export type { GraphQLScalarSerializer, GraphQLScalarValueParser, GraphQLScalarLiteralParser, + GraphQLSchemaElement, } from './type/index'; // Parse and operate on GraphQL language source files. @@ -186,6 +187,7 @@ export { parse, parseValue, parseType, + parseSchemaCoordinate, // Print print, // Visit @@ -205,6 +207,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './language/index'; export type { @@ -273,6 +276,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './language/index'; // Execute GraphQL queries. @@ -416,6 +420,8 @@ export { DangerousChangeType, findBreakingChanges, findDangerousChanges, + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, } from './utilities/index'; export type { diff --git a/src/language/__tests__/lexer-test.js b/src/language/__tests__/lexer-test.js index 77ae6b629dd..d9274db3424 100644 --- a/src/language/__tests__/lexer-test.js +++ b/src/language/__tests__/lexer-test.js @@ -649,7 +649,7 @@ describe('Lexer', () => { }); expectSyntaxError('.123').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character ".".', + message: 'Syntax Error: Invalid number, expected digit before ".".', locations: [{ line: 1, column: 1 }], }); @@ -753,6 +753,13 @@ describe('Lexer', () => { value: undefined, }); + expect(lexOne('.')).to.contain({ + kind: TokenKind.DOT, + start: 0, + end: 1, + value: undefined, + }); + expect(lexOne('...')).to.contain({ kind: TokenKind.SPREAD, start: 0, @@ -819,7 +826,7 @@ describe('Lexer', () => { it('lex reports useful unknown character error', () => { expectSyntaxError('..').to.deep.equal({ - message: 'Syntax Error: Cannot parse the unexpected character ".".', + message: 'Syntax Error: Cannot parse the unexpected character "..".', locations: [{ line: 1, column: 1 }], }); diff --git a/src/language/__tests__/parser-test.js b/src/language/__tests__/parser-test.js index eb718ce580e..81d1da42a19 100644 --- a/src/language/__tests__/parser-test.js +++ b/src/language/__tests__/parser-test.js @@ -9,7 +9,7 @@ import { inspect } from '../../jsutils/inspect'; import { Kind } from '../kinds'; import { Source } from '../source'; import { TokenKind } from '../tokenKind'; -import { parse, parseValue, parseType } from '../parser'; +import { parse, parseValue, parseType, parseSchemaCoordinate } from '../parser'; import { toJSONDeep } from './toJSONDeep'; @@ -531,4 +531,129 @@ describe('Parser', () => { }); }); }); + + describe('parseSchemaCoordinate', () => { + it('parses Name', () => { + const result = parseSchemaCoordinate('MyType'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 6 }, + isDirective: false, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + fieldName: undefined, + argumentName: undefined, + }); + }); + + it('parses Name . Name', () => { + const result = parseSchemaCoordinate('MyType.field'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 12 }, + isDirective: false, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + fieldName: { + kind: Kind.NAME, + loc: { start: 7, end: 12 }, + value: 'field', + }, + argumentName: undefined, + }); + }); + + it('rejects Name . Name . Name', () => { + expect(() => parseSchemaCoordinate('MyType.field.deep')) + .to.throw() + .to.deep.equal({ + message: 'Syntax Error: Expected , found ".".', + locations: [{ line: 1, column: 13 }], + }); + }); + + it('parses Name . Name ( Name : )', () => { + const result = parseSchemaCoordinate('MyType.field(arg:)'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 18 }, + isDirective: false, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + fieldName: { + kind: Kind.NAME, + loc: { start: 7, end: 12 }, + value: 'field', + }, + argumentName: { + kind: Kind.NAME, + loc: { start: 13, end: 16 }, + value: 'arg', + }, + }); + }); + + it('rejects Name . Name ( Name : Name )', () => { + expect(() => parseSchemaCoordinate('MyType.field(arg: value)')) + .to.throw() + .to.deep.equal({ + message: 'Syntax Error: Expected ")", found Name "value".', + locations: [{ line: 1, column: 19 }], + }); + }); + + it('parses @ Name', () => { + const result = parseSchemaCoordinate('@myDirective'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 12 }, + isDirective: true, + name: { + kind: Kind.NAME, + loc: { start: 1, end: 12 }, + value: 'myDirective', + }, + fieldName: undefined, + argumentName: undefined, + }); + }); + + it('parses @ Name ( Name : )', () => { + const result = parseSchemaCoordinate('@myDirective(arg:)'); + expect(toJSONDeep(result)).to.deep.equal({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 18 }, + isDirective: true, + name: { + kind: Kind.NAME, + loc: { start: 1, end: 12 }, + value: 'myDirective', + }, + fieldName: undefined, + argumentName: { + kind: Kind.NAME, + loc: { start: 13, end: 16 }, + value: 'arg', + }, + }); + }); + + it('rejects @ Name . Name', () => { + expect(() => parseSchemaCoordinate('@myDirective.field')) + .to.throw() + .to.deep.equal({ + message: 'Syntax Error: Expected , found ".".', + locations: [{ line: 1, column: 13 }], + }); + }); + }); }); diff --git a/src/language/__tests__/predicates-test.js b/src/language/__tests__/predicates-test.js index eb620abd614..bda67865243 100644 --- a/src/language/__tests__/predicates-test.js +++ b/src/language/__tests__/predicates-test.js @@ -13,6 +13,7 @@ import { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from '../predicates'; const allASTNodes: Array = Object.values(Kind).map( @@ -129,4 +130,10 @@ describe('AST node predicates', () => { 'InputObjectTypeExtension', ]); }); + + it('isSchemaCoordinateNode', () => { + expect(filterNodes(isSchemaCoordinateNode)).to.deep.equal([ + 'SchemaCoordinate', + ]); + }); }); diff --git a/src/language/__tests__/printer-test.js b/src/language/__tests__/printer-test.js index ff5a3b2669a..79f93f084fd 100644 --- a/src/language/__tests__/printer-test.js +++ b/src/language/__tests__/printer-test.js @@ -3,8 +3,8 @@ import { describe, it } from 'mocha'; import { dedent, dedentString } from '../../__testUtils__/dedent'; import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery'; +import { parseSchemaCoordinate, parse } from '../parser'; -import { parse } from '../parser'; import { print } from '../printer'; describe('Printer: Query document', () => { @@ -214,4 +214,18 @@ describe('Printer: Query document', () => { `), ); }); + + it('prints schema coordinates', () => { + expect(print(parseSchemaCoordinate(' Name '))).to.equal('Name'); + expect(print(parseSchemaCoordinate(' Name . field '))).to.equal( + 'Name.field', + ); + expect(print(parseSchemaCoordinate(' Name . field ( arg: )'))).to.equal( + 'Name.field(arg:)', + ); + expect(print(parseSchemaCoordinate(' @ name '))).to.equal('@name'); + expect(print(parseSchemaCoordinate(' @ name (arg:) '))).to.equal( + '@name(arg:)', + ); + }); }); diff --git a/src/language/ast.d.ts b/src/language/ast.d.ts index 20ec92f8052..eb029e7e05c 100644 --- a/src/language/ast.d.ts +++ b/src/language/ast.d.ts @@ -148,7 +148,8 @@ export type ASTNode = | InterfaceTypeExtensionNode | UnionTypeExtensionNode | EnumTypeExtensionNode - | InputObjectTypeExtensionNode; + | InputObjectTypeExtensionNode + | SchemaCoordinateNode; /** * Utility type listing all nodes indexed by their kind. @@ -197,6 +198,7 @@ export interface ASTKindToNode { UnionTypeExtension: UnionTypeExtensionNode; EnumTypeExtension: EnumTypeExtensionNode; InputObjectTypeExtension: InputObjectTypeExtensionNode; + SchemaCoordinate: SchemaCoordinateNode; } // Name @@ -599,3 +601,14 @@ export interface InputObjectTypeExtensionNode { readonly directives?: ReadonlyArray; readonly fields?: ReadonlyArray; } + +// Schema Coordinates + +export interface SchemaCoordinateNode { + readonly kind: 'SchemaCoordinate'; + readonly loc?: Location; + readonly isDirective: boolean; + readonly name: NameNode; + readonly fieldName?: NameNode; + readonly argumentName?: NameNode; +} diff --git a/src/language/ast.js b/src/language/ast.js index c0a9a615e7e..14f29aba0f9 100644 --- a/src/language/ast.js +++ b/src/language/ast.js @@ -174,7 +174,8 @@ export type ASTNode = | InterfaceTypeExtensionNode | UnionTypeExtensionNode | EnumTypeExtensionNode - | InputObjectTypeExtensionNode; + | InputObjectTypeExtensionNode + | SchemaCoordinateNode; /** * Utility type listing all nodes indexed by their kind. @@ -223,6 +224,7 @@ export type ASTKindToNode = {| UnionTypeExtension: UnionTypeExtensionNode, EnumTypeExtension: EnumTypeExtensionNode, InputObjectTypeExtension: InputObjectTypeExtensionNode, + SchemaCoordinate: SchemaCoordinateNode, |}; // Name @@ -625,3 +627,14 @@ export type InputObjectTypeExtensionNode = {| +directives?: $ReadOnlyArray, +fields?: $ReadOnlyArray, |}; + +// Schema Coordinates + +export type SchemaCoordinateNode = {| + +kind: 'SchemaCoordinate', + +loc?: Location, + +isDirective: boolean, + +name: NameNode, + +fieldName?: NameNode, + +argumentName?: NameNode, +|}; diff --git a/src/language/index.d.ts b/src/language/index.d.ts index a5b1157b24a..f547efd1b10 100644 --- a/src/language/index.d.ts +++ b/src/language/index.d.ts @@ -6,7 +6,13 @@ export { printLocation, printSourceLocation } from './printLocation'; export { Kind, KindEnum } from './kinds'; export { TokenKind, TokenKindEnum } from './tokenKind'; export { Lexer } from './lexer'; -export { parse, parseValue, parseType, ParseOptions } from './parser'; +export { + parse, + parseValue, + parseType, + parseSchemaCoordinate, + ParseOptions, +} from './parser'; export { print } from './printer'; export { visit, @@ -76,6 +82,7 @@ export { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast'; export { @@ -88,6 +95,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './predicates'; export { DirectiveLocation, DirectiveLocationEnum } from './directiveLocation'; diff --git a/src/language/index.js b/src/language/index.js index 71cb5f5c226..bfd14cca7ad 100644 --- a/src/language/index.js +++ b/src/language/index.js @@ -13,7 +13,7 @@ export type { TokenKindEnum } from './tokenKind'; export { Lexer } from './lexer'; -export { parse, parseValue, parseType } from './parser'; +export { parse, parseValue, parseType, parseSchemaCoordinate } from './parser'; export type { ParseOptions } from './parser'; export { print } from './printer'; @@ -79,6 +79,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast'; export { @@ -91,6 +92,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './predicates'; export { DirectiveLocation } from './directiveLocation'; diff --git a/src/language/kinds.d.ts b/src/language/kinds.d.ts index 2e31c1f06f6..028c5a6f64f 100644 --- a/src/language/kinds.d.ts +++ b/src/language/kinds.d.ts @@ -66,6 +66,9 @@ export const Kind: Readonly<{ UNION_TYPE_EXTENSION: 'UnionTypeExtension'; ENUM_TYPE_EXTENSION: 'EnumTypeExtension'; INPUT_OBJECT_TYPE_EXTENSION: 'InputObjectTypeExtension'; + + // Schema Coordinates + SCHEMA_COORDINATE: 'SchemaCoordinate'; }>; /** diff --git a/src/language/kinds.js b/src/language/kinds.js index 99e3e4a9ead..c921c81a357 100644 --- a/src/language/kinds.js +++ b/src/language/kinds.js @@ -66,6 +66,9 @@ export const Kind = Object.freeze({ UNION_TYPE_EXTENSION: 'UnionTypeExtension', ENUM_TYPE_EXTENSION: 'EnumTypeExtension', INPUT_OBJECT_TYPE_EXTENSION: 'InputObjectTypeExtension', + + // Schema Coordinates + SCHEMA_COORDINATE: 'SchemaCoordinate', }); /** diff --git a/src/language/lexer.js b/src/language/lexer.js index ad42ce98976..5a8f43b5673 100644 --- a/src/language/lexer.js +++ b/src/language/lexer.js @@ -83,6 +83,7 @@ export function isPunctuatorTokenKind(kind: TokenKindEnum): boolean %checks { kind === TokenKind.PAREN_L || kind === TokenKind.PAREN_R || kind === TokenKind.SPREAD || + kind === TokenKind.DOT || kind === TokenKind.COLON || kind === TokenKind.EQUALS || kind === TokenKind.AT || @@ -167,7 +168,7 @@ function readToken(lexer: Lexer, prev: Token): Token { ) { return new Token(TokenKind.SPREAD, pos, pos + 3, line, col, prev); } - break; + return readDot(source, pos, line, col, prev); case 58: // : return new Token(TokenKind.COLON, pos, pos + 1, line, col, prev); case 61: // = @@ -284,6 +285,38 @@ function unexpectedCharacterMessage(code: number): string { return `Cannot parse the unexpected character ${printCharCode(code)}.`; } +/** + * Reads a dot token with helpful messages for negative lookahead. + * + * \.(?![\.0-9]) + */ +function readDot( + source: Source, + start: number, + line: number, + col: number, + prev: Token | null, +) { + const nextCode = source.body.charCodeAt(start + 1); + if (nextCode === 46) { + // . + throw syntaxError( + source, + start, + 'Cannot parse the unexpected character "..".', + ); + } + if (nextCode >= 48 && nextCode <= 57) { + // 0 - 9 + throw syntaxError( + source, + start, + 'Invalid number, expected digit before ".".', + ); + } + return new Token(TokenKind.DOT, start, start + 1, line, col, prev); +} + /** * Reads a comment token from the source file. * diff --git a/src/language/parser.d.ts b/src/language/parser.d.ts index 443139c7926..96e0259d5e3 100644 --- a/src/language/parser.d.ts +++ b/src/language/parser.d.ts @@ -47,6 +47,7 @@ import { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast'; import { TokenKindEnum } from './tokenKind'; import { Source } from './source'; @@ -115,6 +116,22 @@ export function parseType( options?: ParseOptions, ): TypeNode; +/** + * Given a string containing a GraphQL Schema Coordinate (ex. `Type.field`), + * parse the AST for that schema coordinate. + * Throws GraphQLError if a syntax error is encountered. + * + * This is useful within tools that operate upon GraphQL Types directly and + * in isolation of complete GraphQL documents. + * + * Consider providing the results to the utility function: + * resolveSchemaCoordinate(). + */ +export function parseSchemaCoordinate( + source: string | Source, + options?: ParseOptions, +): SchemaCoordinateNode; + /** * This class is exported only to assist people in implementing their own parsers * without duplicating too much code and should be used only as last resort for cases @@ -517,10 +534,10 @@ export declare class Parser { expectToken(kind: TokenKindEnum): Token; /** - * If the next token is of the given kind, return that token after advancing the lexer. - * Otherwise, do not change the parser state and return undefined. + * If the next token is of the given kind, return "true" after advancing the lexer. + * Otherwise, do not change the parser state and return "false". */ - expectOptionalToken(kind: TokenKindEnum): Maybe; + expectOptionalToken(kind: TokenKindEnum): boolean; /** * If the next token is a given keyword, advance the lexer. diff --git a/src/language/parser.js b/src/language/parser.js index f5f0a876b1b..379b374b211 100644 --- a/src/language/parser.js +++ b/src/language/parser.js @@ -47,6 +47,7 @@ import type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast'; import { Kind } from './kinds'; import { Location } from './ast'; @@ -137,6 +138,28 @@ export function parseType( return type; } +/** + * Given a string containing a GraphQL Schema Coordinate (ex. `Type.field`), + * parse the AST for that schema coordinate. + * Throws GraphQLError if a syntax error is encountered. + * + * This is useful within tools that operate upon GraphQL Types directly and + * in isolation of complete GraphQL documents. + * + * Consider providing the results to the utility function: + * resolveSchemaCoordinate(). + */ +export function parseSchemaCoordinate( + source: string | Source, + options?: ParseOptions, +): SchemaCoordinateNode { + const parser = new Parser(source, options); + parser.expectToken(TokenKind.SOF); + const type = parser.parseSchemaCoordinate(); + parser.expectToken(TokenKind.EOF); + return type; +} + /** * This class is exported only to assist people in implementing their own parsers * without duplicating too much code and should be used only as last resort for cases @@ -1336,6 +1359,35 @@ export class Parser { throw this.unexpected(start); } + // Schema Coordinates + + parseSchemaCoordinate(): SchemaCoordinateNode { + const start = this._lexer.token; + const isDirective = this.expectOptionalToken(TokenKind.AT); + const name = this.parseName(); + let fieldName; + if (!isDirective && this.expectOptionalToken(TokenKind.DOT)) { + fieldName = this.parseName(); + } + let argumentName; + if ( + (isDirective || fieldName) && + this.expectOptionalToken(TokenKind.PAREN_L) + ) { + argumentName = this.parseName(); + this.expectToken(TokenKind.COLON); + this.expectToken(TokenKind.PAREN_R); + } + return { + kind: Kind.SCHEMA_COORDINATE, + isDirective, + name, + fieldName, + argumentName, + loc: this.loc(start), + }; + } + // Core parsing utility functions /** @@ -1377,16 +1429,16 @@ export class Parser { } /** - * If the next token is of the given kind, return that token after advancing the lexer. - * Otherwise, do not change the parser state and return undefined. + * If the next token is of the given kind, return "true" after advancing the lexer. + * Otherwise, do not change the parser state and return "false". */ - expectOptionalToken(kind: TokenKindEnum): ?Token { + expectOptionalToken(kind: TokenKindEnum): boolean { const token = this._lexer.token; if (token.kind === kind) { this._lexer.advance(); - return token; + return true; } - return undefined; + return false; } /** diff --git a/src/language/predicates.d.ts b/src/language/predicates.d.ts index cdbe1f9fd69..f704b1576d6 100644 --- a/src/language/predicates.d.ts +++ b/src/language/predicates.d.ts @@ -9,6 +9,7 @@ import { TypeDefinitionNode, TypeSystemExtensionNode, TypeExtensionNode, + SchemaCoordinateNode, } from './ast'; export function isDefinitionNode(node: ASTNode): node is DefinitionNode; @@ -34,3 +35,7 @@ export function isTypeSystemExtensionNode( ): node is TypeSystemExtensionNode; export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode; + +export function isSchemaCoordinateNode( + node: ASTNode, +): node is SchemaCoordinateNode; diff --git a/src/language/predicates.js b/src/language/predicates.js index b9108f87ade..53f80fd85a3 100644 --- a/src/language/predicates.js +++ b/src/language/predicates.js @@ -79,3 +79,7 @@ export function isTypeExtensionNode(node: ASTNode): boolean %checks { node.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION ); } + +export function isSchemaCoordinateNode(node: ASTNode): boolean %checks { + return node.kind === Kind.SCHEMA_COORDINATE; +} diff --git a/src/language/printer.js b/src/language/printer.js index 43bd627195d..5ec76ca7637 100644 --- a/src/language/printer.js +++ b/src/language/printer.js @@ -301,6 +301,18 @@ const printDocASTReducer: any = { leave: ({ name, directives, fields }) => join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + + // Schema Coordinate + + SchemaCoordinate: { + leave: ({ isDirective, name, fieldName, argumentName }) => + join([ + isDirective && '@', + name, + wrap('.', fieldName), + wrap('(', argumentName, ':)'), + ]), + }, }; /** diff --git a/src/language/tokenKind.d.ts b/src/language/tokenKind.d.ts index ecd6f201dd8..8a00376cd19 100644 --- a/src/language/tokenKind.d.ts +++ b/src/language/tokenKind.d.ts @@ -10,6 +10,7 @@ export const TokenKind: Readonly<{ AMP: '&'; PAREN_L: '('; PAREN_R: ')'; + DOT: '.'; SPREAD: '...'; COLON: ':'; EQUALS: '='; diff --git a/src/language/tokenKind.js b/src/language/tokenKind.js index b4f5248c133..77406bf9499 100644 --- a/src/language/tokenKind.js +++ b/src/language/tokenKind.js @@ -10,6 +10,7 @@ export const TokenKind = Object.freeze({ AMP: '&', PAREN_L: '(', PAREN_R: ')', + DOT: '.', SPREAD: '...', COLON: ':', EQUALS: '=', diff --git a/src/language/visitor.js b/src/language/visitor.js index 582aaf4423f..313a637f432 100644 --- a/src/language/visitor.js +++ b/src/language/visitor.js @@ -122,6 +122,8 @@ const QueryDocumentKeys = { UnionTypeExtension: ['name', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], + + SchemaCoordinate: ['name', 'fieldName', 'argumentName'], }; export const BREAK: { ... } = Object.freeze({}); diff --git a/src/type/element.d.ts b/src/type/element.d.ts new file mode 100644 index 00000000000..1fad2b741a0 --- /dev/null +++ b/src/type/element.d.ts @@ -0,0 +1,16 @@ +import { + GraphQLNamedType, + GraphQLField, + GraphQLInputField, + GraphQLEnumValue, + GraphQLArgument, +} from './definition'; +import { GraphQLDirective } from './directives'; + +export type GraphQLSchemaElement = + | GraphQLNamedType + | GraphQLField + | GraphQLInputField + | GraphQLEnumValue + | GraphQLArgument + | GraphQLDirective; diff --git a/src/type/element.js b/src/type/element.js new file mode 100644 index 00000000000..d5a2b4a5eae --- /dev/null +++ b/src/type/element.js @@ -0,0 +1,16 @@ +import type { + GraphQLNamedType, + GraphQLField, + GraphQLInputField, + GraphQLEnumValue, + GraphQLArgument, +} from './definition'; +import type { GraphQLDirective } from './directives'; + +export type GraphQLSchemaElement = + | GraphQLNamedType + | GraphQLField + | GraphQLInputField + | GraphQLEnumValue + | GraphQLArgument + | GraphQLDirective; diff --git a/src/type/index.d.ts b/src/type/index.d.ts index f226df56518..cf26feee90d 100644 --- a/src/type/index.d.ts +++ b/src/type/index.d.ts @@ -167,3 +167,6 @@ export { } from './introspection'; export { validateSchema, assertValidSchema } from './validate'; + +// Schema Element type. +export { GraphQLSchemaElement } from './element'; diff --git a/src/type/index.js b/src/type/index.js index 6bcef9daede..68aac92d240 100644 --- a/src/type/index.js +++ b/src/type/index.js @@ -161,3 +161,6 @@ export type { // Validate GraphQL schema. export { validateSchema, assertValidSchema } from './validate'; + +// Schema Element type. +export type { GraphQLSchemaElement } from './element'; diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.js b/src/utilities/__tests__/resolveSchemaCoordinate-test.js new file mode 100644 index 00000000000..71c625c01c9 --- /dev/null +++ b/src/utilities/__tests__/resolveSchemaCoordinate-test.js @@ -0,0 +1,147 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { buildSchema } from '../buildASTSchema'; +import { resolveSchemaCoordinate } from '../resolveSchemaCoordinate'; + +describe('resolveSchemaCoordinate', () => { + const schema: any = buildSchema(` + type Query { + searchBusiness(criteria: SearchCriteria!): [Business] + } + + input SearchCriteria { + name: String + filter: SearchFilter + } + + enum SearchFilter { + OPEN_NOW + DELIVERS_TAKEOUT + VEGETARIAN_MENU + } + + type Business { + id: ID + name: String + email: String @private(scope: "loggedIn") + } + + directive @private(scope: String!) on FIELD_DEFINITION + `); + + it('resolves a Named Type', () => { + const expected = schema.getType('Business'); + expect(expected).not.to.equal(undefined); + expect(resolveSchemaCoordinate(schema, 'Business')).to.equal(expected); + + expect(resolveSchemaCoordinate(schema, 'String')).to.equal( + schema.getType('String'), + ); + + expect(resolveSchemaCoordinate(schema, 'private')).to.equal(undefined); + + expect(resolveSchemaCoordinate(schema, 'Unknown')).to.equal(undefined); + }); + + it('resolves a Type Field', () => { + const expected = schema.getType('Business').getFields().name; + expect(expected).not.to.equal(undefined); + expect(resolveSchemaCoordinate(schema, 'Business.name')).to.equal(expected); + + expect(resolveSchemaCoordinate(schema, 'Business.unknown')).to.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, 'Unknown.field')).to.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, 'String.field')).to.equal(undefined); + }); + + it('does not resolve meta-fields', () => { + expect(resolveSchemaCoordinate(schema, 'Business.__typename')).to.equal( + undefined, + ); + }); + + it('resolves a Input Field', () => { + const expected = schema.getType('SearchCriteria').getFields().filter; + expect(expected).not.to.equal(undefined); + expect(resolveSchemaCoordinate(schema, 'SearchCriteria.filter')).to.equal( + expected, + ); + + expect(resolveSchemaCoordinate(schema, 'SearchCriteria.unknown')).to.equal( + undefined, + ); + }); + + it('resolves a Enum Value', () => { + const expected = schema.getType('SearchFilter').getValue('OPEN_NOW'); + expect(expected).not.to.equal(undefined); + expect(resolveSchemaCoordinate(schema, 'SearchFilter.OPEN_NOW')).to.equal( + expected, + ); + + expect(resolveSchemaCoordinate(schema, 'SearchFilter.UNKNOWN')).to.equal( + undefined, + ); + }); + + it('resolves a Field Argument', () => { + const expected = schema + .getType('Query') + .getFields() + .searchBusiness.args.find((arg) => arg.name === 'criteria'); + expect(expected).not.to.equal(undefined); + expect( + resolveSchemaCoordinate(schema, 'Query.searchBusiness(criteria:)'), + ).to.equal(expected); + + expect(resolveSchemaCoordinate(schema, 'Business.name(unknown:)')).to.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, 'Unknown.field(arg:)')).to.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, 'Business.unknown(arg:)')).to.equal( + undefined, + ); + + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.name(arg:)'), + ).to.equal(undefined); + }); + + it('resolves a Directive', () => { + const expected = schema.getDirective('private'); + expect(expected).not.to.equal(undefined); + expect(resolveSchemaCoordinate(schema, '@private')).to.equal(expected); + + expect(resolveSchemaCoordinate(schema, '@unknown')).to.equal(undefined); + + expect(resolveSchemaCoordinate(schema, '@Business')).to.equal(undefined); + }); + + it('resolves a Directive Argument', () => { + const expected = schema + .getDirective('private') + .args.find((arg) => arg.name === 'scope'); + expect(expected).not.to.equal(undefined); + expect(resolveSchemaCoordinate(schema, '@private(scope:)')).to.equal( + expected, + ); + + expect(resolveSchemaCoordinate(schema, '@private(unknown:)')).to.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, '@unknown(arg:)')).to.equal( + undefined, + ); + }); +}); diff --git a/src/utilities/index.d.ts b/src/utilities/index.d.ts index a690e640e95..c90eed997c7 100644 --- a/src/utilities/index.d.ts +++ b/src/utilities/index.d.ts @@ -109,3 +109,9 @@ export { // Wrapper type that contains DocumentNode and types that can be deduced from it. export { TypedQueryDocumentNode } from './typedQueryDocumentNode'; + +// Schema coordinates +export { + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, +} from './resolveSchemaCoordinate'; diff --git a/src/utilities/index.js b/src/utilities/index.js index b4c8372f04e..2e9da2d63ec 100644 --- a/src/utilities/index.js +++ b/src/utilities/index.js @@ -103,3 +103,9 @@ export { findDangerousChanges, } from './findBreakingChanges'; export type { BreakingChange, DangerousChange } from './findBreakingChanges'; + +// Schema coordinates +export { + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, +} from './resolveSchemaCoordinate'; diff --git a/src/utilities/resolveSchemaCoordinate.d.ts b/src/utilities/resolveSchemaCoordinate.d.ts new file mode 100644 index 00000000000..4825ddaf475 --- /dev/null +++ b/src/utilities/resolveSchemaCoordinate.d.ts @@ -0,0 +1,22 @@ +import { GraphQLSchema } from '../type/schema'; +import { GraphQLSchemaElement } from '../type/element'; +import { SchemaCoordinateNode } from '../language/ast'; +import { Source } from '../language/source'; + +/** + * A schema coordinate is resolved in the context of a GraphQL schema to + * uniquely identifies a schema element. It returns undefined if the schema + * coordinate does not resolve to a schema element. + */ +export function resolveSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: string | Source, +): GraphQLSchemaElement | undefined; + +/** + * Resolves schema coordinate from a parsed SchemaCoordinate node. + */ +export function resolveASTSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: SchemaCoordinateNode, +): GraphQLSchemaElement | undefined; diff --git a/src/utilities/resolveSchemaCoordinate.js b/src/utilities/resolveSchemaCoordinate.js new file mode 100644 index 00000000000..82fde2ce5a3 --- /dev/null +++ b/src/utilities/resolveSchemaCoordinate.js @@ -0,0 +1,109 @@ +import type { GraphQLSchema } from '../type/schema'; +import type { GraphQLSchemaElement } from '../type/element'; +import type { SchemaCoordinateNode } from '../language/ast'; +import type { Source } from '../language/source'; +import { + isObjectType, + isInterfaceType, + isEnumType, + isInputObjectType, +} from '../type/definition'; +import { parseSchemaCoordinate } from '../language/parser'; + +/** + * A schema coordinate is resolved in the context of a GraphQL schema to + * uniquely identifies a schema element. It returns undefined if the schema + * coordinate does not resolve to a schema element. + */ +export function resolveSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: string | Source, +): GraphQLSchemaElement | void { + return resolveASTSchemaCoordinate( + schema, + parseSchemaCoordinate(schemaCoordinate), + ); +} + +/** + * Resolves schema coordinate from a parsed SchemaCoordinate node. + */ +export function resolveASTSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: SchemaCoordinateNode, +): GraphQLSchemaElement | void { + const { isDirective, name, fieldName, argumentName } = schemaCoordinate; + if (isDirective) { + // SchemaCoordinate : + // - @ Name + // - @ Name ( Name : ) + // Let {directiveName} be the value of the first {Name}. + // Let {directive} be the directive in the {schema} named {directiveName}. + const directive = schema.getDirective(name.value); + if (!argumentName) { + // SchemaCoordinate : @ Name + // Return the directive in the {schema} named {directiveName}. + return directive || undefined; + } + + // SchemaCoordinate : @ Name ( Name : ) + // Assert {directive} must exist. + if (!directive) { + return; + } + return directive.args.find((arg) => arg.name === argumentName.value); + } + + // SchemaCoordinate : + // - Name + // - Name . Name + // - Name . Name ( Name : ) + // Let {typeName} be the value of the first {Name}. + // Let {type} be the type in the {schema} named {typeName}. + const type = schema.getType(name.value); + if (!fieldName) { + // SchemaCoordinate : Name + // Return the type in the {schema} named {typeName}. + return type || undefined; + } + + if (!argumentName) { + // SchemaCoordinate : Name . Name + // If {type} is an Enum type: + if (isEnumType(type)) { + // Let {enumValueName} be the value of the second {Name}. + // Return the enum value of {type} named {enumValueName}. + return type.getValue(fieldName.value) || undefined; + } + // Otherwise if {type} is an Input Object type: + if (isInputObjectType(type)) { + // Let {inputFieldName} be the value of the second {Name}. + // Return the input field of {type} named {inputFieldName}. + return type.getFields()[fieldName.value]; + } + // Otherwise: + // Assert {type} must be an Object or Interface type. + if (!isObjectType(type) && !isInterfaceType(type)) { + return; + } + // Let {fieldName} be the value of the second {Name}. + // Return the field of {type} named {fieldName}. + return type.getFields()[fieldName.value]; + } + + // SchemaCoordinate : Name . Name ( Name : ) + // Assert {type} must be an Object or Interface type. + if (!isObjectType(type) && !isInterfaceType(type)) { + return; + } + // Let {fieldName} be the value of the second {Name}. + // Let {field} be the field of {type} named {fieldName}. + const field = type.getFields()[fieldName.value]; + // Assert {field} must exist. + if (!field) { + return; + } + // Let {argumentName} be the value of the third {Name}. + // Return the argument of {field} named {argumentName}. + return field.args.find((arg) => arg.name === argumentName.value); +}