From 98e75417b13e83a6b39f5a4bca45236d28738272 Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Fri, 16 Apr 2021 02:11:50 -0700 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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; } /** From 42d1f4d7b075aa6bf7ba69ae45782df3784d3022 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 1 Jul 2025 06:57:52 -0500 Subject: [PATCH 09/13] schema coordinates spec wording edits (#4440) Update comments per https://github.com/graphql/graphql-spec/pull/794/commits/5ac68a005b07b70a581574774cbd58b1971dd78a --- src/utilities/resolveSchemaCoordinate.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/utilities/resolveSchemaCoordinate.ts b/src/utilities/resolveSchemaCoordinate.ts index 3613a07f16..db780930a0 100644 --- a/src/utilities/resolveSchemaCoordinate.ts +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -109,7 +109,7 @@ function resolveTypeCoordinate( 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. + // 2. Return the type in the {schema} named {typeName} if it exists. if (type == null) { return; } @@ -152,7 +152,7 @@ function resolveMemberCoordinate( 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. + // 2. Return the enum value of {type} named {enumValueName} if it exists. if (enumValue == null) { return; } @@ -166,7 +166,7 @@ function resolveMemberCoordinate( 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. + // 2. Return the input field of {type} named {inputFieldName} if it exists. if (inputField == null) { return; } @@ -179,7 +179,7 @@ function resolveMemberCoordinate( 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. + // 2. Return the field of {type} named {fieldName} if it exists. if (field == null) { return; } @@ -229,7 +229,7 @@ function resolveArgumentCoordinate( (arg) => arg.name === fieldArgumentName, ); - // 8. Return the argument of {field} named {fieldArgumentName}, or {null} if no such argument exists. + // 8. Return the argument of {field} named {fieldArgumentName} if it exists. if (fieldArgument == null) { return; } @@ -248,7 +248,7 @@ function resolveDirectiveCoordinate( 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. + // 2. Return the directive in the {schema} named {directiveName} if it exists. if (!directive) { return; } @@ -283,7 +283,7 @@ function resolveDirectiveArgumentCoordinate( (arg) => arg.name === directiveArgumentName, ); - // 5. Return the argument of {directive} named {directiveArgumentName}, or {null} if no such argument exists. + // 5. Return the argument of {directive} named {directiveArgumentName} if it exists. if (!directiveArgument) { return; } From 04dd13e28c9c992d65355e309d8233f6abf36e43 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Sun, 6 Jul 2025 08:54:11 -0500 Subject: [PATCH 10/13] No `{Ignored}` tokens when parsing schema coordinates (#4450) --- src/index.ts | 2 ++ src/language/__tests__/lexer-test.ts | 33 +++++++++++++++++++++++- src/language/__tests__/parser-test.ts | 4 +-- src/language/__tests__/printer-test.ts | 24 ++++++++++++------ src/language/index.ts | 4 +-- src/language/lexer.ts | 25 ++++++++++++++++++ src/language/parser.ts | 35 +++++++++++++++++++++++--- 7 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1f80cf51f3..ddc799e2ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -230,6 +230,7 @@ export { printSourceLocation, // Lex Lexer, + SchemaCoordinateLexer, TokenKind, // Parse parse, @@ -261,6 +262,7 @@ export { export type { ParseOptions, + ParseSchemaCoordinateOptions, SourceLocation, // Visitor utilities ASTVisitor, diff --git a/src/language/__tests__/lexer-test.ts b/src/language/__tests__/lexer-test.ts index f324a20a24..433d3c4181 100644 --- a/src/language/__tests__/lexer-test.ts +++ b/src/language/__tests__/lexer-test.ts @@ -9,7 +9,11 @@ import { inspect } from '../../jsutils/inspect.js'; import { GraphQLError } from '../../error/GraphQLError.js'; import type { Token } from '../ast.js'; -import { isPunctuatorTokenKind, Lexer } from '../lexer.js'; +import { + isPunctuatorTokenKind, + Lexer, + SchemaCoordinateLexer, +} from '../lexer.js'; import { Source } from '../source.js'; import { TokenKind } from '../tokenKind.js'; @@ -1189,6 +1193,33 @@ describe('Lexer', () => { }); }); +describe('SchemaCoordinateLexer', () => { + it('can be stringified', () => { + const lexer = new SchemaCoordinateLexer(new Source('Name.field')); + expect(Object.prototype.toString.call(lexer)).to.equal( + '[object SchemaCoordinateLexer]', + ); + }); + + it('tracks a schema coordinate', () => { + const lexer = new SchemaCoordinateLexer(new Source('Name.field')); + expect(lexer.advance()).to.contain({ + kind: TokenKind.NAME, + start: 0, + end: 4, + value: 'Name', + }); + }); + + it('forbids ignored tokens', () => { + const lexer = new SchemaCoordinateLexer(new Source('\nName.field')); + expectToThrowJSON(() => lexer.advance()).to.deep.equal({ + message: 'Syntax Error: Invalid character: U+000A.', + locations: [{ line: 1, column: 1 }], + }); + }); +}); + describe('isPunctuatorTokenKind', () => { function isPunctuatorToken(text: string) { return isPunctuatorTokenKind(lexOne(text).kind); diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index c0d247ddf5..e8dd914f71 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -751,11 +751,11 @@ describe('Parser', () => { }); it('rejects Name . Name ( Name : Name )', () => { - expect(() => parseSchemaCoordinate('MyType.field(arg: value)')) + expect(() => parseSchemaCoordinate('MyType.field(arg:value)')) .to.throw() .to.deep.include({ message: 'Syntax Error: Expected ")", found Name "value".', - locations: [{ line: 1, column: 19 }], + locations: [{ line: 1, column: 18 }], }); }); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 589d9bfc8d..a7a604bcba 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -301,16 +301,24 @@ 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( + 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:)', + expect(print(parseSchemaCoordinate('@name'))).to.equal('@name'); + expect(print(parseSchemaCoordinate('@name(arg:)'))).to.equal('@name(arg:)'); + }); + + it('throws syntax error for ignored tokens in schema coordinates', () => { + expect(() => print(parseSchemaCoordinate('# foo\nName'))).to.throw( + 'Syntax Error: Invalid character: "#"', + ); + expect(() => print(parseSchemaCoordinate('\nName'))).to.throw( + 'Syntax Error: Invalid character: U+000A.', + ); + expect(() => print(parseSchemaCoordinate('Name .field'))).to.throw( + 'Syntax Error: Invalid character: " "', ); }); }); diff --git a/src/language/index.ts b/src/language/index.ts index c5620b4948..1f2eff6bb7 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -11,7 +11,7 @@ export { Kind } from './kinds.js'; export { TokenKind } from './tokenKind.js'; -export { Lexer } from './lexer.js'; +export { Lexer, SchemaCoordinateLexer } from './lexer.js'; export { parse, @@ -20,7 +20,7 @@ export { parseType, parseSchemaCoordinate, } from './parser.js'; -export type { ParseOptions } from './parser.js'; +export type { ParseOptions, ParseSchemaCoordinateOptions } from './parser.js'; export { print } from './printer.js'; diff --git a/src/language/lexer.ts b/src/language/lexer.ts index 44abc05197..4a2228e285 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -83,6 +83,27 @@ export class Lexer { } return token; } + + validateIgnoredToken(_position: number): void { + /* noop - ignored tokens are ignored */ + } +} + +/** + * As `Lexer`, but forbids ignored tokens as required of schema coordinates. + */ +export class SchemaCoordinateLexer extends Lexer { + override get [Symbol.toStringTag]() { + return 'SchemaCoordinateLexer'; + } + + override validateIgnoredToken(position: number): void { + throw syntaxError( + this.source, + position, + `Invalid character: ${printCodePointAt(this, position)}.`, + ); + } } /** @@ -217,6 +238,7 @@ function readNextToken(lexer: Lexer, start: number): Token { case 0x0009: // \t case 0x0020: // case 0x002c: // , + lexer.validateIgnoredToken(position); ++position; continue; // LineTerminator :: @@ -224,11 +246,13 @@ function readNextToken(lexer: Lexer, start: number): Token { // - "Carriage Return (U+000D)" [lookahead != "New Line (U+000A)"] // - "Carriage Return (U+000D)" "New Line (U+000A)" case 0x000a: // \n + lexer.validateIgnoredToken(position); ++position; ++lexer.line; lexer.lineStart = position; continue; case 0x000d: // \r + lexer.validateIgnoredToken(position); if (body.charCodeAt(position + 1) === 0x000a) { position += 2; } else { @@ -239,6 +263,7 @@ function readNextToken(lexer: Lexer, start: number): Token { continue; // Comment case 0x0023: // # + lexer.validateIgnoredToken(position); return readComment(lexer, position); // Token :: // - Punctuator diff --git a/src/language/parser.ts b/src/language/parser.ts index 5acfb4e85d..5cf3e14d21 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -70,7 +70,11 @@ import type { import { Location, OperationTypeNode } from './ast.js'; import { DirectiveLocation } from './directiveLocation.js'; import { Kind } from './kinds.js'; -import { isPunctuatorTokenKind, Lexer } from './lexer.js'; +import { + isPunctuatorTokenKind, + Lexer, + SchemaCoordinateLexer, +} from './lexer.js'; import { isSource, Source } from './source.js'; import { TokenKind } from './tokenKind.js'; @@ -114,6 +118,24 @@ export interface ParseOptions { * ``` */ experimentalFragmentArguments?: boolean | undefined; + + /** + * You may override the Lexer class used to lex the source; this is used by + * schema coordinates to introduce a lexer that forbids ignored tokens. + */ + Lexer?: typeof Lexer | undefined; +} + +/** + * Configuration options to control schema coordinate parser behavior + */ +export interface ParseSchemaCoordinateOptions { + /** + * By default, the parser creates AST nodes that know the location + * in the source that they correspond to. This configuration flag + * disables that behavior for performance or testing. + */ + noLocation?: boolean | undefined; } /** @@ -199,9 +221,13 @@ export function parseType( */ export function parseSchemaCoordinate( source: string | Source, - options?: ParseOptions, + options?: ParseSchemaCoordinateOptions, ): SchemaCoordinateNode { - const parser = new Parser(source, options); + // Ignored tokens are excluded syntax for a Schema Coordinate. + const parser = new Parser(source, { + ...options, + Lexer: SchemaCoordinateLexer, + }); parser.expectToken(TokenKind.SOF); const coordinate = parser.parseSchemaCoordinate(); parser.expectToken(TokenKind.EOF); @@ -227,7 +253,8 @@ export class Parser { constructor(source: string | Source, options: ParseOptions = {}) { const sourceObj = isSource(source) ? source : new Source(source); - this._lexer = new Lexer(sourceObj); + const LexerClass = options.Lexer ?? Lexer; + this._lexer = new LexerClass(sourceObj); this._options = options; this._tokenCounter = 0; } From fd6ce88a407052b7a5c37a792de92fb992987fbb Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 21 Aug 2025 13:58:51 -0500 Subject: [PATCH 11/13] Schema coordinates (#4463) --- src/index.ts | 2 - src/language/__tests__/lexer-test.ts | 44 +------ src/language/__tests__/parser-test.ts | 14 +- .../__tests__/schemaCoordinateLexer-test.ts | 52 ++++++++ src/language/index.ts | 4 +- src/language/lexer.ts | 114 +++++++---------- src/language/parser.ts | 50 +++----- src/language/schemaCoordinateLexer.ts | 120 ++++++++++++++++++ 8 files changed, 254 insertions(+), 146 deletions(-) create mode 100644 src/language/__tests__/schemaCoordinateLexer-test.ts create mode 100644 src/language/schemaCoordinateLexer.ts diff --git a/src/index.ts b/src/index.ts index ddc799e2ba..1f80cf51f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -230,7 +230,6 @@ export { printSourceLocation, // Lex Lexer, - SchemaCoordinateLexer, TokenKind, // Parse parse, @@ -262,7 +261,6 @@ export { export type { ParseOptions, - ParseSchemaCoordinateOptions, SourceLocation, // Visitor utilities ASTVisitor, diff --git a/src/language/__tests__/lexer-test.ts b/src/language/__tests__/lexer-test.ts index 433d3c4181..85603dfaaa 100644 --- a/src/language/__tests__/lexer-test.ts +++ b/src/language/__tests__/lexer-test.ts @@ -9,11 +9,7 @@ import { inspect } from '../../jsutils/inspect.js'; import { GraphQLError } from '../../error/GraphQLError.js'; import type { Token } from '../ast.js'; -import { - isPunctuatorTokenKind, - Lexer, - SchemaCoordinateLexer, -} from '../lexer.js'; +import { isPunctuatorTokenKind, Lexer } from '../lexer.js'; import { Source } from '../source.js'; import { TokenKind } from '../tokenKind.js'; @@ -170,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 }], }); }); @@ -969,13 +965,6 @@ 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, @@ -1193,33 +1182,6 @@ describe('Lexer', () => { }); }); -describe('SchemaCoordinateLexer', () => { - it('can be stringified', () => { - const lexer = new SchemaCoordinateLexer(new Source('Name.field')); - expect(Object.prototype.toString.call(lexer)).to.equal( - '[object SchemaCoordinateLexer]', - ); - }); - - it('tracks a schema coordinate', () => { - const lexer = new SchemaCoordinateLexer(new Source('Name.field')); - expect(lexer.advance()).to.contain({ - kind: TokenKind.NAME, - start: 0, - end: 4, - value: 'Name', - }); - }); - - it('forbids ignored tokens', () => { - const lexer = new SchemaCoordinateLexer(new Source('\nName.field')); - expectToThrowJSON(() => lexer.advance()).to.deep.equal({ - message: 'Syntax Error: Invalid character: U+000A.', - locations: [{ line: 1, column: 1 }], - }); - }); -}); - describe('isPunctuatorTokenKind', () => { function isPunctuatorToken(text: string) { return isPunctuatorTokenKind(lexOne(text).kind); diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index e8dd914f71..2ca4c86216 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -722,7 +722,7 @@ describe('Parser', () => { expect(() => parseSchemaCoordinate('MyType.field.deep')) .to.throw() .to.deep.include({ - message: 'Syntax Error: Expected , found ".".', + message: 'Syntax Error: Expected , found ..', locations: [{ line: 1, column: 13 }], }); }); @@ -751,10 +751,10 @@ describe('Parser', () => { }); it('rejects Name . Name ( Name : Name )', () => { - expect(() => parseSchemaCoordinate('MyType.field(arg:value)')) + expect(() => parseSchemaCoordinate('MyType.field(arg: value)')) .to.throw() .to.deep.include({ - message: 'Syntax Error: Expected ")", found Name "value".', + message: 'Syntax Error: Invalid character: " ".', locations: [{ line: 1, column: 18 }], }); }); @@ -794,9 +794,15 @@ describe('Parser', () => { expect(() => parseSchemaCoordinate('@myDirective.field')) .to.throw() .to.deep.include({ - message: 'Syntax Error: Expected , found ".".', + message: 'Syntax Error: Expected , found ..', locations: [{ line: 1, column: 13 }], }); }); + + it('accepts a Source object', () => { + expect(parseSchemaCoordinate('MyType')).to.deep.equal( + parseSchemaCoordinate(new Source('MyType')), + ); + }); }); }); diff --git a/src/language/__tests__/schemaCoordinateLexer-test.ts b/src/language/__tests__/schemaCoordinateLexer-test.ts new file mode 100644 index 0000000000..1851e227f1 --- /dev/null +++ b/src/language/__tests__/schemaCoordinateLexer-test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectToThrowJSON } from '../../__testUtils__/expectJSON.js'; + +import { SchemaCoordinateLexer } from '../schemaCoordinateLexer.js'; +import { Source } from '../source.js'; +import { TokenKind } from '../tokenKind.js'; + +function lexSecond(str: string) { + const lexer = new SchemaCoordinateLexer(new Source(str)); + lexer.advance(); + return lexer.advance(); +} + +function expectSyntaxError(text: string) { + return expectToThrowJSON(() => lexSecond(text)); +} + +describe('SchemaCoordinateLexer', () => { + it('can be stringified', () => { + const lexer = new SchemaCoordinateLexer(new Source('Name.field')); + expect(Object.prototype.toString.call(lexer)).to.equal( + '[object SchemaCoordinateLexer]', + ); + }); + + it('tracks a schema coordinate', () => { + const lexer = new SchemaCoordinateLexer(new Source('Name.field')); + expect(lexer.advance()).to.contain({ + kind: TokenKind.NAME, + start: 0, + end: 4, + value: 'Name', + }); + }); + + it('forbids ignored tokens', () => { + const lexer = new SchemaCoordinateLexer(new Source('\nName.field')); + expectToThrowJSON(() => lexer.advance()).to.deep.equal({ + message: 'Syntax Error: Invalid character: U+000A.', + locations: [{ line: 1, column: 1 }], + }); + }); + + it('lex reports a useful syntax errors', () => { + expectSyntaxError('Foo .bar').to.deep.equal({ + message: 'Syntax Error: Invalid character: " ".', + locations: [{ line: 1, column: 4 }], + }); + }); +}); diff --git a/src/language/index.ts b/src/language/index.ts index 1f2eff6bb7..c5620b4948 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -11,7 +11,7 @@ export { Kind } from './kinds.js'; export { TokenKind } from './tokenKind.js'; -export { Lexer, SchemaCoordinateLexer } from './lexer.js'; +export { Lexer } from './lexer.js'; export { parse, @@ -20,7 +20,7 @@ export { parseType, parseSchemaCoordinate, } from './parser.js'; -export type { ParseOptions, ParseSchemaCoordinateOptions } from './parser.js'; +export type { ParseOptions } from './parser.js'; export { print } from './printer.js'; diff --git a/src/language/lexer.ts b/src/language/lexer.ts index 4a2228e285..3709636e58 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -6,6 +6,21 @@ import { isDigit, isNameContinue, isNameStart } from './characterClasses.js'; import type { Source } from './source.js'; import { TokenKind } from './tokenKind.js'; +/** + * Parser supports parsing multiple Source types, which may have differing + * Lexer classes. This is used for schema coordinates which has its own distinct + * SchemaCoordinateLexer class. + */ +export interface LexerInterface { + source: Source; + lastToken: Token; + token: Token; + line: number; + lineStart: number; + advance: () => Token; + lookahead: () => Token; +} + /** * Given a Source object, creates a Lexer for that source. * A Lexer is a stateful stream generator in that every time @@ -14,7 +29,7 @@ import { TokenKind } from './tokenKind.js'; * EOF, after which the lexer will repeatedly return the same EOF token * whenever called. */ -export class Lexer { +export class Lexer implements LexerInterface { source: Source; /** @@ -83,27 +98,6 @@ export class Lexer { } return token; } - - validateIgnoredToken(_position: number): void { - /* noop - ignored tokens are ignored */ - } -} - -/** - * As `Lexer`, but forbids ignored tokens as required of schema coordinates. - */ -export class SchemaCoordinateLexer extends Lexer { - override get [Symbol.toStringTag]() { - return 'SchemaCoordinateLexer'; - } - - override validateIgnoredToken(position: number): void { - throw syntaxError( - this.source, - position, - `Invalid character: ${printCodePointAt(this, position)}.`, - ); - } } /** @@ -116,7 +110,6 @@ 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 || @@ -172,8 +165,13 @@ function isTrailingSurrogate(code: number): boolean { * * Printable ASCII is printed quoted, while other points are printed in Unicode * code point form (ie. U+1234). + * + * @internal */ -function printCodePointAt(lexer: Lexer, location: number): string { +export function printCodePointAt( + lexer: LexerInterface, + location: number, +): string { const code = lexer.source.body.codePointAt(location); if (code === undefined) { @@ -190,9 +188,11 @@ function printCodePointAt(lexer: Lexer, location: number): string { /** * Create a token with line and column location information. + * + * @internal */ -function createToken( - lexer: Lexer, +export function createToken( + lexer: LexerInterface, kind: TokenKind, start: number, end: number, @@ -238,7 +238,6 @@ function readNextToken(lexer: Lexer, start: number): Token { case 0x0009: // \t case 0x0020: // case 0x002c: // , - lexer.validateIgnoredToken(position); ++position; continue; // LineTerminator :: @@ -246,13 +245,11 @@ function readNextToken(lexer: Lexer, start: number): Token { // - "Carriage Return (U+000D)" [lookahead != "New Line (U+000A)"] // - "Carriage Return (U+000D)" "New Line (U+000A)" case 0x000a: // \n - lexer.validateIgnoredToken(position); ++position; ++lexer.line; lexer.lineStart = position; continue; case 0x000d: // \r - lexer.validateIgnoredToken(position); if (body.charCodeAt(position + 1) === 0x000a) { position += 2; } else { @@ -263,7 +260,6 @@ function readNextToken(lexer: Lexer, start: number): Token { continue; // Comment case 0x0023: // # - lexer.validateIgnoredToken(position); return readComment(lexer, position); // Token :: // - Punctuator @@ -272,11 +268,7 @@ function readNextToken(lexer: Lexer, start: number): Token { // - FloatValue // - StringValue // - // Punctuator :: - // - DotPunctuator - // - OtherPunctuator - // - // OtherPunctuator :: one of ! $ & ( ) ... : = @ [ ] { | } + // Punctuator :: one of ! $ & ( ) ... : = @ [ ] { | } case 0x0021: // ! return createToken(lexer, TokenKind.BANG, position, position + 1); case 0x0024: // $ @@ -293,7 +285,24 @@ function readNextToken(lexer: Lexer, start: number): Token { if (nextCode === 0x002e && body.charCodeAt(position + 2) === 0x002e) { return createToken(lexer, TokenKind.SPREAD, position, position + 3); } - return readDot(lexer, position); + 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; } case 0x003a: // : return createToken(lexer, TokenKind.COLON, position, position + 1); @@ -346,35 +355,6 @@ 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. * @@ -888,8 +868,10 @@ function readBlockString(lexer: Lexer, start: number): Token { * Name :: * - NameStart NameContinue* [lookahead != NameContinue] * ``` + * + * @internal */ -function readName(lexer: Lexer, start: number): Token { +export function readName(lexer: LexerInterface, start: number): Token { const body = lexer.source.body; const bodyLength = body.length; let position = start + 1; diff --git a/src/language/parser.ts b/src/language/parser.ts index 5cf3e14d21..369ec2bb02 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -70,11 +70,9 @@ import type { import { Location, OperationTypeNode } from './ast.js'; import { DirectiveLocation } from './directiveLocation.js'; import { Kind } from './kinds.js'; -import { - isPunctuatorTokenKind, - Lexer, - SchemaCoordinateLexer, -} from './lexer.js'; +import type { LexerInterface } from './lexer.js'; +import { isPunctuatorTokenKind, Lexer } from './lexer.js'; +import { SchemaCoordinateLexer } from './schemaCoordinateLexer.js'; import { isSource, Source } from './source.js'; import { TokenKind } from './tokenKind.js'; @@ -121,21 +119,9 @@ export interface ParseOptions { /** * You may override the Lexer class used to lex the source; this is used by - * schema coordinates to introduce a lexer that forbids ignored tokens. + * schema coordinates to introduce a lexer with a restricted syntax. */ - Lexer?: typeof Lexer | undefined; -} - -/** - * Configuration options to control schema coordinate parser behavior - */ -export interface ParseSchemaCoordinateOptions { - /** - * By default, the parser creates AST nodes that know the location - * in the source that they correspond to. This configuration flag - * disables that behavior for performance or testing. - */ - noLocation?: boolean | undefined; + lexer?: LexerInterface | undefined; } /** @@ -221,13 +207,10 @@ export function parseType( */ export function parseSchemaCoordinate( source: string | Source, - options?: ParseSchemaCoordinateOptions, ): SchemaCoordinateNode { - // Ignored tokens are excluded syntax for a Schema Coordinate. - const parser = new Parser(source, { - ...options, - Lexer: SchemaCoordinateLexer, - }); + const sourceObj = isSource(source) ? source : new Source(source); + const lexer = new SchemaCoordinateLexer(sourceObj); + const parser = new Parser(source, { lexer }); parser.expectToken(TokenKind.SOF); const coordinate = parser.parseSchemaCoordinate(); parser.expectToken(TokenKind.EOF); @@ -246,16 +229,21 @@ export function parseSchemaCoordinate( * @internal */ export class Parser { - protected _options: ParseOptions; - protected _lexer: Lexer; + protected _options: Omit; + protected _lexer: LexerInterface; protected _tokenCounter: number; constructor(source: string | Source, options: ParseOptions = {}) { - const sourceObj = isSource(source) ? source : new Source(source); + const { lexer, ..._options } = options; + + if (lexer) { + this._lexer = lexer; + } else { + const sourceObj = isSource(source) ? source : new Source(source); + this._lexer = new Lexer(sourceObj); + } - const LexerClass = options.Lexer ?? Lexer; - this._lexer = new LexerClass(sourceObj); - this._options = options; + this._options = _options; this._tokenCounter = 0; } diff --git a/src/language/schemaCoordinateLexer.ts b/src/language/schemaCoordinateLexer.ts new file mode 100644 index 0000000000..6daf0238ad --- /dev/null +++ b/src/language/schemaCoordinateLexer.ts @@ -0,0 +1,120 @@ +import { syntaxError } from '../error/syntaxError.js'; + +import { Token } from './ast.js'; +import { isNameStart } from './characterClasses.js'; +import type { LexerInterface } from './lexer.js'; +import { createToken, printCodePointAt, readName } from './lexer.js'; +import type { Source } from './source.js'; +import { TokenKind } from './tokenKind.js'; + +/** + * Given a Source schema coordinate, creates a Lexer for that source. + * A SchemaCoordinateLexer is a stateful stream generator in that every time + * it is advanced, it returns the next token in the Source. Assuming the + * source lexes, the final Token emitted by the lexer will be of kind + * EOF, after which the lexer will repeatedly return the same EOF token + * whenever called. + */ +export class SchemaCoordinateLexer implements LexerInterface { + source: Source; + + /** + * The previously focused non-ignored token. + */ + lastToken: Token; + + /** + * The currently focused non-ignored token. + */ + token: Token; + + /** + * The (1-indexed) line containing the current token. + * Since a schema coordinate may not contain newline, this value is always 1. + */ + line: 1 = 1 as const; + + /** + * The character offset at which the current line begins. + * Since a schema coordinate may not contain newline, this value is always 0. + */ + lineStart: 0 = 0 as const; + + constructor(source: Source) { + const startOfFileToken = new Token(TokenKind.SOF, 0, 0, 0, 0); + + this.source = source; + this.lastToken = startOfFileToken; + this.token = startOfFileToken; + } + + get [Symbol.toStringTag]() { + return 'SchemaCoordinateLexer'; + } + + /** + * Advances the token stream to the next non-ignored token. + */ + advance(): Token { + this.lastToken = this.token; + const token = (this.token = this.lookahead()); + return token; + } + + /** + * Looks ahead and returns the next non-ignored token, but does not change + * the current Lexer token. + */ + lookahead(): Token { + let token = this.token; + if (token.kind !== TokenKind.EOF) { + // Read the next token and form a link in the token linked-list. + const nextToken = readNextToken(this, token.end); + // @ts-expect-error next is only mutable during parsing. + token.next = nextToken; + // @ts-expect-error prev is only mutable during parsing. + nextToken.prev = token; + token = nextToken; + } + return token; + } +} + +/** + * Gets the next token from the source starting at the given position. + */ +function readNextToken(lexer: SchemaCoordinateLexer, start: number): Token { + const body = lexer.source.body; + const bodyLength = body.length; + const position = start; + + if (position < bodyLength) { + const code = body.charCodeAt(position); + + switch (code) { + case 0x002e: // . + return createToken(lexer, TokenKind.DOT, position, position + 1); + case 0x0028: // ( + return createToken(lexer, TokenKind.PAREN_L, position, position + 1); + case 0x0029: // ) + return createToken(lexer, TokenKind.PAREN_R, position, position + 1); + case 0x003a: // : + return createToken(lexer, TokenKind.COLON, position, position + 1); + case 0x0040: // @ + return createToken(lexer, TokenKind.AT, position, position + 1); + } + + // Name + if (isNameStart(code)) { + return readName(lexer, position); + } + + throw syntaxError( + lexer.source, + position, + `Invalid character: ${printCodePointAt(lexer, position)}.`, + ); + } + + return createToken(lexer, TokenKind.EOF, bodyLength, bodyLength); +} From fe0ad30d91d87cd779cb1f6aee354db44187ffef Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 27 Aug 2025 13:35:24 -0500 Subject: [PATCH 12/13] Add benjie's suggested changes to `schema-coordinates` (#4478) --- cspell.yml | 1 + src/language/__tests__/parser-test.ts | 54 ++++++++++++++++ src/language/__tests__/printer-test.ts | 7 +++ .../__tests__/resolveSchemaCoordinate-test.ts | 62 +++++++++++-------- src/utilities/resolveSchemaCoordinate.ts | 18 ++++-- 5 files changed, 110 insertions(+), 32 deletions(-) diff --git a/cspell.yml b/cspell.yml index 3870f6f531..0de546fe97 100644 --- a/cspell.yml +++ b/cspell.yml @@ -48,6 +48,7 @@ ignoreRegExpList: words: - graphiql + - metafield - uncoerce - uncoerced diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index 2ca4c86216..9bbfa17b18 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -790,6 +790,60 @@ describe('Parser', () => { }); }); + it('parses __Type', () => { + const result = parseSchemaCoordinate('__Type'); + expectJSON(result).toDeepEqual({ + kind: Kind.TYPE_COORDINATE, + loc: { start: 0, end: 6 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: '__Type', + }, + }); + }); + + it('parses Type.__metafield', () => { + const result = parseSchemaCoordinate('Type.__metafield'); + expectJSON(result).toDeepEqual({ + kind: Kind.MEMBER_COORDINATE, + loc: { start: 0, end: 16 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 4 }, + value: 'Type', + }, + memberName: { + kind: Kind.NAME, + loc: { start: 5, end: 16 }, + value: '__metafield', + }, + }); + }); + + it('parses Type.__metafield(arg:)', () => { + const result = parseSchemaCoordinate('Type.__metafield(arg:)'); + expectJSON(result).toDeepEqual({ + kind: Kind.ARGUMENT_COORDINATE, + loc: { start: 0, end: 22 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 4 }, + value: 'Type', + }, + fieldName: { + kind: Kind.NAME, + loc: { start: 5, end: 16 }, + value: '__metafield', + }, + argumentName: { + kind: Kind.NAME, + loc: { start: 17, end: 20 }, + value: 'arg', + }, + }); + }); + it('rejects @ Name . Name', () => { expect(() => parseSchemaCoordinate('@myDirective.field')) .to.throw() diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index a7a604bcba..6ac39ef3d3 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -308,6 +308,13 @@ describe('Printer: Query document', () => { ); expect(print(parseSchemaCoordinate('@name'))).to.equal('@name'); expect(print(parseSchemaCoordinate('@name(arg:)'))).to.equal('@name(arg:)'); + expect(print(parseSchemaCoordinate('__Type'))).to.equal('__Type'); + expect(print(parseSchemaCoordinate('Type.__metafield'))).to.equal( + 'Type.__metafield', + ); + expect(print(parseSchemaCoordinate('Type.__metafield(arg:)'))).to.equal( + 'Type.__metafield(arg:)', + ); }); it('throws syntax error for ignored tokens in schema coordinates', () => { diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts index 42d4310e0e..ae6435f137 100644 --- a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts +++ b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts @@ -1,4 +1,4 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; import type { @@ -12,32 +12,32 @@ 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 - `); +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 +`); + +describe('resolveSchemaCoordinate', () => { it('resolves a Named Type', () => { expect(resolveSchemaCoordinate(schema, 'Business')).to.deep.equal({ kind: 'NamedType', @@ -181,10 +181,20 @@ describe('resolveSchemaCoordinate', () => { 'Expected "unknown" to be defined as a directive in the schema.', ); }); +}); +/* + * NOTE: the following are not required for spec compliance; resolution + * of meta-fields is implementation-defined. + * + * These tests are here to ensure a change of behavior will only be made + * in a semver-major release of GraphQL.js. + */ +describe('resolveSchemaCoordinate (meta-fields and introspection types)', () => { it('resolves a meta-field', () => { const type = schema.getType('Business') as GraphQLObjectType; const field = schema.getField(type, '__typename'); + assert.ok(field); expect( resolveSchemaCoordinate(schema, 'Business.__typename'), ).to.deep.equal({ diff --git a/src/utilities/resolveSchemaCoordinate.ts b/src/utilities/resolveSchemaCoordinate.ts index db780930a0..018cb0eed4 100644 --- a/src/utilities/resolveSchemaCoordinate.ts +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -14,10 +14,14 @@ import type { Source } from '../language/source.js'; import type { GraphQLArgument, + GraphQLEnumType, GraphQLEnumValue, GraphQLField, GraphQLInputField, + GraphQLInputObjectType, + GraphQLInterfaceType, GraphQLNamedType, + GraphQLObjectType, } from '../type/definition.js'; import { isEnumType, @@ -38,25 +42,25 @@ export interface ResolvedNamedType { export interface ResolvedField { readonly kind: 'Field'; - readonly type: GraphQLNamedType; + readonly type: GraphQLObjectType | GraphQLInterfaceType; readonly field: GraphQLField; } export interface ResolvedInputField { readonly kind: 'InputField'; - readonly type: GraphQLNamedType; + readonly type: GraphQLInputObjectType; readonly inputField: GraphQLInputField; } export interface ResolvedEnumValue { readonly kind: 'EnumValue'; - readonly type: GraphQLNamedType; + readonly type: GraphQLEnumType; readonly enumValue: GraphQLEnumValue; } export interface ResolvedFieldArgument { readonly kind: 'FieldArgument'; - readonly type: GraphQLNamedType; + readonly type: GraphQLObjectType | GraphQLInterfaceType; readonly field: GraphQLField; readonly fieldArgument: GraphQLArgument; } @@ -83,8 +87,10 @@ export type ResolvedSchemaElement = /** * 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. + * uniquely identify a schema element. It returns undefined if the schema + * coordinate does not resolve to a schema element, meta-field, or introspection + * schema element. It will throw if the containing schema element (if + * applicable) does not exist. * * https://spec.graphql.org/draft/#sec-Schema-Coordinates.Semantics */ From 81b8a23a9895a2c669747bf1679990bab78dd298 Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Sun, 31 Aug 2025 22:41:15 -0700 Subject: [PATCH 13/13] patch ci --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e648326020..a084dd53e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,8 @@ jobs: run: | git clone --depth 1 https://github.com/github/gitignore.git - rm gitignore/Global/ModelSim.gitignore - rm gitignore/Global/Images.gitignore + rm -f gitignore/Global/ModelSim.gitignore + rm -f gitignore/Global/Images.gitignore cat gitignore/Node.gitignore gitignore/Global/*.gitignore > all.gitignore IGNORED_FILES=$(git ls-files --cached --ignored --exclude-from=all.gitignore)