From e688f88752b3899990b5b413dd4ffdaf21400a31 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 `resolveASTSchemaCoordinate()` which implement the semantics (name mirrored from `buildASTSchema`) as well as the return type `ResolvedSchemaElement` --- src/index.ts | 6 + src/language/__tests__/lexer-test.ts | 12 +- src/language/__tests__/parser-test.ts | 133 +++++++++++- src/language/__tests__/predicates-test.ts | 7 + src/language/__tests__/printer-test.ts | 16 +- src/language/ast.ts | 15 +- src/language/index.ts | 10 +- src/language/kinds.ts | 3 + src/language/lexer.ts | 39 +++- src/language/parser.ts | 57 ++++++ src/language/predicates.ts | 7 + src/language/printer.ts | 12 ++ src/language/tokenKind.ts | 1 + src/language/visitor.ts | 2 + .../__tests__/resolveSchemaCoordinate-test.ts | 185 +++++++++++++++++ src/utilities/index.ts | 7 + src/utilities/resolveSchemaCoordinate.ts | 189 ++++++++++++++++++ 17 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 src/utilities/__tests__/resolveSchemaCoordinate-test.ts create mode 100644 src/utilities/resolveSchemaCoordinate.ts diff --git a/src/index.ts b/src/index.ts index d9d02c9245..859bfe5744 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,6 +201,7 @@ export { parseValue, parseConstValue, parseType, + parseSchemaCoordinate, /** Print */ print, /** Visit */ @@ -221,6 +222,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './language/index'; export type { @@ -295,6 +297,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './language/index'; /** Execute GraphQL queries. */ @@ -436,6 +439,8 @@ export { DangerousChangeType, findBreakingChanges, findDangerousChanges, + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, } from './utilities/index'; export type { @@ -465,4 +470,5 @@ export type { BreakingChange, DangerousChange, TypedQueryDocumentNode, + ResolvedSchemaElement, } from './utilities/index'; diff --git a/src/language/__tests__/lexer-test.ts b/src/language/__tests__/lexer-test.ts index 053c329709..18d7da98f9 100644 --- a/src/language/__tests__/lexer-test.ts +++ b/src/language/__tests__/lexer-test.ts @@ -657,7 +657,8 @@ describe('Lexer', () => { }); expectSyntaxError('.123').to.deep.equal({ - message: 'Syntax Error: Unexpected character: ".".', + message: + 'Syntax Error: Invalid number, expected digit before ".", did you mean "0.123"?', locations: [{ line: 1, column: 1 }], }); @@ -762,6 +763,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, @@ -828,7 +836,7 @@ describe('Lexer', () => { it('lex reports useful unknown character error', () => { expectSyntaxError('..').to.deep.equal({ - message: 'Syntax Error: Unexpected character: ".".', + message: 'Syntax Error: Unexpected "..", did you mean "..."?', locations: [{ line: 1, column: 1 }], }); diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index d042bec291..a9b1815f8d 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -9,7 +9,13 @@ import { inspect } from '../../jsutils/inspect'; import { Kind } from '../kinds'; import { Source } from '../source'; import { TokenKind } from '../tokenKind'; -import { parse, parseValue, parseConstValue, parseType } from '../parser'; +import { + parse, + parseValue, + parseConstValue, + parseType, + parseSchemaCoordinate, +} from '../parser'; import { toJSONDeep } from './toJSONDeep'; @@ -619,4 +625,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', + }, + memberName: 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', + }, + memberName: { + 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', + }, + memberName: { + 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', + }, + memberName: 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', + }, + memberName: 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.ts b/src/language/__tests__/predicates-test.ts index b90e2b31e9..978bfbedcb 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -15,6 +15,7 @@ import { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from '../predicates'; function filterNodes(predicate: (node: ASTNode) => boolean): Array { @@ -141,4 +142,10 @@ describe('AST node predicates', () => { 'InputObjectTypeExtension', ]); }); + + it('isSchemaCoordinateNode', () => { + expect(filterNodes(isSchemaCoordinateNode)).to.deep.equal([ + 'SchemaCoordinate', + ]); + }); }); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index cfa1e14052..3abd84e574 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -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', () => { @@ -216,4 +216,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.ts b/src/language/ast.ts index 62ddf24c6b..58a7016728 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -176,7 +176,8 @@ export type ASTNode = | InterfaceTypeExtensionNode | UnionTypeExtensionNode | EnumTypeExtensionNode - | InputObjectTypeExtensionNode; + | InputObjectTypeExtensionNode + | SchemaCoordinateNode; /** * Utility type listing all nodes indexed by their kind. @@ -225,6 +226,7 @@ export interface ASTKindToNode { UnionTypeExtension: UnionTypeExtensionNode; EnumTypeExtension: EnumTypeExtensionNode; InputObjectTypeExtension: InputObjectTypeExtensionNode; + SchemaCoordinate: SchemaCoordinateNode; } /** Name */ @@ -670,3 +672,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 memberName?: NameNode; + readonly argumentName?: NameNode; +} diff --git a/src/language/index.ts b/src/language/index.ts index dfe4e53584..b53f89e26a 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -13,7 +13,13 @@ export type { TokenKindEnum } from './tokenKind'; export { Lexer } from './lexer'; -export { parse, parseValue, parseConstValue, parseType } from './parser'; +export { + parse, + parseValue, + parseConstValue, + parseType, + parseSchemaCoordinate, +} from './parser'; export type { ParseOptions } from './parser'; export { print } from './printer'; @@ -85,6 +91,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast'; export { @@ -98,6 +105,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './predicates'; export { DirectiveLocation } from './directiveLocation'; diff --git a/src/language/kinds.ts b/src/language/kinds.ts index b5c0058827..fe1063abb3 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -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', } as const); /** diff --git a/src/language/lexer.ts b/src/language/lexer.ts index e7b7b8e733..b67c8e00f5 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -91,6 +91,7 @@ export function isPunctuatorTokenKind(kind: TokenKindEnum): boolean { kind === TokenKind.AMP || kind === TokenKind.PAREN_L || kind === TokenKind.PAREN_R || + kind === TokenKind.DOT || kind === TokenKind.SPREAD || kind === TokenKind.COLON || kind === TokenKind.EQUALS || @@ -219,7 +220,11 @@ function readNextToken(lexer: Lexer, prev: Token): Token { // - FloatValue // - StringValue // - // Punctuator :: one of ! $ & ( ) ... : = @ [ ] { | } + // Punctuator :: + // - DotPunctuator + // - OtherPunctuator + // + // OtherPunctuator :: one of ! $ & ( ) ... : = @ [ ] { | } case 0x0021: // ! return createToken(lexer, TokenKind.BANG, position, position + 1); case 0x0024: // $ @@ -237,7 +242,7 @@ function readNextToken(lexer: Lexer, prev: Token): Token { ) { return createToken(lexer, TokenKind.SPREAD, position, position + 3); } - break; + return readDot(lexer, position); case 0x003a: // : return createToken(lexer, TokenKind.COLON, position, position + 1); case 0x003d: // = @@ -290,6 +295,36 @@ function readNextToken(lexer: Lexer, prev: Token): Token { return createToken(lexer, TokenKind.EOF, bodyLength, bodyLength); } +/** + * Reads a dot token with helpful messages for negative lookahead. + * + * DotPunctuator :: `.` [lookahead != {`.`, Digit}] + */ +function readDot(lexer: Lexer, start: number): Token { + const nextCode = lexer.source.body.charCodeAt(start + 1); + // . + if (nextCode === 0x002e) { + throw syntaxError( + lexer.source, + start, + 'Unexpected "..", did you mean "..."?', + ); + } + // Digit (0-9) + if (nextCode >= 0x0030 && nextCode <= 0x0039) { + const digits = lexer.source.body.slice( + start + 1, + readDigits(lexer, start + 1, nextCode), + ); + throw syntaxError( + lexer.source, + start, + `Invalid number, expected digit before ".", did you mean "0.${digits}"?`, + ); + } + return createToken(lexer, TokenKind.DOT, start, start + 1); +} + /** * Reads a comment token from the source file. * diff --git a/src/language/parser.ts b/src/language/parser.ts index c8822a036f..37904b5d2e 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -62,6 +62,7 @@ import type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast'; import { Kind } from './kinds'; import { Location } from './ast'; @@ -167,6 +168,26 @@ 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. + * + * Consider providing the results to the utility function: + * resolveASTSchemaCoordinate(). Or calling resolveSchemaCoordinate() directly + * with an unparsed source. + */ +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 @@ -1351,6 +1372,42 @@ export class Parser { throw this.unexpected(start); } + // Schema Coordinates + + /** + * SchemaCoordinate : + * - Name + * - Name . Name + * - Name . Name ( Name : ) + * - @ Name + * - @ Name ( Name : ) + */ + parseSchemaCoordinate(): SchemaCoordinateNode { + const start = this._lexer.token; + const isDirective = this.expectOptionalToken(TokenKind.AT); + const name = this.parseName(); + let memberName; + if (!isDirective && this.expectOptionalToken(TokenKind.DOT)) { + memberName = this.parseName(); + } + let argumentName; + if ( + (isDirective || memberName) && + this.expectOptionalToken(TokenKind.PAREN_L) + ) { + argumentName = this.parseName(); + this.expectToken(TokenKind.COLON); + this.expectToken(TokenKind.PAREN_R); + } + return this.node(start, { + kind: Kind.SCHEMA_COORDINATE, + isDirective, + name, + memberName, + argumentName, + }); + } + // Core parsing utility functions /** diff --git a/src/language/predicates.ts b/src/language/predicates.ts index 29e4984d5e..1a1c9b8781 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -10,6 +10,7 @@ import type { TypeDefinitionNode, TypeSystemExtensionNode, TypeExtensionNode, + SchemaCoordinateNode, } from './ast'; import { Kind } from './kinds'; @@ -110,3 +111,9 @@ export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode { node.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION ); } + +export function isSchemaCoordinateNode( + node: ASTNode, +): node is SchemaCoordinateNode { + return node.kind === Kind.SCHEMA_COORDINATE; +} diff --git a/src/language/printer.ts b/src/language/printer.ts index 0d907fca39..02277e98d2 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -302,6 +302,18 @@ const printDocASTReducer: ASTReducer = { leave: ({ name, directives, fields }) => join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + + // Schema Coordinate + + SchemaCoordinate: { + leave: ({ isDirective, name, memberName, argumentName }) => + join([ + isDirective && '@', + name, + wrap('.', memberName), + wrap('(', argumentName, ':)'), + ]), + }, }; /** diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index 10e1e66a80..55097dd053 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -10,6 +10,7 @@ export const TokenKind = Object.freeze({ AMP: '&', PAREN_L: '(', PAREN_R: ')', + DOT: '.', SPREAD: '...', COLON: ':', EQUALS: '=', diff --git a/src/language/visitor.ts b/src/language/visitor.ts index c6ffa4c70b..723515f5b3 100644 --- a/src/language/visitor.ts +++ b/src/language/visitor.ts @@ -160,6 +160,8 @@ const QueryDocumentKeys = { UnionTypeExtension: ['name', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], + + SchemaCoordinate: ['name', 'memberName', 'argumentName'], }; export const BREAK: unknown = Object.freeze({}); diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts new file mode 100644 index 0000000000..bf7eb0af06 --- /dev/null +++ b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts @@ -0,0 +1,185 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLObjectType, +} from '../../type/definition'; +import type { GraphQLDirective } from '../../type/directives'; + +import { buildSchema } from '../buildASTSchema'; +import { resolveSchemaCoordinate } from '../resolveSchemaCoordinate'; + +describe('resolveSchemaCoordinate', () => { + const schema = 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', () => { + expect(resolveSchemaCoordinate(schema, 'Business')).to.deep.equal({ + kind: 'NamedType', + type: schema.getType('Business'), + }); + + expect(resolveSchemaCoordinate(schema, 'String')).to.deep.equal({ + kind: 'NamedType', + type: schema.getType('String'), + }); + + expect(resolveSchemaCoordinate(schema, 'private')).to.deep.equal(undefined); + + expect(resolveSchemaCoordinate(schema, 'Unknown')).to.deep.equal(undefined); + }); + + it('resolves a Type Field', () => { + const type = schema.getType('Business') as GraphQLObjectType; + const field = type.getFields().name; + expect(resolveSchemaCoordinate(schema, 'Business.name')).to.deep.equal({ + kind: 'Field', + type, + field, + }); + + expect(resolveSchemaCoordinate(schema, 'Business.unknown')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, 'Unknown.field')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, 'String.field')).to.deep.equal( + undefined, + ); + }); + + it('does not resolve meta-fields', () => { + expect( + resolveSchemaCoordinate(schema, 'Business.__typename'), + ).to.deep.equal(undefined); + }); + + it('resolves a Input Field', () => { + const type = schema.getType('SearchCriteria') as GraphQLInputObjectType; + const inputField = type.getFields().filter; + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.filter'), + ).to.deep.equal({ + kind: 'InputField', + type, + inputField, + }); + + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.unknown'), + ).to.deep.equal(undefined); + }); + + it('resolves a Enum Value', () => { + const type = schema.getType('SearchFilter') as GraphQLEnumType; + const enumValue = type.getValue('OPEN_NOW'); + expect( + resolveSchemaCoordinate(schema, 'SearchFilter.OPEN_NOW'), + ).to.deep.equal({ + kind: 'EnumValue', + type, + enumValue, + }); + + expect( + resolveSchemaCoordinate(schema, 'SearchFilter.UNKNOWN'), + ).to.deep.equal(undefined); + }); + + it('resolves a Field Argument', () => { + const type = schema.getType('Query') as GraphQLObjectType; + const field = type.getFields().searchBusiness; + const fieldArgument = field.args.find((arg) => arg.name === 'criteria'); + expect( + resolveSchemaCoordinate(schema, 'Query.searchBusiness(criteria:)'), + ).to.deep.equal({ + kind: 'FieldArgument', + type, + field, + fieldArgument, + }); + + expect( + resolveSchemaCoordinate(schema, 'Business.name(unknown:)'), + ).to.deep.equal(undefined); + + expect( + resolveSchemaCoordinate(schema, 'Unknown.field(arg:)'), + ).to.deep.equal(undefined); + + expect( + resolveSchemaCoordinate(schema, 'Business.unknown(arg:)'), + ).to.deep.equal(undefined); + + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.name(arg:)'), + ).to.deep.equal(undefined); + }); + + it('resolves a Directive', () => { + expect(resolveSchemaCoordinate(schema, '@private')).to.deep.equal({ + kind: 'Directive', + directive: schema.getDirective('private'), + }); + + expect(resolveSchemaCoordinate(schema, '@deprecated')).to.deep.equal({ + kind: 'Directive', + directive: schema.getDirective('deprecated'), + }); + + expect(resolveSchemaCoordinate(schema, '@unknown')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, '@Business')).to.deep.equal( + undefined, + ); + }); + + it('resolves a Directive Argument', () => { + const directive = schema.getDirective('private') as GraphQLDirective; + const directiveArgument = directive.args.find( + (arg) => arg.name === 'scope', + ); + expect(resolveSchemaCoordinate(schema, '@private(scope:)')).to.deep.equal({ + kind: 'DirectiveArgument', + directive, + directiveArgument, + }); + + expect(resolveSchemaCoordinate(schema, '@private(unknown:)')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, '@unknown(arg:)')).to.deep.equal( + undefined, + ); + }); +}); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 982779c773..e9197f946b 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -106,3 +106,10 @@ export type { BreakingChange, DangerousChange } from './findBreakingChanges'; /** Wrapper type that contains DocumentNode and types that can be deduced from it. */ export { TypedQueryDocumentNode } from './typedQueryDocumentNode'; + +/** Schema coordinates */ +export { + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, +} from './resolveSchemaCoordinate'; +export type { ResolvedSchemaElement } from './resolveSchemaCoordinate'; diff --git a/src/utilities/resolveSchemaCoordinate.ts b/src/utilities/resolveSchemaCoordinate.ts new file mode 100644 index 0000000000..8b7d2992ba --- /dev/null +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -0,0 +1,189 @@ +import type { GraphQLSchema } from '../type/schema'; +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'; +import type { + GraphQLNamedType, + GraphQLField, + GraphQLInputField, + GraphQLEnumValue, + GraphQLArgument, +} from '../type/definition'; +import type { GraphQLDirective } from '../type/directives'; + +/** + * A resolved schema element may be one of the following kinds: + */ +export type ResolvedSchemaElement = + | { + readonly kind: 'NamedType'; + readonly type: GraphQLNamedType; + } + | { + readonly kind: 'Field'; + readonly type: GraphQLNamedType; + readonly field: GraphQLField; + } + | { + readonly kind: 'InputField'; + readonly type: GraphQLNamedType; + readonly inputField: GraphQLInputField; + } + | { + readonly kind: 'EnumValue'; + readonly type: GraphQLNamedType; + readonly enumValue: GraphQLEnumValue; + } + | { + readonly kind: 'FieldArgument'; + readonly type: GraphQLNamedType; + readonly field: GraphQLField; + readonly fieldArgument: GraphQLArgument; + } + | { + readonly kind: 'Directive'; + readonly directive: GraphQLDirective; + } + | { + readonly kind: 'DirectiveArgument'; + readonly directive: GraphQLDirective; + readonly directiveArgument: GraphQLArgument; + }; + +/** + * 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. + * + * https://spec.graphql.org/draft/#sec-Schema-Coordinates.Semantics + */ +export function resolveSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: string | Source, +): ResolvedSchemaElement | undefined { + return resolveASTSchemaCoordinate( + schema, + parseSchemaCoordinate(schemaCoordinate), + ); +} + +/** + * Resolves schema coordinate from a parsed SchemaCoordinate node. + */ +export function resolveASTSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: SchemaCoordinateNode, +): ResolvedSchemaElement | undefined { + const { isDirective, name, memberName, 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}. + if (!directive) { + return; + } + return { kind: 'Directive', directive }; + } + + // SchemaCoordinate : @ Name ( Name : ) + // Assert {directive} must exist. + if (!directive) { + return; + } + // Let {directiveArgumentName} be the value of the second {Name}. + // Return the argument of {directive} named {directiveArgumentName}. + const directiveArgument = directive.args.find( + (arg) => arg.name === argumentName.value, + ); + if (!directiveArgument) { + return; + } + return { kind: 'DirectiveArgument', directive, directiveArgument }; + } + + // 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 (!memberName) { + // SchemaCoordinate : Name + // Return the type in the {schema} named {typeName}. + if (!type) { + return; + } + return { kind: 'NamedType', type }; + } + + 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}. + const enumValue = type.getValue(memberName.value); + if (!enumValue) { + return; + } + return { kind: 'EnumValue', type, enumValue }; + } + // 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}. + const inputField = type.getFields()[memberName.value]; + if (!inputField) { + return; + } + return { kind: 'InputField', type, inputField }; + } + // 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}. + const field = type.getFields()[memberName.value]; + if (!field) { + return; + } + return { kind: 'Field', type, field }; + } + + // 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()[memberName.value]; + // Assert {field} must exist. + if (!field) { + return; + } + // Let {fieldArgumentName} be the value of the third {Name}. + // Return the argument of {field} named {fieldArgumentName}. + const fieldArgument = field.args.find( + (arg) => arg.name === argumentName.value, + ); + if (!fieldArgument) { + return; + } + return { kind: 'FieldArgument', type, field, fieldArgument }; +}