From 2c3466e379298f5f5c47482d1e475ad5d780252b 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 | 14 +- 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 | 14 ++ src/language/index.ts | 10 +- src/language/kinds.ts | 3 + src/language/lexer.ts | 57 ++++-- src/language/parser.ts | 59 ++++++ src/language/predicates.ts | 7 + src/language/printer.ts | 12 ++ src/language/tokenKind.ts | 1 + .../__tests__/resolveSchemaCoordinate-test.ts | 185 +++++++++++++++++ src/utilities/index.ts | 7 + src/utilities/resolveSchemaCoordinate.ts | 190 ++++++++++++++++++ 16 files changed, 692 insertions(+), 29 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 3d82bffdda..e3fb51aac3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -219,6 +219,7 @@ export { parseValue, parseConstValue, parseType, + parseSchemaCoordinate, // Print print, // Visit @@ -240,6 +241,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './language/index.js'; export type { @@ -316,6 +318,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './language/index.js'; // Execute GraphQL queries. @@ -465,6 +468,8 @@ export { DangerousChangeType, findBreakingChanges, findDangerousChanges, + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, } from './utilities/index.js'; export type { @@ -494,4 +499,5 @@ export type { BreakingChange, DangerousChange, TypedQueryDocumentNode, + ResolvedSchemaElement, } from './utilities/index.js'; diff --git a/src/language/__tests__/lexer-test.ts b/src/language/__tests__/lexer-test.ts index f781e57207..e3e46b1295 100644 --- a/src/language/__tests__/lexer-test.ts +++ b/src/language/__tests__/lexer-test.ts @@ -165,13 +165,6 @@ describe('Lexer', () => { }); }); - it('reports unexpected characters', () => { - expectSyntaxError('.').to.deep.equal({ - message: 'Syntax Error: Unexpected character: ".".', - locations: [{ line: 1, column: 1 }], - }); - }); - it('errors respect whitespace', () => { let caughtError; try { @@ -972,6 +965,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, diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index 215e743be7..2dfb1a027d 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -11,7 +11,13 @@ import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js'; import { inspect } from '../../jsutils/inspect.js'; import { Kind } from '../kinds.js'; -import { parse, parseConstValue, parseType, parseValue } from '../parser.js'; +import { + parse, + parseConstValue, + parseSchemaCoordinate, + parseType, + parseValue, +} from '../parser.js'; import { Source } from '../source.js'; import { TokenKind } from '../tokenKind.js'; @@ -883,4 +889,129 @@ describe('Parser', () => { }); }); }); + + describe('parseSchemaCoordinate', () => { + it('parses Name', () => { + const result = parseSchemaCoordinate('MyType'); + expectJSON(result).toDeepEqual({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 6 }, + ofDirective: 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'); + expectJSON(result).toDeepEqual({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 12 }, + ofDirective: 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', () => { + expectToThrowJSON(() => + parseSchemaCoordinate('MyType.field.deep'), + ).to.deep.equal({ + message: 'Syntax Error: Expected , found ".".', + locations: [{ line: 1, column: 13 }], + }); + }); + + it('parses Name . Name ( Name : )', () => { + const result = parseSchemaCoordinate('MyType.field(arg:)'); + expectJSON(result).toDeepEqual({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 18 }, + ofDirective: 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 )', () => { + expectToThrowJSON(() => + parseSchemaCoordinate('MyType.field(arg: value)'), + ).to.deep.equal({ + message: 'Syntax Error: Expected ")", found Name "value".', + locations: [{ line: 1, column: 19 }], + }); + }); + + it('parses @ Name', () => { + const result = parseSchemaCoordinate('@myDirective'); + expectJSON(result).toDeepEqual({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 12 }, + ofDirective: 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:)'); + expectJSON(result).toDeepEqual({ + kind: Kind.SCHEMA_COORDINATE, + loc: { start: 0, end: 18 }, + ofDirective: 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', () => { + expectToThrowJSON(() => + parseSchemaCoordinate('@myDirective.field'), + ).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 55e5782648..63f688f52d 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -9,6 +9,7 @@ import { isDefinitionNode, isExecutableDefinitionNode, isNullabilityAssertionNode, + isSchemaCoordinateNode, isSelectionNode, isTypeDefinitionNode, isTypeExtensionNode, @@ -150,4 +151,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 8669b64ec7..53b33d4f3d 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -5,7 +5,7 @@ import { dedent, dedentString } from '../../__testUtils__/dedent.js'; import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js'; import { Kind } from '../kinds.js'; -import { parse } from '../parser.js'; +import { parse, parseSchemaCoordinate } from '../parser.js'; import { print } from '../printer.js'; describe('Printer: Query document', () => { @@ -318,4 +318,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 dd0d429329..e64c7621a1 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -182,6 +182,7 @@ export type ASTNode = | UnionTypeExtensionNode | EnumTypeExtensionNode | InputObjectTypeExtensionNode + | SchemaCoordinateNode | NonNullAssertionNode | ErrorBoundaryNode | ListNullabilityOperatorNode; @@ -297,6 +298,8 @@ export const QueryDocumentKeys: { UnionTypeExtension: ['name', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], + + SchemaCoordinate: ['name', 'memberName', 'argumentName'], }; const kindValues = new Set(Object.keys(QueryDocumentKeys)); @@ -795,3 +798,14 @@ export interface InputObjectTypeExtensionNode { readonly directives?: ReadonlyArray | undefined; readonly fields?: ReadonlyArray | undefined; } + +/** Schema Coordinates */ + +export interface SchemaCoordinateNode { + readonly kind: Kind.SCHEMA_COORDINATE; + readonly loc?: Location | undefined; + readonly ofDirective: boolean; + readonly name: NameNode; + readonly memberName?: NameNode | undefined; + readonly argumentName?: NameNode | undefined; +} diff --git a/src/language/index.ts b/src/language/index.ts index 2899f47c64..baeddc5763 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -11,7 +11,13 @@ export { TokenKind } from './tokenKind.js'; export { Lexer } from './lexer.js'; -export { parse, parseValue, parseConstValue, parseType } from './parser.js'; +export { + parse, + parseValue, + parseConstValue, + parseType, + parseSchemaCoordinate, +} from './parser.js'; export type { ParseOptions } from './parser.js'; export { print } from './printer.js'; @@ -92,6 +98,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast.js'; export { @@ -106,6 +113,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './predicates.js'; export { DirectiveLocation } from './directiveLocation.js'; diff --git a/src/language/kinds.ts b/src/language/kinds.ts index edf55ac26e..609a7bc222 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -72,6 +72,9 @@ enum Kind { UNION_TYPE_EXTENSION = 'UnionTypeExtension', ENUM_TYPE_EXTENSION = 'EnumTypeExtension', INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension', + + /** Schema Coordinates */ + SCHEMA_COORDINATE = 'SchemaCoordinate', } export { Kind }; diff --git a/src/language/lexer.ts b/src/language/lexer.ts index 6b6a5e90dc..ac71063d2f 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -96,6 +96,7 @@ export function isPunctuatorTokenKind(kind: TokenKind): boolean { kind === TokenKind.AMP || kind === TokenKind.PAREN_L || kind === TokenKind.PAREN_R || + kind === TokenKind.DOT || kind === TokenKind.SPREAD || kind === TokenKind.COLON || kind === TokenKind.EQUALS || @@ -247,7 +248,11 @@ function readNextToken(lexer: Lexer, start: number): Token { // - FloatValue // - StringValue // - // Punctuator :: one of ! $ & ( ) ... : = @ [ ] { | } + // Punctuator :: + // - DotPunctuator + // - OtherPunctuator + // + // OtherPunctuator :: one of ! $ & ( ) ... : = @ [ ] { | } case 0x0021: // ! return createToken(lexer, TokenKind.BANG, position, position + 1); case 0x0024: // $ @@ -264,24 +269,7 @@ function readNextToken(lexer: Lexer, start: number): Token { if (nextCode === 0x002e && body.charCodeAt(position + 2) === 0x002e) { return createToken(lexer, TokenKind.SPREAD, position, position + 3); } - if (nextCode === 0x002e) { - throw syntaxError( - lexer.source, - position, - 'Unexpected "..", did you mean "..."?', - ); - } else if (isDigit(nextCode)) { - const digits = lexer.source.body.slice( - position + 1, - readDigits(lexer, position + 1, nextCode), - ); - throw syntaxError( - lexer.source, - position, - `Invalid number, expected digit before ".", did you mean "0.${digits}"?`, - ); - } - break; + return readDot(lexer, position); } case 0x003a: // : return createToken(lexer, TokenKind.COLON, position, position + 1); @@ -341,6 +329,37 @@ function readNextToken(lexer: Lexer, start: number): 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); + // Full Stop (.) + if (nextCode === 0x002e) { + throw syntaxError( + lexer.source, + start, + 'Unexpected "..", did you mean "..."?', + ); + } + if (isDigit(nextCode)) { + 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 0bda2b0c8f..226e21d01a 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -51,6 +51,7 @@ import type { OperationTypeDefinitionNode, ScalarTypeDefinitionNode, ScalarTypeExtensionNode, + SchemaCoordinateNode, SchemaDefinitionNode, SchemaExtensionNode, SelectionNode, @@ -206,6 +207,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 @@ -1488,6 +1509,44 @@ 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 ofDirective = this.expectOptionalToken(TokenKind.AT); + const name = this.parseName(); + let memberName; + if (!ofDirective && this.expectOptionalToken(TokenKind.DOT)) { + memberName = this.parseName(); + } + let argumentName; + if ( + (ofDirective || 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, + ofDirective, + name, + memberName, + argumentName, + }); + } + // Core parsing utility functions /** diff --git a/src/language/predicates.ts b/src/language/predicates.ts index 2c1b6a2ef3..e6fc316233 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -4,6 +4,7 @@ import type { DefinitionNode, ExecutableDefinitionNode, NullabilityAssertionNode, + SchemaCoordinateNode, SelectionNode, TypeDefinitionNode, TypeExtensionNode, @@ -121,3 +122,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 9889e41bd8..6a4e087fee 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -350,6 +350,18 @@ const printDocASTReducer: ASTReducer = { leave: ({ name, directives, fields }) => join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + + // Schema Coordinate + + SchemaCoordinate: { + leave: ({ ofDirective, name, memberName, argumentName }) => + join([ + ofDirective ? '@' : undefined, + name, + wrap('.', memberName), + wrap('(', argumentName, ':)'), + ]), + }, }; /** diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index 15c6a37aa0..befa58bca6 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -11,6 +11,7 @@ enum TokenKind { AMP = '&', PAREN_L = '(', PAREN_R = ')', + DOT = '.', SPREAD = '...', COLON = ':', EQUALS = '=', diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts new file mode 100644 index 0000000000..e316ef52a1 --- /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.js'; +import type { GraphQLDirective } from '../../type/directives.js'; + +import { buildSchema } from '../buildASTSchema.js'; +import { resolveSchemaCoordinate } from '../resolveSchemaCoordinate.js'; + +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 6968dca4d3..3a3817c32b 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -98,3 +98,10 @@ export type { BreakingChange, DangerousChange } from './findBreakingChanges.js'; // Wrapper type that contains DocumentNode and types that can be deduced from it. export type { TypedQueryDocumentNode } from './typedQueryDocumentNode.js'; + +// Schema coordinates +export { + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, +} from './resolveSchemaCoordinate.js'; +export type { ResolvedSchemaElement } from './resolveSchemaCoordinate.js'; diff --git a/src/utilities/resolveSchemaCoordinate.ts b/src/utilities/resolveSchemaCoordinate.ts new file mode 100644 index 0000000000..9b9ce6f38b --- /dev/null +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -0,0 +1,190 @@ +import type { SchemaCoordinateNode } from '../language/ast.js'; +import { parseSchemaCoordinate } from '../language/parser.js'; +import type { Source } from '../language/source.js'; + +import type { + GraphQLArgument, + GraphQLEnumValue, + GraphQLField, + GraphQLInputField, + GraphQLNamedType, +} from '../type/definition.js'; +import { + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, +} from '../type/definition.js'; +import type { GraphQLDirective } from '../type/directives.js'; +import type { GraphQLSchema } from '../type/schema.js'; + +/** + * 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 { ofDirective, name, memberName, argumentName } = schemaCoordinate; + if (ofDirective) { + // 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 == null) { + 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 == null) { + 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 == null) { + 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 }; +}