diff --git a/src/index.ts b/src/index.ts index 7bba9d8eee..1f80cf51f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -236,6 +236,7 @@ export { parseValue, parseConstValue, parseType, + parseSchemaCoordinate, // Print print, // Visit @@ -255,6 +256,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './language/index.js'; export type { @@ -327,6 +329,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './language/index.js'; // Execute GraphQL queries. @@ -496,6 +499,8 @@ export { findBreakingChanges, findDangerousChanges, findSchemaChanges, + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, } from './utilities/index.js'; export type { @@ -526,4 +531,5 @@ export type { SafeChange, DangerousChange, TypedQueryDocumentNode, + ResolvedSchemaElement, } from './utilities/index.js'; diff --git a/src/language/__tests__/lexer-test.ts b/src/language/__tests__/lexer-test.ts index 85603dfaaa..f324a20a24 100644 --- a/src/language/__tests__/lexer-test.ts +++ b/src/language/__tests__/lexer-test.ts @@ -166,8 +166,8 @@ describe('Lexer', () => { }); it('reports unexpected characters', () => { - expectSyntaxError('.').to.deep.equal({ - message: 'Syntax Error: Unexpected character: ".".', + expectSyntaxError('^').to.deep.equal({ + message: 'Syntax Error: Unexpected character: "^".', locations: [{ line: 1, column: 1 }], }); }); @@ -965,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 d98b6a6f41..c0d247ddf5 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'; @@ -679,4 +685,118 @@ describe('Parser', () => { }); }); }); + + describe('parseSchemaCoordinate', () => { + it('parses Name', () => { + const result = parseSchemaCoordinate('MyType'); + expectJSON(result).toDeepEqual({ + kind: Kind.TYPE_COORDINATE, + loc: { start: 0, end: 6 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + }); + }); + + it('parses Name . Name', () => { + const result = parseSchemaCoordinate('MyType.field'); + expectJSON(result).toDeepEqual({ + kind: Kind.MEMBER_COORDINATE, + loc: { start: 0, end: 12 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + memberName: { + kind: Kind.NAME, + loc: { start: 7, end: 12 }, + value: 'field', + }, + }); + }); + + it('rejects Name . Name . Name', () => { + expect(() => parseSchemaCoordinate('MyType.field.deep')) + .to.throw() + .to.deep.include({ + 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.ARGUMENT_COORDINATE, + loc: { start: 0, end: 18 }, + 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.include({ + message: 'Syntax Error: Expected ")", found Name "value".', + locations: [{ line: 1, column: 19 }], + }); + }); + + it('parses @ Name', () => { + const result = parseSchemaCoordinate('@myDirective'); + expectJSON(result).toDeepEqual({ + kind: Kind.DIRECTIVE_COORDINATE, + loc: { start: 0, end: 12 }, + name: { + kind: Kind.NAME, + loc: { start: 1, end: 12 }, + value: 'myDirective', + }, + }); + }); + + it('parses @ Name ( Name : )', () => { + const result = parseSchemaCoordinate('@myDirective(arg:)'); + expectJSON(result).toDeepEqual({ + kind: Kind.DIRECTIVE_ARGUMENT_COORDINATE, + loc: { start: 0, end: 18 }, + name: { + kind: Kind.NAME, + loc: { start: 1, end: 12 }, + value: 'myDirective', + }, + argumentName: { + kind: Kind.NAME, + loc: { start: 13, end: 16 }, + value: 'arg', + }, + }); + }); + + it('rejects @ Name . Name', () => { + expect(() => parseSchemaCoordinate('@myDirective.field')) + .to.throw() + .to.deep.include({ + 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 7eeb682f3f..57907d6aa6 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -8,6 +8,7 @@ import { isConstValueNode, isDefinitionNode, isExecutableDefinitionNode, + isSchemaCoordinateNode, isSelectionNode, isTypeDefinitionNode, isTypeExtensionNode, @@ -141,4 +142,14 @@ describe('AST node predicates', () => { 'UnionTypeExtension', ]); }); + + it('isSchemaCoordinateNode', () => { + expect(filterNodes(isSchemaCoordinateNode)).to.deep.equal([ + 'ArgumentCoordinate', + 'DirectiveArgumentCoordinate', + 'DirectiveCoordinate', + 'MemberCoordinate', + 'TypeCoordinate', + ]); + }); }); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 624dc75ca2..589d9bfc8d 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', () => { @@ -299,4 +299,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 bce69a6e19..812b988835 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -181,7 +181,12 @@ export type ASTNode = | InterfaceTypeExtensionNode | UnionTypeExtensionNode | EnumTypeExtensionNode - | InputObjectTypeExtensionNode; + | InputObjectTypeExtensionNode + | TypeCoordinateNode + | MemberCoordinateNode + | ArgumentCoordinateNode + | DirectiveCoordinateNode + | DirectiveArgumentCoordinateNode; /** * Utility type listing all nodes indexed by their kind. @@ -287,6 +292,13 @@ export const QueryDocumentKeys: { UnionTypeExtension: ['name', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], + + // Schema Coordinates + TypeCoordinate: ['name'], + MemberCoordinate: ['name', 'memberName'], + ArgumentCoordinate: ['name', 'fieldName', 'argumentName'], + DirectiveCoordinate: ['name'], + DirectiveArgumentCoordinate: ['name', 'argumentName'], }; const kindValues = new Set(Object.keys(QueryDocumentKeys)); @@ -762,3 +774,46 @@ export interface InputObjectTypeExtensionNode { readonly directives?: ReadonlyArray | undefined; readonly fields?: ReadonlyArray | undefined; } + +/** Schema Coordinates */ + +export type SchemaCoordinateNode = + | TypeCoordinateNode + | MemberCoordinateNode + | ArgumentCoordinateNode + | DirectiveCoordinateNode + | DirectiveArgumentCoordinateNode; + +export interface TypeCoordinateNode { + readonly kind: typeof Kind.TYPE_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; +} + +export interface MemberCoordinateNode { + readonly kind: typeof Kind.MEMBER_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly memberName: NameNode; +} + +export interface ArgumentCoordinateNode { + readonly kind: typeof Kind.ARGUMENT_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly fieldName: NameNode; + readonly argumentName: NameNode; +} + +export interface DirectiveCoordinateNode { + readonly kind: typeof Kind.DIRECTIVE_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; +} + +export interface DirectiveArgumentCoordinateNode { + readonly kind: typeof Kind.DIRECTIVE_ARGUMENT_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly argumentName: NameNode; +} diff --git a/src/language/index.ts b/src/language/index.ts index 706072a75b..c5620b4948 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -13,7 +13,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'; @@ -90,6 +96,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast.js'; export { @@ -103,6 +110,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 0389c60c75..252feb6107 100644 --- a/src/language/kinds_.ts +++ b/src/language/kinds_.ts @@ -108,3 +108,20 @@ export const ENUM_TYPE_EXTENSION = 'EnumTypeExtension'; export type ENUM_TYPE_EXTENSION = typeof ENUM_TYPE_EXTENSION; export const INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension'; export type INPUT_OBJECT_TYPE_EXTENSION = typeof INPUT_OBJECT_TYPE_EXTENSION; + +/** Schema Coordinates */ +export const TYPE_COORDINATE = 'TypeCoordinate'; +export type TYPE_COORDINATE = typeof TYPE_COORDINATE; + +export const MEMBER_COORDINATE = 'MemberCoordinate'; +export type MEMBER_COORDINATE = typeof MEMBER_COORDINATE; + +export const ARGUMENT_COORDINATE = 'ArgumentCoordinate'; +export type ARGUMENT_COORDINATE = typeof ARGUMENT_COORDINATE; + +export const DIRECTIVE_COORDINATE = 'DirectiveCoordinate'; +export type DIRECTIVE_COORDINATE = typeof DIRECTIVE_COORDINATE; + +export const DIRECTIVE_ARGUMENT_COORDINATE = 'DirectiveArgumentCoordinate'; +export type DIRECTIVE_ARGUMENT_COORDINATE = + typeof DIRECTIVE_ARGUMENT_COORDINATE; diff --git a/src/language/lexer.ts b/src/language/lexer.ts index 841f25e786..44abc05197 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -95,6 +95,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 || @@ -246,7 +247,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: // $ @@ -263,24 +268,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); @@ -333,6 +321,35 @@ 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 8b12cafb29..5acfb4e85d 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -4,6 +4,7 @@ import type { GraphQLError } from '../error/GraphQLError.js'; import { syntaxError } from '../error/syntaxError.js'; import type { + ArgumentCoordinateNode, ArgumentNode, BooleanValueNode, ConstArgumentNode, @@ -13,6 +14,8 @@ import type { ConstObjectValueNode, ConstValueNode, DefinitionNode, + DirectiveArgumentCoordinateNode, + DirectiveCoordinateNode, DirectiveDefinitionNode, DirectiveNode, DocumentNode, @@ -35,6 +38,7 @@ import type { IntValueNode, ListTypeNode, ListValueNode, + MemberCoordinateNode, NamedTypeNode, NameNode, NonNullTypeNode, @@ -47,12 +51,14 @@ import type { OperationTypeDefinitionNode, ScalarTypeDefinitionNode, ScalarTypeExtensionNode, + SchemaCoordinateNode, SchemaDefinitionNode, SchemaExtensionNode, SelectionNode, SelectionSetNode, StringValueNode, Token, + TypeCoordinateNode, TypeNode, TypeSystemExtensionNode, UnionTypeDefinitionNode, @@ -182,6 +188,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 coordinate = parser.parseSchemaCoordinate(); + parser.expectToken(TokenKind.EOF); + return coordinate; +} + /** * 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 @@ -1433,6 +1459,68 @@ 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: NameNode | undefined; + if (!ofDirective && this.expectOptionalToken(TokenKind.DOT)) { + memberName = this.parseName(); + } + let argumentName: NameNode | undefined; + if ( + (ofDirective || memberName) && + this.expectOptionalToken(TokenKind.PAREN_L) + ) { + argumentName = this.parseName(); + this.expectToken(TokenKind.COLON); + this.expectToken(TokenKind.PAREN_R); + } + + if (ofDirective) { + if (argumentName) { + return this.node(start, { + kind: Kind.DIRECTIVE_ARGUMENT_COORDINATE, + name, + argumentName, + }); + } + return this.node(start, { + kind: Kind.DIRECTIVE_COORDINATE, + name, + }); + } else if (memberName) { + if (argumentName) { + return this.node(start, { + kind: Kind.ARGUMENT_COORDINATE, + name, + fieldName: memberName, + argumentName, + }); + } + return this.node(start, { + kind: Kind.MEMBER_COORDINATE, + name, + memberName, + }); + } + + return this.node(start, { + kind: Kind.TYPE_COORDINATE, + name, + }); + } + // Core parsing utility functions /** diff --git a/src/language/predicates.ts b/src/language/predicates.ts index 29ad5bf289..5146e8244e 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -3,6 +3,7 @@ import type { ConstValueNode, DefinitionNode, ExecutableDefinitionNode, + SchemaCoordinateNode, SelectionNode, TypeDefinitionNode, TypeExtensionNode, @@ -110,3 +111,15 @@ 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.TYPE_COORDINATE || + node.kind === Kind.MEMBER_COORDINATE || + node.kind === Kind.ARGUMENT_COORDINATE || + node.kind === Kind.DIRECTIVE_COORDINATE || + node.kind === Kind.DIRECTIVE_ARGUMENT_COORDINATE + ); +} diff --git a/src/language/printer.ts b/src/language/printer.ts index bf2959e59c..823b14a02d 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -320,6 +320,26 @@ const printDocASTReducer: ASTReducer = { leave: ({ name, directives, fields }) => join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + + // Schema Coordinates + + TypeCoordinate: { leave: ({ name }) => name }, + + MemberCoordinate: { + leave: ({ name, memberName }) => join([name, wrap('.', memberName)]), + }, + + ArgumentCoordinate: { + leave: ({ name, fieldName, argumentName }) => + join([name, wrap('.', fieldName), wrap('(', argumentName, ':)')]), + }, + + DirectiveCoordinate: { leave: ({ name }) => join(['@', name]) }, + + DirectiveArgumentCoordinate: { + leave: ({ name, argumentName }) => + join(['@', name, wrap('(', argumentName, ':)')]), + }, }; /** diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index d1c7129b04..eae0972b81 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -10,6 +10,7 @@ export const TokenKind = { AMP: '&' as const, PAREN_L: '(' as const, PAREN_R: ')' as const, + DOT: '.' as const, SPREAD: '...' as const, COLON: ':' as const, EQUALS: '=' as const, diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts new file mode 100644 index 0000000000..42d4310e0e --- /dev/null +++ b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts @@ -0,0 +1,239 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { + GraphQLEnumType, + GraphQLField, + 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.throw( + 'Expected "Unknown" to be defined as a type in the schema.', + ); + + expect(() => resolveSchemaCoordinate(schema, 'String.field')).to.throw( + 'Expected "String" to be an Enum, Input Object, Object or Interface type.', + ); + }); + + 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.throw('Expected "Unknown" to be defined as a type in the schema.'); + + expect(() => + resolveSchemaCoordinate(schema, 'Business.unknown(arg:)'), + ).to.throw( + 'Expected "unknown" to exist as a field of type "Business" in the schema.', + ); + + expect(() => + resolveSchemaCoordinate(schema, 'SearchCriteria.name(arg:)'), + ).to.throw( + 'Expected "SearchCriteria" to be an object type or interface type.', + ); + }); + + 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.throw( + 'Expected "unknown" to be defined as a directive in the schema.', + ); + }); + + it('resolves a meta-field', () => { + const type = schema.getType('Business') as GraphQLObjectType; + const field = schema.getField(type, '__typename'); + expect( + resolveSchemaCoordinate(schema, 'Business.__typename'), + ).to.deep.equal({ + kind: 'Field', + type, + field, + }); + }); + + it('resolves a meta-field argument', () => { + const type = schema.getType('Query') as GraphQLObjectType; + const field = schema.getField(type, '__type') as GraphQLField; + const fieldArgument = field.args.find((arg) => arg.name === 'name'); + expect( + resolveSchemaCoordinate(schema, 'Query.__type(name:)'), + ).to.deep.equal({ + kind: 'FieldArgument', + type, + field, + fieldArgument, + }); + }); + + it('resolves an Introspection Type', () => { + expect(resolveSchemaCoordinate(schema, '__Type')).to.deep.equal({ + kind: 'NamedType', + type: schema.getType('__Type'), + }); + }); + + it('resolves an Introspection Type Field', () => { + const type = schema.getType('__Directive') as GraphQLObjectType; + const field = type.getFields().name; + expect(resolveSchemaCoordinate(schema, '__Directive.name')).to.deep.equal({ + kind: 'Field', + type, + field, + }); + }); + + it('resolves an Introspection Type Enum Value', () => { + const type = schema.getType('__DirectiveLocation') as GraphQLEnumType; + const enumValue = type.getValue('INLINE_FRAGMENT'); + expect( + resolveSchemaCoordinate(schema, '__DirectiveLocation.INLINE_FRAGMENT'), + ).to.deep.equal({ + kind: 'EnumValue', + type, + enumValue, + }); + }); +}); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 5b891cded1..470ff1ee29 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -127,3 +127,10 @@ export type { // 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..3613a07f16 --- /dev/null +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -0,0 +1,313 @@ +import { inspect } from '../jsutils/inspect.js'; + +import type { + ArgumentCoordinateNode, + DirectiveArgumentCoordinateNode, + DirectiveCoordinateNode, + MemberCoordinateNode, + SchemaCoordinateNode, + TypeCoordinateNode, +} from '../language/ast.js'; +import { Kind } from '../language/kinds.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 interface ResolvedNamedType { + readonly kind: 'NamedType'; + readonly type: GraphQLNamedType; +} + +export interface ResolvedField { + readonly kind: 'Field'; + readonly type: GraphQLNamedType; + readonly field: GraphQLField; +} + +export interface ResolvedInputField { + readonly kind: 'InputField'; + readonly type: GraphQLNamedType; + readonly inputField: GraphQLInputField; +} + +export interface ResolvedEnumValue { + readonly kind: 'EnumValue'; + readonly type: GraphQLNamedType; + readonly enumValue: GraphQLEnumValue; +} + +export interface ResolvedFieldArgument { + readonly kind: 'FieldArgument'; + readonly type: GraphQLNamedType; + readonly field: GraphQLField; + readonly fieldArgument: GraphQLArgument; +} + +export interface ResolvedDirective { + readonly kind: 'Directive'; + readonly directive: GraphQLDirective; +} + +export interface ResolvedDirectiveArgument { + readonly kind: 'DirectiveArgument'; + readonly directive: GraphQLDirective; + readonly directiveArgument: GraphQLArgument; +} + +export type ResolvedSchemaElement = + | ResolvedNamedType + | ResolvedField + | ResolvedInputField + | ResolvedEnumValue + | ResolvedFieldArgument + | ResolvedDirective + | ResolvedDirectiveArgument; + +/** + * 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), + ); +} + +/** + * TypeCoordinate : Name + */ +function resolveTypeCoordinate( + schema: GraphQLSchema, + schemaCoordinate: TypeCoordinateNode, +): ResolvedNamedType | undefined { + // 1. Let {typeName} be the value of {Name}. + const typeName = schemaCoordinate.name.value; + const type = schema.getType(typeName); + + // 2. Return the type in the {schema} named {typeName}, or {null} if no such type exists. + if (type == null) { + return; + } + + return { kind: 'NamedType', type }; +} + +/** + * MemberCoordinate : Name . Name + */ +function resolveMemberCoordinate( + schema: GraphQLSchema, + schemaCoordinate: MemberCoordinateNode, +): ResolvedField | ResolvedInputField | ResolvedEnumValue | undefined { + // 1. Let {typeName} be the value of the first {Name}. + // 2. Let {type} be the type in the {schema} named {typeName}. + const typeName = schemaCoordinate.name.value; + const type = schema.getType(typeName); + + // 3. Assert: {type} must exist, and must be an Enum, Input Object, Object or Interface type. + if (!type) { + throw new Error( + `Expected ${inspect(typeName)} to be defined as a type in the schema.`, + ); + } + if ( + !isEnumType(type) && + !isInputObjectType(type) && + !isObjectType(type) && + !isInterfaceType(type) + ) { + throw new Error( + `Expected ${inspect(typeName)} to be an Enum, Input Object, Object or Interface type.`, + ); + } + + // 4. If {type} is an Enum type: + if (isEnumType(type)) { + // 1. Let {enumValueName} be the value of the second {Name}. + const enumValueName = schemaCoordinate.memberName.value; + const enumValue = type.getValue(enumValueName); + + // 2. Return the enum value of {type} named {enumValueName}, or {null} if no such value exists. + if (enumValue == null) { + return; + } + + return { kind: 'EnumValue', type, enumValue }; + } + + // 5. Otherwise, if {type} is an Input Object type: + if (isInputObjectType(type)) { + // 1. Let {inputFieldName} be the value of the second {Name}. + const inputFieldName = schemaCoordinate.memberName.value; + const inputField = type.getFields()[inputFieldName]; + + // 2. Return the input field of {type} named {inputFieldName}, or {null} if no such input field exists. + if (inputField == null) { + return; + } + + return { kind: 'InputField', type, inputField }; + } + + // 6. Otherwise: + // 1. Let {fieldName} be the value of the second {Name}. + const fieldName = schemaCoordinate.memberName.value; + const field = schema.getField(type, fieldName); + + // 2. Return the field of {type} named {fieldName}, or {null} if no such field exists. + if (field == null) { + return; + } + + return { kind: 'Field', type, field }; +} + +/** + * ArgumentCoordinate : Name . Name ( Name : ) + */ +function resolveArgumentCoordinate( + schema: GraphQLSchema, + schemaCoordinate: ArgumentCoordinateNode, +): ResolvedFieldArgument | undefined { + // 1. Let {typeName} be the value of the first {Name}. + // 2. Let {type} be the type in the {schema} named {typeName}. + const typeName = schemaCoordinate.name.value; + const type = schema.getType(typeName); + + // 3. Assert: {type} must exist, and be an Object or Interface type. + if (type == null) { + throw new Error( + `Expected ${inspect(typeName)} to be defined as a type in the schema.`, + ); + } + if (!isObjectType(type) && !isInterfaceType(type)) { + throw new Error( + `Expected ${inspect(typeName)} to be an object type or interface type.`, + ); + } + + // 4. Let {fieldName} be the value of the second {Name}. + // 5. Let {field} be the field of {type} named {fieldName}. + const fieldName = schemaCoordinate.fieldName.value; + const field = schema.getField(type, fieldName); + + // 7. Assert: {field} must exist. + if (field == null) { + throw new Error( + `Expected ${inspect(fieldName)} to exist as a field of type ${inspect(typeName)} in the schema.`, + ); + } + + // 7. Let {fieldArgumentName} be the value of the third {Name}. + const fieldArgumentName = schemaCoordinate.argumentName.value; + const fieldArgument = field.args.find( + (arg) => arg.name === fieldArgumentName, + ); + + // 8. Return the argument of {field} named {fieldArgumentName}, or {null} if no such argument exists. + if (fieldArgument == null) { + return; + } + + return { kind: 'FieldArgument', type, field, fieldArgument }; +} + +/** + * DirectiveCoordinate : @ Name + */ +function resolveDirectiveCoordinate( + schema: GraphQLSchema, + schemaCoordinate: DirectiveCoordinateNode, +): ResolvedDirective | undefined { + // 1. Let {directiveName} be the value of {Name}. + const directiveName = schemaCoordinate.name.value; + const directive = schema.getDirective(directiveName); + + // 2. Return the directive in the {schema} named {directiveName}, or {null} if no such directive exists. + if (!directive) { + return; + } + + return { kind: 'Directive', directive }; +} + +/** + * DirectiveArgumentCoordinate : @ Name ( Name : ) + */ +function resolveDirectiveArgumentCoordinate( + schema: GraphQLSchema, + schemaCoordinate: DirectiveArgumentCoordinateNode, +): ResolvedDirectiveArgument | undefined { + // 1. Let {directiveName} be the value of the first {Name}. + // 2. Let {directive} be the directive in the {schema} named {directiveName}. + const directiveName = schemaCoordinate.name.value; + const directive = schema.getDirective(directiveName); + + // 3. Assert {directive} must exist. + if (!directive) { + throw new Error( + `Expected ${inspect(directiveName)} to be defined as a directive in the schema.`, + ); + } + + // 4. Let {directiveArgumentName} be the value of the second {Name}. + const { + argumentName: { value: directiveArgumentName }, + } = schemaCoordinate; + const directiveArgument = directive.args.find( + (arg) => arg.name === directiveArgumentName, + ); + + // 5. Return the argument of {directive} named {directiveArgumentName}, or {null} if no such argument exists. + if (!directiveArgument) { + return; + } + + return { kind: 'DirectiveArgument', directive, directiveArgument }; +} + +/** + * Resolves schema coordinate from a parsed SchemaCoordinate node. + */ +export function resolveASTSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: SchemaCoordinateNode, +): ResolvedSchemaElement | undefined { + switch (schemaCoordinate.kind) { + case Kind.TYPE_COORDINATE: + return resolveTypeCoordinate(schema, schemaCoordinate); + case Kind.MEMBER_COORDINATE: + return resolveMemberCoordinate(schema, schemaCoordinate); + case Kind.ARGUMENT_COORDINATE: + return resolveArgumentCoordinate(schema, schemaCoordinate); + case Kind.DIRECTIVE_COORDINATE: + return resolveDirectiveCoordinate(schema, schemaCoordinate); + case Kind.DIRECTIVE_ARGUMENT_COORDINATE: + return resolveDirectiveArgumentCoordinate(schema, schemaCoordinate); + } +}