From 98e75417b13e83a6b39f5a4bca45236d28738272 Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Fri, 16 Apr 2021 02:11:50 -0700 Subject: [PATCH 1/8] 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 | 38 +++- 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, 692 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..257a04d745 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 }, + 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'); + expect(toJSONDeep(result)).to.deep.equal({ + 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', () => { + 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 }, + 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 )', () => { + 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 }, + 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:)'); + expect(toJSONDeep(result)).to.deep.equal({ + 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', () => { + 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..f69cc066f2 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 ofDirective: 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 b5637e388d..0e0378b776 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, 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: // $ @@ -237,7 +242,7 @@ function readNextToken(lexer: Lexer, start: number): 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: // = @@ -289,6 +294,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 f2807b5c1f..cc0a1f9156 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 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 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..2f3c7db08e 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: ({ ofDirective, name, memberName, argumentName }) => + join([ + ofDirective && '@', + 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 a1411f508e..84a7dac2c2 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 type { 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..d1e15976f5 --- /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 { 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) { + 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 }; +} From a23ecd11a16f644bd4aac20ed6dc7570b5872571 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 22 May 2025 10:24:00 +0100 Subject: [PATCH 2/8] Appease TypeScript --- src/language/ast.ts | 4 ++-- src/language/parser.ts | 4 ++-- src/language/printer.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/language/ast.ts b/src/language/ast.ts index 7a5c8128ae..af1c2d6ca7 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -772,6 +772,6 @@ export interface SchemaCoordinateNode { readonly loc?: Location; readonly ofDirective: boolean; readonly name: NameNode; - readonly memberName?: NameNode; - readonly argumentName?: NameNode; + readonly memberName?: NameNode | undefined; + readonly argumentName?: NameNode | undefined; } diff --git a/src/language/parser.ts b/src/language/parser.ts index 82ecffb26d..cb72914f07 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -1468,11 +1468,11 @@ export class Parser { const start = this._lexer.token; const ofDirective = this.expectOptionalToken(TokenKind.AT); const name = this.parseName(); - let memberName; + let memberName: NameNode | undefined; if (!ofDirective && this.expectOptionalToken(TokenKind.DOT)) { memberName = this.parseName(); } - let argumentName; + let argumentName: NameNode | undefined; if ( (ofDirective || memberName) && this.expectOptionalToken(TokenKind.PAREN_L) diff --git a/src/language/printer.ts b/src/language/printer.ts index 6687cc190c..dcc9f048b5 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -326,7 +326,7 @@ const printDocASTReducer: ASTReducer = { SchemaCoordinate: { leave: ({ ofDirective, name, memberName, argumentName }) => join([ - ofDirective && '@', + ofDirective ? '@' : '', name, wrap('.', memberName), wrap('(', argumentName, ':)'), From c7a9fdb7595869c2e9ae2bd0cc13676030ec186b Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 22 May 2025 10:28:41 +0100 Subject: [PATCH 3/8] Change bad syntax test --- src/language/__tests__/lexer-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/language/__tests__/lexer-test.ts b/src/language/__tests__/lexer-test.ts index 709286919e..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 }], }); }); From a59ce3f3cd28aedf183a085c5b5465833e78459e Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 22 May 2025 10:28:53 +0100 Subject: [PATCH 4/8] Fix error deep equality tests --- src/language/__tests__/parser-test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index af27b4d8a6..ca9753bcc2 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -726,7 +726,7 @@ describe('Parser', () => { it('rejects Name . Name . Name', () => { expect(() => parseSchemaCoordinate('MyType.field.deep')) .to.throw() - .to.deep.equal({ + .to.deep.include({ message: 'Syntax Error: Expected , found ".".', locations: [{ line: 1, column: 13 }], }); @@ -759,7 +759,7 @@ describe('Parser', () => { it('rejects Name . Name ( Name : Name )', () => { expect(() => parseSchemaCoordinate('MyType.field(arg: value)')) .to.throw() - .to.deep.equal({ + .to.deep.include({ message: 'Syntax Error: Expected ")", found Name "value".', locations: [{ line: 1, column: 19 }], }); @@ -804,7 +804,7 @@ describe('Parser', () => { it('rejects @ Name . Name', () => { expect(() => parseSchemaCoordinate('@myDirective.field')) .to.throw() - .to.deep.equal({ + .to.deep.include({ message: 'Syntax Error: Expected , found ".".', locations: [{ line: 1, column: 13 }], }); From bbc3dfad18665622e2019d1e8c3e97cb34b515bc Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 5 Jun 2025 12:20:41 -0500 Subject: [PATCH 5/8] Updates to schema coordinates (#3044) (#4422) Co-authored-by: Benjie --- src/language/__tests__/parser-test.ts | 43 +- src/language/__tests__/predicates-test.ts | 7 +- src/language/ast.ts | 68 ++- src/language/kinds_.ts | 20 +- src/language/lexer.ts | 9 + src/language/parser.ts | 53 ++- src/language/predicates.ts | 9 +- src/language/printer.ts | 32 +- src/language/tokenKind.ts | 1 + .../__tests__/resolveSchemaCoordinate-test.ts | 32 +- src/utilities/resolveSchemaCoordinate.ts | 403 ++++++++++++------ 11 files changed, 489 insertions(+), 188 deletions(-) diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index ca9753bcc2..ba3ef79cd9 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -690,36 +690,31 @@ describe('Parser', () => { it('parses Name', () => { const result = parseSchemaCoordinate('MyType'); expectJSON(result).toDeepEqual({ - kind: Kind.SCHEMA_COORDINATE, + kind: Kind.TYPE_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, + kind: Kind.FIELD_COORDINATE, loc: { start: 0, end: 12 }, - ofDirective: false, name: { kind: Kind.NAME, loc: { start: 0, end: 6 }, value: 'MyType', }, - memberName: { + fieldName: { kind: Kind.NAME, loc: { start: 7, end: 12 }, value: 'field', }, - argumentName: undefined, }); }); @@ -732,18 +727,35 @@ describe('Parser', () => { }); }); + it('parses Name :: Name', () => { + const result = parseSchemaCoordinate('MyEnum::value'); + expectJSON(result).toDeepEqual({ + kind: Kind.VALUE_COORDINATE, + loc: { start: 0, end: 13 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyEnum', + }, + valueName: { + kind: Kind.NAME, + loc: { start: 8, end: 13 }, + value: 'value', + }, + }); + }); + it('parses Name . Name ( Name : )', () => { const result = parseSchemaCoordinate('MyType.field(arg:)'); expectJSON(result).toDeepEqual({ - kind: Kind.SCHEMA_COORDINATE, + kind: Kind.ARGUMENT_COORDINATE, loc: { start: 0, end: 18 }, - ofDirective: false, name: { kind: Kind.NAME, loc: { start: 0, end: 6 }, value: 'MyType', }, - memberName: { + fieldName: { kind: Kind.NAME, loc: { start: 7, end: 12 }, value: 'field', @@ -768,31 +780,26 @@ describe('Parser', () => { it('parses @ Name', () => { const result = parseSchemaCoordinate('@myDirective'); expectJSON(result).toDeepEqual({ - kind: Kind.SCHEMA_COORDINATE, + kind: Kind.DIRECTIVE_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, + kind: Kind.DIRECTIVE_ARGUMENT_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 }, diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index f2df5ccf08..7455fd73e4 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -145,7 +145,12 @@ describe('AST node predicates', () => { it('isSchemaCoordinateNode', () => { expect(filterNodes(isSchemaCoordinateNode)).to.deep.equal([ - 'SchemaCoordinate', + 'ArgumentCoordinate', + 'DirectiveArgumentCoordinate', + 'DirectiveCoordinate', + 'FieldCoordinate', + 'TypeCoordinate', + 'ValueCoordinate', ]); }); }); diff --git a/src/language/ast.ts b/src/language/ast.ts index af1c2d6ca7..268a2ddd98 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -182,7 +182,12 @@ export type ASTNode = | UnionTypeExtensionNode | EnumTypeExtensionNode | InputObjectTypeExtensionNode - | SchemaCoordinateNode; + | TypeCoordinateNode + | FieldCoordinateNode + | ArgumentCoordinateNode + | ValueCoordinateNode + | DirectiveCoordinateNode + | DirectiveArgumentCoordinateNode; /** * Utility type listing all nodes indexed by their kind. @@ -288,7 +293,14 @@ export const QueryDocumentKeys: { UnionTypeExtension: ['name', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], - SchemaCoordinate: ['name', 'memberName', 'argumentName'], + + // Schema Coordinates + TypeCoordinate: ['name'], + FieldCoordinate: ['name', 'fieldName'], + ArgumentCoordinate: ['name', 'fieldName', 'argumentName'], + ValueCoordinate: ['name', 'valueName'], + DirectiveCoordinate: ['name'], + DirectiveArgumentCoordinate: ['name', 'argumentName'], }; const kindValues = new Set(Object.keys(QueryDocumentKeys)); @@ -765,13 +777,53 @@ export interface InputObjectTypeExtensionNode { readonly fields?: ReadonlyArray | undefined; } -// Schema Coordinates +/** Schema Coordinates */ + +export type SchemaCoordinateNode = + | TypeCoordinateNode + | FieldCoordinateNode + | ArgumentCoordinateNode + | ValueCoordinateNode + | DirectiveCoordinateNode + | DirectiveArgumentCoordinateNode; + +export interface TypeCoordinateNode { + readonly kind: typeof Kind.TYPE_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; +} + +export interface FieldCoordinateNode { + readonly kind: typeof Kind.FIELD_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly fieldName: NameNode; +} + +export interface ArgumentCoordinateNode { + readonly kind: typeof Kind.ARGUMENT_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly fieldName: NameNode; + readonly argumentName: NameNode; +} + +export interface ValueCoordinateNode { + readonly kind: typeof Kind.VALUE_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly valueName: NameNode; +} + +export interface DirectiveCoordinateNode { + readonly kind: typeof Kind.DIRECTIVE_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; +} -export interface SchemaCoordinateNode { - readonly kind: 'SchemaCoordinate'; +export interface DirectiveArgumentCoordinateNode { + readonly kind: typeof Kind.DIRECTIVE_ARGUMENT_COORDINATE; readonly loc?: Location; - readonly ofDirective: boolean; readonly name: NameNode; - readonly memberName?: NameNode | undefined; - readonly argumentName?: NameNode | undefined; + readonly argumentName: NameNode; } diff --git a/src/language/kinds_.ts b/src/language/kinds_.ts index 78ec798531..24d909fdfe 100644 --- a/src/language/kinds_.ts +++ b/src/language/kinds_.ts @@ -110,5 +110,21 @@ export const INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension'; export type INPUT_OBJECT_TYPE_EXTENSION = typeof INPUT_OBJECT_TYPE_EXTENSION; /** Schema Coordinates */ -export const SCHEMA_COORDINATE = 'SchemaCoordinate'; -export type SCHEMA_COORDINATE = typeof SCHEMA_COORDINATE; +export const TYPE_COORDINATE = 'TypeCoordinate'; +export type TYPE_COORDINATE = typeof TYPE_COORDINATE; + +export const FIELD_COORDINATE = 'FieldCoordinate'; +export type FIELD_COORDINATE = typeof FIELD_COORDINATE; + +export const ARGUMENT_COORDINATE = 'ArgumentCoordinate'; +export type ARGUMENT_COORDINATE = typeof ARGUMENT_COORDINATE; + +export const VALUE_COORDINATE = 'ValueCoordinate'; +export type VALUE_COORDINATE = typeof VALUE_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 44abc05197..a2d305e645 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -98,6 +98,7 @@ export function isPunctuatorTokenKind(kind: TokenKind): boolean { kind === TokenKind.DOT || kind === TokenKind.SPREAD || kind === TokenKind.COLON || + kind === TokenKind.TWO_COLON || kind === TokenKind.EQUALS || kind === TokenKind.AT || kind === TokenKind.BRACKET_L || @@ -271,6 +272,14 @@ function readNextToken(lexer: Lexer, start: number): Token { return readDot(lexer, position); } case 0x003a: // : + if (body.charCodeAt(position + 1) === 0x003a) { + return createToken( + lexer, + TokenKind.TWO_COLON, + position, + position + 2, + ); + } return createToken(lexer, TokenKind.COLON, position, position + 1); case 0x003d: // = return createToken(lexer, TokenKind.EQUALS, position, position + 1); diff --git a/src/language/parser.ts b/src/language/parser.ts index cb72914f07..31fa99d074 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, @@ -20,6 +23,7 @@ import type { EnumTypeExtensionNode, EnumValueDefinitionNode, EnumValueNode, + FieldCoordinateNode, FieldDefinitionNode, FieldNode, FloatValueNode, @@ -54,10 +58,12 @@ import type { SelectionSetNode, StringValueNode, Token, + TypeCoordinateNode, TypeNode, TypeSystemExtensionNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, + ValueCoordinateNode, ValueNode, VariableDefinitionNode, VariableNode, @@ -1461,6 +1467,7 @@ export class Parser { * - Name * - Name . Name * - Name . Name ( Name : ) + * - Name :: Name * - @ Name * - @ Name ( Name : ) */ @@ -1468,6 +1475,16 @@ export class Parser { const start = this._lexer.token; const ofDirective = this.expectOptionalToken(TokenKind.AT); const name = this.parseName(); + + if (!ofDirective && this.expectOptionalToken(TokenKind.TWO_COLON)) { + const valueName = this.parseName(); + return this.node(start, { + kind: Kind.VALUE_COORDINATE, + name, + valueName, + }); + } + let memberName: NameNode | undefined; if (!ofDirective && this.expectOptionalToken(TokenKind.DOT)) { memberName = this.parseName(); @@ -1481,12 +1498,38 @@ export class Parser { this.expectToken(TokenKind.COLON); this.expectToken(TokenKind.PAREN_R); } - return this.node(start, { - kind: Kind.SCHEMA_COORDINATE, - ofDirective, + + 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.FIELD_COORDINATE, + name, + fieldName: memberName, + }); + } + + return this.node(start, { + kind: Kind.TYPE_COORDINATE, name, - memberName, - argumentName, }); } diff --git a/src/language/predicates.ts b/src/language/predicates.ts index fa5923b90d..488e9828f2 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -115,5 +115,12 @@ export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode { export function isSchemaCoordinateNode( node: ASTNode, ): node is SchemaCoordinateNode { - return node.kind === Kind.SCHEMA_COORDINATE; + return ( + node.kind === Kind.TYPE_COORDINATE || + node.kind === Kind.FIELD_COORDINATE || + node.kind === Kind.ARGUMENT_COORDINATE || + node.kind === Kind.VALUE_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 dcc9f048b5..2701f8373b 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -321,16 +321,28 @@ const printDocASTReducer: ASTReducer = { join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, - // Schema Coordinate - - SchemaCoordinate: { - leave: ({ ofDirective, name, memberName, argumentName }) => - join([ - ofDirective ? '@' : '', - name, - wrap('.', memberName), - wrap('(', argumentName, ':)'), - ]), + // Schema Coordinates + + TypeCoordinate: { leave: ({ name }) => name }, + + FieldCoordinate: { + leave: ({ name, fieldName }) => join([name, wrap('.', fieldName)]), + }, + + ArgumentCoordinate: { + leave: ({ name, fieldName, argumentName }) => + join([name, wrap('.', fieldName), wrap('(', argumentName, ':)')]), + }, + + ValueCoordinate: { + leave: ({ name, valueName }) => join([name, wrap('::', valueName)]), + }, + + 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 7872370675..f6547f095a 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -13,6 +13,7 @@ export const TokenKind = { DOT: '.', SPREAD: '...' as const, COLON: ':' as const, + TWO_COLON: '::' as const, EQUALS: '=' as const, AT: '@' as const, BRACKET_L: '[' as const, diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts index e316ef52a1..0fa9cfdf10 100644 --- a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts +++ b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts @@ -66,12 +66,12 @@ describe('resolveSchemaCoordinate', () => { undefined, ); - expect(resolveSchemaCoordinate(schema, 'Unknown.field')).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.deep.equal( - undefined, + expect(() => resolveSchemaCoordinate(schema, 'String.field')).to.throw( + 'Expected "String" to be an Input Object, Object or Interface type.', ); }); @@ -101,7 +101,7 @@ describe('resolveSchemaCoordinate', () => { const type = schema.getType('SearchFilter') as GraphQLEnumType; const enumValue = type.getValue('OPEN_NOW'); expect( - resolveSchemaCoordinate(schema, 'SearchFilter.OPEN_NOW'), + resolveSchemaCoordinate(schema, 'SearchFilter::OPEN_NOW'), ).to.deep.equal({ kind: 'EnumValue', type, @@ -109,7 +109,7 @@ describe('resolveSchemaCoordinate', () => { }); expect( - resolveSchemaCoordinate(schema, 'SearchFilter.UNKNOWN'), + resolveSchemaCoordinate(schema, 'SearchFilter::UNKNOWN'), ).to.deep.equal(undefined); }); @@ -130,17 +130,21 @@ describe('resolveSchemaCoordinate', () => { resolveSchemaCoordinate(schema, 'Business.name(unknown:)'), ).to.deep.equal(undefined); - expect( + expect(() => resolveSchemaCoordinate(schema, 'Unknown.field(arg:)'), - ).to.deep.equal(undefined); + ).to.throw('Expected "Unknown" to be defined as a type in the schema.'); - expect( + expect(() => resolveSchemaCoordinate(schema, 'Business.unknown(arg:)'), - ).to.deep.equal(undefined); + ).to.throw( + 'Expected "unknown" to exist as a field of type "Business" in the schema.', + ); - expect( + expect(() => resolveSchemaCoordinate(schema, 'SearchCriteria.name(arg:)'), - ).to.deep.equal(undefined); + ).to.throw( + 'Expected "SearchCriteria" to be an object type or interface type.', + ); }); it('resolves a Directive', () => { @@ -178,8 +182,8 @@ describe('resolveSchemaCoordinate', () => { undefined, ); - expect(resolveSchemaCoordinate(schema, '@unknown(arg:)')).to.deep.equal( - undefined, + expect(() => resolveSchemaCoordinate(schema, '@unknown(arg:)')).to.throw( + 'Expected "unknown" to be defined as a directive in the schema.', ); }); }); diff --git a/src/utilities/resolveSchemaCoordinate.ts b/src/utilities/resolveSchemaCoordinate.ts index 026076672c..afebe13199 100644 --- a/src/utilities/resolveSchemaCoordinate.ts +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -1,4 +1,15 @@ -import type { SchemaCoordinateNode } from '../language/ast.js'; +import { inspect } from '../jsutils/inspect.js'; + +import type { + ArgumentCoordinateNode, + DirectiveArgumentCoordinateNode, + DirectiveCoordinateNode, + FieldCoordinateNode, + SchemaCoordinateNode, + TypeCoordinateNode, + ValueCoordinateNode, +} from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; import { parseSchemaCoordinate } from '../language/parser.js'; import type { Source } from '../language/source.js'; @@ -21,41 +32,55 @@ 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 = - | { - 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; - }; + | ResolvedNamedType + | ResolvedField + | ResolvedInputField + | ResolvedEnumValue + | ResolvedFieldArgument + | ResolvedDirective + | ResolvedDirectiveArgument; /** * A schema coordinate is resolved in the context of a GraphQL schema to @@ -75,116 +100,236 @@ export function resolveSchemaCoordinate( } /** - * Resolves schema coordinate from a parsed SchemaCoordinate node. + * TypeCoordinate : Name */ -export function resolveASTSchemaCoordinate( +function resolveTypeCoordinate( 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: TypeCoordinateNode, +): ResolvedNamedType | undefined { + // 1. Let {typeName} be the value of {Name}. + const typeName = schemaCoordinate.name.value; + const type = schema.getType(typeName); - // 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, + // 2. Return the type in the {schema} named {typeName}, or {null} if no such type exists. + if (type == null) { + return; + } + + return { kind: 'NamedType', type }; +} + +/** + * FieldCoordinate : Name . Name + */ +function resolveFieldCoordinate( + schema: GraphQLSchema, + schemaCoordinate: FieldCoordinateNode, +): ResolvedField | ResolvedInputField | 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 Input Object, Object or Interface type. + if (!type) { + throw new Error( + `Expected ${inspect(typeName)} to be defined as a type in the schema.`, ); - 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 == null) { - 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) { + } + if ( + !isInputObjectType(type) && + !isObjectType(type) && + !isInterfaceType(type) + ) { + throw new Error( + `Expected ${inspect(typeName)} to be an Input Object, Object or Interface type.`, + ); + } + + // 4. If {type} is an Input Object type: + if (isInputObjectType(type)) { + // 1. Let {inputFieldName} be the value of the second {Name}. + const inputFieldName = schemaCoordinate.fieldName.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: 'Field', type, field }; + + return { kind: 'InputField', type, inputField }; } - // SchemaCoordinate : Name . Name ( Name : ) - // Assert {type} must be an Object or Interface type. - if (!isObjectType(type) && !isInterfaceType(type)) { + // 5. Otherwise: + // 1. Let {fieldName} be the value of the second {Name}. + const fieldName = schemaCoordinate.fieldName.value; + const field = type.getFields()[fieldName]; + + // 2. Return the field of {type} named {fieldName}, or {null} if no such field exists. + if (field == null) { 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. + + 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 = type.getFields()[fieldName]; + + // 7. Assert: {field} must exist. if (field == null) { - return; + throw new Error( + `Expected ${inspect(fieldName)} to exist as a field of type ${inspect(typeName)} in the schema.`, + ); } - // Let {fieldArgumentName} be the value of the third {Name}. - // Return the argument of {field} named {fieldArgumentName}. + + // 7. Let {fieldArgumentName} be the value of the third {Name}. + const fieldArgumentName = schemaCoordinate.argumentName.value; const fieldArgument = field.args.find( - (arg) => arg.name === argumentName.value, + (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 }; } + +/** + * ValueCoordinate : Name :: Name + */ +function resolveValueCoordinate( + schema: GraphQLSchema, + schemaCoordinate: ValueCoordinateNode, +): 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 type. + if (!type) { + throw new Error( + `Expected ${inspect(typeName)} to be defined as a type in the schema.`, + ); + } + if (!isEnumType(type)) { + throw new Error(`Expected ${inspect(typeName)} to be an Enum type.`); + } + + // 4. Let {enumValueName} be the value of the second {Name}. + const enumValueName = schemaCoordinate.valueName.value; + const enumValue = type.getValue(enumValueName); + + // 5. Return the enum value of {type} named {enumValueName}, or {null} if no such value exists. + if (enumValue == null) { + return; + } + + return { kind: 'EnumValue', type, enumValue }; +} + +/** + * 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.FIELD_COORDINATE: + return resolveFieldCoordinate(schema, schemaCoordinate); + case Kind.ARGUMENT_COORDINATE: + return resolveArgumentCoordinate(schema, schemaCoordinate); + case Kind.VALUE_COORDINATE: + return resolveValueCoordinate(schema, schemaCoordinate); + case Kind.DIRECTIVE_COORDINATE: + return resolveDirectiveCoordinate(schema, schemaCoordinate); + case Kind.DIRECTIVE_ARGUMENT_COORDINATE: + return resolveDirectiveArgumentCoordinate(schema, schemaCoordinate); + } +} From b759e9228ff2a75c07f1731475fad1bab4238b86 Mon Sep 17 00:00:00 2001 From: Benjie Date: Thu, 5 Jun 2025 18:34:56 +0100 Subject: [PATCH 6/8] Add missing "as const" --- src/language/tokenKind.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index f6547f095a..b14fe45a05 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -10,7 +10,7 @@ export const TokenKind = { AMP: '&' as const, PAREN_L: '(' as const, PAREN_R: ')' as const, - DOT: '.', + DOT: '.' as const, SPREAD: '...' as const, COLON: ':' as const, TWO_COLON: '::' as const, From b6533adf4e4e90266bd4fd215ce0e55560cdad23 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Mon, 9 Jun 2025 08:46:14 -0500 Subject: [PATCH 7/8] Implement schema coordinate spec as of 2025-06-06 (#4432) This PR is applied to the `schema-coordinates` branch PR here: https://github.com/graphql/graphql-js/pull/3044 Implements schema coordinate spec changes per the June 5th 2025 WG discussion https://github.com/graphql/graphql-spec/pull/794 - Add support for meta-fields (e.g. `__typename`) - Add support for introspection types - Revert back from FieldCoordinate+ValueCoordinate -> MemberCoordinate cc @benjie --- src/language/__tests__/parser-test.ts | 22 +---- src/language/__tests__/predicates-test.ts | 3 +- src/language/ast.ts | 22 ++--- src/language/kinds_.ts | 7 +- src/language/lexer.ts | 9 -- src/language/parser.ts | 20 +---- src/language/predicates.ts | 3 +- src/language/printer.ts | 8 +- src/language/tokenKind.ts | 1 - .../__tests__/resolveSchemaCoordinate-test.ts | 68 +++++++++++++-- src/utilities/resolveSchemaCoordinate.ts | 82 +++++++------------ 11 files changed, 107 insertions(+), 138 deletions(-) diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index ba3ef79cd9..c0d247ddf5 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -703,14 +703,14 @@ describe('Parser', () => { it('parses Name . Name', () => { const result = parseSchemaCoordinate('MyType.field'); expectJSON(result).toDeepEqual({ - kind: Kind.FIELD_COORDINATE, + kind: Kind.MEMBER_COORDINATE, loc: { start: 0, end: 12 }, name: { kind: Kind.NAME, loc: { start: 0, end: 6 }, value: 'MyType', }, - fieldName: { + memberName: { kind: Kind.NAME, loc: { start: 7, end: 12 }, value: 'field', @@ -727,24 +727,6 @@ describe('Parser', () => { }); }); - it('parses Name :: Name', () => { - const result = parseSchemaCoordinate('MyEnum::value'); - expectJSON(result).toDeepEqual({ - kind: Kind.VALUE_COORDINATE, - loc: { start: 0, end: 13 }, - name: { - kind: Kind.NAME, - loc: { start: 0, end: 6 }, - value: 'MyEnum', - }, - valueName: { - kind: Kind.NAME, - loc: { start: 8, end: 13 }, - value: 'value', - }, - }); - }); - it('parses Name . Name ( Name : )', () => { const result = parseSchemaCoordinate('MyType.field(arg:)'); expectJSON(result).toDeepEqual({ diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 7455fd73e4..57907d6aa6 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -148,9 +148,8 @@ describe('AST node predicates', () => { 'ArgumentCoordinate', 'DirectiveArgumentCoordinate', 'DirectiveCoordinate', - 'FieldCoordinate', + 'MemberCoordinate', 'TypeCoordinate', - 'ValueCoordinate', ]); }); }); diff --git a/src/language/ast.ts b/src/language/ast.ts index 268a2ddd98..812b988835 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -183,9 +183,8 @@ export type ASTNode = | EnumTypeExtensionNode | InputObjectTypeExtensionNode | TypeCoordinateNode - | FieldCoordinateNode + | MemberCoordinateNode | ArgumentCoordinateNode - | ValueCoordinateNode | DirectiveCoordinateNode | DirectiveArgumentCoordinateNode; @@ -296,9 +295,8 @@ export const QueryDocumentKeys: { // Schema Coordinates TypeCoordinate: ['name'], - FieldCoordinate: ['name', 'fieldName'], + MemberCoordinate: ['name', 'memberName'], ArgumentCoordinate: ['name', 'fieldName', 'argumentName'], - ValueCoordinate: ['name', 'valueName'], DirectiveCoordinate: ['name'], DirectiveArgumentCoordinate: ['name', 'argumentName'], }; @@ -781,9 +779,8 @@ export interface InputObjectTypeExtensionNode { export type SchemaCoordinateNode = | TypeCoordinateNode - | FieldCoordinateNode + | MemberCoordinateNode | ArgumentCoordinateNode - | ValueCoordinateNode | DirectiveCoordinateNode | DirectiveArgumentCoordinateNode; @@ -793,11 +790,11 @@ export interface TypeCoordinateNode { readonly name: NameNode; } -export interface FieldCoordinateNode { - readonly kind: typeof Kind.FIELD_COORDINATE; +export interface MemberCoordinateNode { + readonly kind: typeof Kind.MEMBER_COORDINATE; readonly loc?: Location; readonly name: NameNode; - readonly fieldName: NameNode; + readonly memberName: NameNode; } export interface ArgumentCoordinateNode { @@ -808,13 +805,6 @@ export interface ArgumentCoordinateNode { readonly argumentName: NameNode; } -export interface ValueCoordinateNode { - readonly kind: typeof Kind.VALUE_COORDINATE; - readonly loc?: Location; - readonly name: NameNode; - readonly valueName: NameNode; -} - export interface DirectiveCoordinateNode { readonly kind: typeof Kind.DIRECTIVE_COORDINATE; readonly loc?: Location; diff --git a/src/language/kinds_.ts b/src/language/kinds_.ts index 24d909fdfe..252feb6107 100644 --- a/src/language/kinds_.ts +++ b/src/language/kinds_.ts @@ -113,15 +113,12 @@ export type INPUT_OBJECT_TYPE_EXTENSION = typeof INPUT_OBJECT_TYPE_EXTENSION; export const TYPE_COORDINATE = 'TypeCoordinate'; export type TYPE_COORDINATE = typeof TYPE_COORDINATE; -export const FIELD_COORDINATE = 'FieldCoordinate'; -export type FIELD_COORDINATE = typeof FIELD_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 VALUE_COORDINATE = 'ValueCoordinate'; -export type VALUE_COORDINATE = typeof VALUE_COORDINATE; - export const DIRECTIVE_COORDINATE = 'DirectiveCoordinate'; export type DIRECTIVE_COORDINATE = typeof DIRECTIVE_COORDINATE; diff --git a/src/language/lexer.ts b/src/language/lexer.ts index a2d305e645..44abc05197 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -98,7 +98,6 @@ export function isPunctuatorTokenKind(kind: TokenKind): boolean { kind === TokenKind.DOT || kind === TokenKind.SPREAD || kind === TokenKind.COLON || - kind === TokenKind.TWO_COLON || kind === TokenKind.EQUALS || kind === TokenKind.AT || kind === TokenKind.BRACKET_L || @@ -272,14 +271,6 @@ function readNextToken(lexer: Lexer, start: number): Token { return readDot(lexer, position); } case 0x003a: // : - if (body.charCodeAt(position + 1) === 0x003a) { - return createToken( - lexer, - TokenKind.TWO_COLON, - position, - position + 2, - ); - } return createToken(lexer, TokenKind.COLON, position, position + 1); case 0x003d: // = return createToken(lexer, TokenKind.EQUALS, position, position + 1); diff --git a/src/language/parser.ts b/src/language/parser.ts index 31fa99d074..de049abeb5 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -23,7 +23,6 @@ import type { EnumTypeExtensionNode, EnumValueDefinitionNode, EnumValueNode, - FieldCoordinateNode, FieldDefinitionNode, FieldNode, FloatValueNode, @@ -39,6 +38,7 @@ import type { IntValueNode, ListTypeNode, ListValueNode, + MemberCoordinateNode, NamedTypeNode, NameNode, NonNullTypeNode, @@ -63,7 +63,6 @@ import type { TypeSystemExtensionNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, - ValueCoordinateNode, ValueNode, VariableDefinitionNode, VariableNode, @@ -1467,7 +1466,6 @@ export class Parser { * - Name * - Name . Name * - Name . Name ( Name : ) - * - Name :: Name * - @ Name * - @ Name ( Name : ) */ @@ -1475,16 +1473,6 @@ export class Parser { const start = this._lexer.token; const ofDirective = this.expectOptionalToken(TokenKind.AT); const name = this.parseName(); - - if (!ofDirective && this.expectOptionalToken(TokenKind.TWO_COLON)) { - const valueName = this.parseName(); - return this.node(start, { - kind: Kind.VALUE_COORDINATE, - name, - valueName, - }); - } - let memberName: NameNode | undefined; if (!ofDirective && this.expectOptionalToken(TokenKind.DOT)) { memberName = this.parseName(); @@ -1520,10 +1508,10 @@ export class Parser { argumentName, }); } - return this.node(start, { - kind: Kind.FIELD_COORDINATE, + return this.node(start, { + kind: Kind.MEMBER_COORDINATE, name, - fieldName: memberName, + memberName, }); } diff --git a/src/language/predicates.ts b/src/language/predicates.ts index 488e9828f2..5146e8244e 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -117,9 +117,8 @@ export function isSchemaCoordinateNode( ): node is SchemaCoordinateNode { return ( node.kind === Kind.TYPE_COORDINATE || - node.kind === Kind.FIELD_COORDINATE || + node.kind === Kind.MEMBER_COORDINATE || node.kind === Kind.ARGUMENT_COORDINATE || - node.kind === Kind.VALUE_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 2701f8373b..823b14a02d 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -325,8 +325,8 @@ const printDocASTReducer: ASTReducer = { TypeCoordinate: { leave: ({ name }) => name }, - FieldCoordinate: { - leave: ({ name, fieldName }) => join([name, wrap('.', fieldName)]), + MemberCoordinate: { + leave: ({ name, memberName }) => join([name, wrap('.', memberName)]), }, ArgumentCoordinate: { @@ -334,10 +334,6 @@ const printDocASTReducer: ASTReducer = { join([name, wrap('.', fieldName), wrap('(', argumentName, ':)')]), }, - ValueCoordinate: { - leave: ({ name, valueName }) => join([name, wrap('::', valueName)]), - }, - DirectiveCoordinate: { leave: ({ name }) => join(['@', name]) }, DirectiveArgumentCoordinate: { diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index b14fe45a05..eae0972b81 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -13,7 +13,6 @@ export const TokenKind = { DOT: '.' as const, SPREAD: '...' as const, COLON: ':' as const, - TWO_COLON: '::' as const, EQUALS: '=' as const, AT: '@' as const, BRACKET_L: '[' as const, diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts index 0fa9cfdf10..42d4310e0e 100644 --- a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts +++ b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts @@ -3,6 +3,7 @@ import { describe, it } from 'mocha'; import type { GraphQLEnumType, + GraphQLField, GraphQLInputObjectType, GraphQLObjectType, } from '../../type/definition.js'; @@ -71,16 +72,10 @@ describe('resolveSchemaCoordinate', () => { ); expect(() => resolveSchemaCoordinate(schema, 'String.field')).to.throw( - 'Expected "String" to be an Input Object, Object or Interface type.', + 'Expected "String" to be an Enum, Input Object, Object or Interface type.', ); }); - 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; @@ -101,7 +96,7 @@ describe('resolveSchemaCoordinate', () => { const type = schema.getType('SearchFilter') as GraphQLEnumType; const enumValue = type.getValue('OPEN_NOW'); expect( - resolveSchemaCoordinate(schema, 'SearchFilter::OPEN_NOW'), + resolveSchemaCoordinate(schema, 'SearchFilter.OPEN_NOW'), ).to.deep.equal({ kind: 'EnumValue', type, @@ -109,7 +104,7 @@ describe('resolveSchemaCoordinate', () => { }); expect( - resolveSchemaCoordinate(schema, 'SearchFilter::UNKNOWN'), + resolveSchemaCoordinate(schema, 'SearchFilter.UNKNOWN'), ).to.deep.equal(undefined); }); @@ -186,4 +181,59 @@ describe('resolveSchemaCoordinate', () => { '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/resolveSchemaCoordinate.ts b/src/utilities/resolveSchemaCoordinate.ts index afebe13199..3613a07f16 100644 --- a/src/utilities/resolveSchemaCoordinate.ts +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -4,10 +4,9 @@ import type { ArgumentCoordinateNode, DirectiveArgumentCoordinateNode, DirectiveCoordinateNode, - FieldCoordinateNode, + MemberCoordinateNode, SchemaCoordinateNode, TypeCoordinateNode, - ValueCoordinateNode, } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; import { parseSchemaCoordinate } from '../language/parser.js'; @@ -119,37 +118,52 @@ function resolveTypeCoordinate( } /** - * FieldCoordinate : Name . Name + * MemberCoordinate : Name . Name */ -function resolveFieldCoordinate( +function resolveMemberCoordinate( schema: GraphQLSchema, - schemaCoordinate: FieldCoordinateNode, -): ResolvedField | ResolvedInputField | undefined { + 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 Input Object, Object or Interface type. + // 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 Input Object, Object or Interface type.`, + `Expected ${inspect(typeName)} to be an Enum, Input Object, Object or Interface type.`, ); } - // 4. If {type} is an Input Object 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.fieldName.value; + 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. @@ -160,10 +174,10 @@ function resolveFieldCoordinate( return { kind: 'InputField', type, inputField }; } - // 5. Otherwise: + // 6. Otherwise: // 1. Let {fieldName} be the value of the second {Name}. - const fieldName = schemaCoordinate.fieldName.value; - const field = type.getFields()[fieldName]; + 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) { @@ -200,7 +214,7 @@ function resolveArgumentCoordinate( // 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 = type.getFields()[fieldName]; + const field = schema.getField(type, fieldName); // 7. Assert: {field} must exist. if (field == null) { @@ -223,40 +237,6 @@ function resolveArgumentCoordinate( return { kind: 'FieldArgument', type, field, fieldArgument }; } -/** - * ValueCoordinate : Name :: Name - */ -function resolveValueCoordinate( - schema: GraphQLSchema, - schemaCoordinate: ValueCoordinateNode, -): 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 type. - if (!type) { - throw new Error( - `Expected ${inspect(typeName)} to be defined as a type in the schema.`, - ); - } - if (!isEnumType(type)) { - throw new Error(`Expected ${inspect(typeName)} to be an Enum type.`); - } - - // 4. Let {enumValueName} be the value of the second {Name}. - const enumValueName = schemaCoordinate.valueName.value; - const enumValue = type.getValue(enumValueName); - - // 5. Return the enum value of {type} named {enumValueName}, or {null} if no such value exists. - if (enumValue == null) { - return; - } - - return { kind: 'EnumValue', type, enumValue }; -} - /** * DirectiveCoordinate : @ Name */ @@ -321,12 +301,10 @@ export function resolveASTSchemaCoordinate( switch (schemaCoordinate.kind) { case Kind.TYPE_COORDINATE: return resolveTypeCoordinate(schema, schemaCoordinate); - case Kind.FIELD_COORDINATE: - return resolveFieldCoordinate(schema, schemaCoordinate); + case Kind.MEMBER_COORDINATE: + return resolveMemberCoordinate(schema, schemaCoordinate); case Kind.ARGUMENT_COORDINATE: return resolveArgumentCoordinate(schema, schemaCoordinate); - case Kind.VALUE_COORDINATE: - return resolveValueCoordinate(schema, schemaCoordinate); case Kind.DIRECTIVE_COORDINATE: return resolveDirectiveCoordinate(schema, schemaCoordinate); case Kind.DIRECTIVE_ARGUMENT_COORDINATE: From 1f70a93426c38969c4b3be7ba36e83fdc0c6a1a5 Mon Sep 17 00:00:00 2001 From: Benjie Date: Mon, 9 Jun 2025 23:04:26 +0100 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: Yaacov Rydzinski --- src/language/parser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/language/parser.ts b/src/language/parser.ts index de049abeb5..5acfb4e85d 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -203,9 +203,9 @@ export function parseSchemaCoordinate( ): SchemaCoordinateNode { const parser = new Parser(source, options); parser.expectToken(TokenKind.SOF); - const type = parser.parseSchemaCoordinate(); + const coordinate = parser.parseSchemaCoordinate(); parser.expectToken(TokenKind.EOF); - return type; + return coordinate; } /**