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) diff --git a/cspell.yml b/cspell.yml index f1f19e8c52..409144082c 100644 --- a/cspell.yml +++ b/cspell.yml @@ -48,6 +48,7 @@ ignoreRegExpList: words: - graphiql + - metafield - uncoerce - uncoerced diff --git a/src/index.ts b/src/index.ts index 434411deb7..f67c5360b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -239,6 +239,7 @@ export { parseValue, parseConstValue, parseType, + parseSchemaCoordinate, // Print print, // Visit @@ -258,6 +259,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './language/index.js'; export type { @@ -330,6 +332,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './language/index.js'; // Execute GraphQL queries. @@ -499,6 +502,8 @@ export { findBreakingChanges, findDangerousChanges, findSchemaChanges, + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, } from './utilities/index.js'; export type { @@ -529,4 +534,5 @@ export type { SafeChange, DangerousChange, TypedQueryDocumentNode, + ResolvedSchemaElement, } from './utilities/index.js'; diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index d98b6a6f41..9bbfa17b18 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -11,7 +11,13 @@ import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js'; import { inspect } from '../../jsutils/inspect.js'; import { Kind } from '../kinds.js'; -import { parse, parseConstValue, parseType, parseValue } from '../parser.js'; +import { + parse, + parseConstValue, + parseSchemaCoordinate, + parseType, + parseValue, +} from '../parser.js'; import { Source } from '../source.js'; import { TokenKind } from '../tokenKind.js'; @@ -679,4 +685,178 @@ describe('Parser', () => { }); }); }); + + describe('parseSchemaCoordinate', () => { + it('parses Name', () => { + const result = parseSchemaCoordinate('MyType'); + expectJSON(result).toDeepEqual({ + kind: Kind.TYPE_COORDINATE, + loc: { start: 0, end: 6 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + }); + }); + + it('parses Name . Name', () => { + const result = parseSchemaCoordinate('MyType.field'); + expectJSON(result).toDeepEqual({ + kind: Kind.MEMBER_COORDINATE, + loc: { start: 0, end: 12 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + memberName: { + kind: Kind.NAME, + loc: { start: 7, end: 12 }, + value: 'field', + }, + }); + }); + + it('rejects Name . Name . Name', () => { + expect(() => parseSchemaCoordinate('MyType.field.deep')) + .to.throw() + .to.deep.include({ + message: 'Syntax Error: Expected , found ..', + locations: [{ line: 1, column: 13 }], + }); + }); + + it('parses Name . Name ( Name : )', () => { + const result = parseSchemaCoordinate('MyType.field(arg:)'); + expectJSON(result).toDeepEqual({ + kind: Kind.ARGUMENT_COORDINATE, + loc: { start: 0, end: 18 }, + name: { + kind: Kind.NAME, + loc: { start: 0, end: 6 }, + value: 'MyType', + }, + fieldName: { + kind: Kind.NAME, + loc: { start: 7, end: 12 }, + value: 'field', + }, + argumentName: { + kind: Kind.NAME, + loc: { start: 13, end: 16 }, + value: 'arg', + }, + }); + }); + + it('rejects Name . Name ( Name : Name )', () => { + expect(() => parseSchemaCoordinate('MyType.field(arg: value)')) + .to.throw() + .to.deep.include({ + message: 'Syntax Error: Invalid character: " ".', + locations: [{ line: 1, column: 18 }], + }); + }); + + it('parses @ Name', () => { + const result = parseSchemaCoordinate('@myDirective'); + expectJSON(result).toDeepEqual({ + kind: Kind.DIRECTIVE_COORDINATE, + loc: { start: 0, end: 12 }, + name: { + kind: Kind.NAME, + loc: { start: 1, end: 12 }, + value: 'myDirective', + }, + }); + }); + + it('parses @ Name ( Name : )', () => { + const result = parseSchemaCoordinate('@myDirective(arg:)'); + expectJSON(result).toDeepEqual({ + kind: Kind.DIRECTIVE_ARGUMENT_COORDINATE, + loc: { start: 0, end: 18 }, + name: { + kind: Kind.NAME, + loc: { start: 1, end: 12 }, + value: 'myDirective', + }, + argumentName: { + kind: Kind.NAME, + loc: { start: 13, end: 16 }, + value: 'arg', + }, + }); + }); + + it('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() + .to.deep.include({ + 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__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 7eeb682f3f..57907d6aa6 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -8,6 +8,7 @@ import { isConstValueNode, isDefinitionNode, isExecutableDefinitionNode, + isSchemaCoordinateNode, isSelectionNode, isTypeDefinitionNode, isTypeExtensionNode, @@ -141,4 +142,14 @@ describe('AST node predicates', () => { 'UnionTypeExtension', ]); }); + + it('isSchemaCoordinateNode', () => { + expect(filterNodes(isSchemaCoordinateNode)).to.deep.equal([ + 'ArgumentCoordinate', + 'DirectiveArgumentCoordinate', + 'DirectiveCoordinate', + 'MemberCoordinate', + 'TypeCoordinate', + ]); + }); }); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 624dc75ca2..6ac39ef3d3 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -5,7 +5,7 @@ import { dedent, dedentString } from '../../__testUtils__/dedent.js'; import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js'; import { Kind } from '../kinds.js'; -import { parse } from '../parser.js'; +import { parse, parseSchemaCoordinate } from '../parser.js'; import { print } from '../printer.js'; describe('Printer: Query document', () => { @@ -299,4 +299,33 @@ 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:)'); + 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', () => { + 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/__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/ast.ts b/src/language/ast.ts index 54952b83ce..b15d4150f8 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -181,7 +181,12 @@ export type ASTNode = | InterfaceTypeExtensionNode | UnionTypeExtensionNode | EnumTypeExtensionNode - | InputObjectTypeExtensionNode; + | InputObjectTypeExtensionNode + | TypeCoordinateNode + | MemberCoordinateNode + | ArgumentCoordinateNode + | DirectiveCoordinateNode + | DirectiveArgumentCoordinateNode; /** * Utility type listing all nodes indexed by their kind. @@ -287,6 +292,13 @@ export const QueryDocumentKeys: { UnionTypeExtension: ['name', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], + + // Schema Coordinates + TypeCoordinate: ['name'], + MemberCoordinate: ['name', 'memberName'], + ArgumentCoordinate: ['name', 'fieldName', 'argumentName'], + DirectiveCoordinate: ['name'], + DirectiveArgumentCoordinate: ['name', 'argumentName'], }; const kindValues = new Set(Object.keys(QueryDocumentKeys)); @@ -762,3 +774,46 @@ export interface InputObjectTypeExtensionNode { readonly directives?: ReadonlyArray | undefined; readonly fields?: ReadonlyArray | undefined; } + +/** Schema Coordinates */ + +export type SchemaCoordinateNode = + | TypeCoordinateNode + | MemberCoordinateNode + | ArgumentCoordinateNode + | DirectiveCoordinateNode + | DirectiveArgumentCoordinateNode; + +export interface TypeCoordinateNode { + readonly kind: typeof Kind.TYPE_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; +} + +export interface MemberCoordinateNode { + readonly kind: typeof Kind.MEMBER_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly memberName: NameNode; +} + +export interface ArgumentCoordinateNode { + readonly kind: typeof Kind.ARGUMENT_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly fieldName: NameNode; + readonly argumentName: NameNode; +} + +export interface DirectiveCoordinateNode { + readonly kind: typeof Kind.DIRECTIVE_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; +} + +export interface DirectiveArgumentCoordinateNode { + readonly kind: typeof Kind.DIRECTIVE_ARGUMENT_COORDINATE; + readonly loc?: Location; + readonly name: NameNode; + readonly argumentName: NameNode; +} diff --git a/src/language/index.ts b/src/language/index.ts index 706072a75b..c5620b4948 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -13,7 +13,13 @@ export { TokenKind } from './tokenKind.js'; export { Lexer } from './lexer.js'; -export { parse, parseValue, parseConstValue, parseType } from './parser.js'; +export { + parse, + parseValue, + parseConstValue, + parseType, + parseSchemaCoordinate, +} from './parser.js'; export type { ParseOptions } from './parser.js'; export { print } from './printer.js'; @@ -90,6 +96,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + SchemaCoordinateNode, } from './ast.js'; export { @@ -103,6 +110,7 @@ export { isTypeDefinitionNode, isTypeSystemExtensionNode, isTypeExtensionNode, + isSchemaCoordinateNode, } from './predicates.js'; export { DirectiveLocation } from './directiveLocation.js'; diff --git a/src/language/kinds_.ts b/src/language/kinds_.ts index 0389c60c75..252feb6107 100644 --- a/src/language/kinds_.ts +++ b/src/language/kinds_.ts @@ -108,3 +108,20 @@ export const ENUM_TYPE_EXTENSION = 'EnumTypeExtension'; export type ENUM_TYPE_EXTENSION = typeof ENUM_TYPE_EXTENSION; export const INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension'; export type INPUT_OBJECT_TYPE_EXTENSION = typeof INPUT_OBJECT_TYPE_EXTENSION; + +/** Schema Coordinates */ +export const TYPE_COORDINATE = 'TypeCoordinate'; +export type TYPE_COORDINATE = typeof TYPE_COORDINATE; + +export const MEMBER_COORDINATE = 'MemberCoordinate'; +export type MEMBER_COORDINATE = typeof MEMBER_COORDINATE; + +export const ARGUMENT_COORDINATE = 'ArgumentCoordinate'; +export type ARGUMENT_COORDINATE = typeof ARGUMENT_COORDINATE; + +export const DIRECTIVE_COORDINATE = 'DirectiveCoordinate'; +export type DIRECTIVE_COORDINATE = typeof DIRECTIVE_COORDINATE; + +export const DIRECTIVE_ARGUMENT_COORDINATE = 'DirectiveArgumentCoordinate'; +export type DIRECTIVE_ARGUMENT_COORDINATE = + typeof DIRECTIVE_ARGUMENT_COORDINATE; diff --git a/src/language/lexer.ts b/src/language/lexer.ts index 841f25e786..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; /** @@ -150,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) { @@ -168,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, @@ -846,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 8b12cafb29..369ec2bb02 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -4,6 +4,7 @@ import type { GraphQLError } from '../error/GraphQLError.js'; import { syntaxError } from '../error/syntaxError.js'; import type { + ArgumentCoordinateNode, ArgumentNode, BooleanValueNode, ConstArgumentNode, @@ -13,6 +14,8 @@ import type { ConstObjectValueNode, ConstValueNode, DefinitionNode, + DirectiveArgumentCoordinateNode, + DirectiveCoordinateNode, DirectiveDefinitionNode, DirectiveNode, DocumentNode, @@ -35,6 +38,7 @@ import type { IntValueNode, ListTypeNode, ListValueNode, + MemberCoordinateNode, NamedTypeNode, NameNode, NonNullTypeNode, @@ -47,12 +51,14 @@ import type { OperationTypeDefinitionNode, ScalarTypeDefinitionNode, ScalarTypeExtensionNode, + SchemaCoordinateNode, SchemaDefinitionNode, SchemaExtensionNode, SelectionNode, SelectionSetNode, StringValueNode, Token, + TypeCoordinateNode, TypeNode, TypeSystemExtensionNode, UnionTypeDefinitionNode, @@ -64,7 +70,9 @@ import type { import { Location, OperationTypeNode } from './ast.js'; import { DirectiveLocation } from './directiveLocation.js'; import { Kind } from './kinds.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'; @@ -108,6 +116,12 @@ 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 with a restricted syntax. + */ + lexer?: LexerInterface | undefined; } /** @@ -182,6 +196,27 @@ 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, +): SchemaCoordinateNode { + 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); + return coordinate; +} + /** * This class is exported only to assist people in implementing their own parsers * without duplicating too much code and should be used only as last resort for cases @@ -194,15 +229,21 @@ export function parseType( * @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; - this._lexer = new Lexer(sourceObj); - this._options = options; + if (lexer) { + this._lexer = lexer; + } else { + const sourceObj = isSource(source) ? source : new Source(source); + this._lexer = new Lexer(sourceObj); + } + + this._options = _options; this._tokenCounter = 0; } @@ -1433,6 +1474,68 @@ export class Parser { throw this.unexpected(start); } + // Schema Coordinates + + /** + * SchemaCoordinate : + * - Name + * - Name . Name + * - Name . Name ( Name : ) + * - @ Name + * - @ Name ( Name : ) + */ + parseSchemaCoordinate(): SchemaCoordinateNode { + const start = this._lexer.token; + const ofDirective = this.expectOptionalToken(TokenKind.AT); + const name = this.parseName(); + let memberName: NameNode | undefined; + if (!ofDirective && this.expectOptionalToken(TokenKind.DOT)) { + memberName = this.parseName(); + } + let argumentName: NameNode | undefined; + if ( + (ofDirective || memberName) && + this.expectOptionalToken(TokenKind.PAREN_L) + ) { + argumentName = this.parseName(); + this.expectToken(TokenKind.COLON); + this.expectToken(TokenKind.PAREN_R); + } + + if (ofDirective) { + if (argumentName) { + return this.node(start, { + kind: Kind.DIRECTIVE_ARGUMENT_COORDINATE, + name, + argumentName, + }); + } + return this.node(start, { + kind: Kind.DIRECTIVE_COORDINATE, + name, + }); + } else if (memberName) { + if (argumentName) { + return this.node(start, { + kind: Kind.ARGUMENT_COORDINATE, + name, + fieldName: memberName, + argumentName, + }); + } + return this.node(start, { + kind: Kind.MEMBER_COORDINATE, + name, + memberName, + }); + } + + return this.node(start, { + kind: Kind.TYPE_COORDINATE, + name, + }); + } + // Core parsing utility functions /** diff --git a/src/language/predicates.ts b/src/language/predicates.ts index 29ad5bf289..5146e8244e 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -3,6 +3,7 @@ import type { ConstValueNode, DefinitionNode, ExecutableDefinitionNode, + SchemaCoordinateNode, SelectionNode, TypeDefinitionNode, TypeExtensionNode, @@ -110,3 +111,15 @@ export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode { node.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION ); } + +export function isSchemaCoordinateNode( + node: ASTNode, +): node is SchemaCoordinateNode { + return ( + node.kind === Kind.TYPE_COORDINATE || + node.kind === Kind.MEMBER_COORDINATE || + node.kind === Kind.ARGUMENT_COORDINATE || + node.kind === Kind.DIRECTIVE_COORDINATE || + node.kind === Kind.DIRECTIVE_ARGUMENT_COORDINATE + ); +} diff --git a/src/language/printer.ts b/src/language/printer.ts index 0ef7e22116..e0663a56bf 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -320,6 +320,26 @@ const printDocASTReducer: ASTReducer = { leave: ({ name, directives, fields }) => join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + + // Schema Coordinates + + TypeCoordinate: { leave: ({ name }) => name }, + + MemberCoordinate: { + leave: ({ name, memberName }) => join([name, wrap('.', memberName)]), + }, + + ArgumentCoordinate: { + leave: ({ name, fieldName, argumentName }) => + join([name, wrap('.', fieldName), wrap('(', argumentName, ':)')]), + }, + + DirectiveCoordinate: { leave: ({ name }) => join(['@', name]) }, + + DirectiveArgumentCoordinate: { + leave: ({ name, argumentName }) => + join(['@', name, wrap('(', argumentName, ':)')]), + }, }; /** diff --git a/src/language/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); +} diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index d1c7129b04..eae0972b81 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -10,6 +10,7 @@ export const TokenKind = { AMP: '&' as const, PAREN_L: '(' as const, PAREN_R: ')' as const, + DOT: '.' as const, SPREAD: '...' as const, COLON: ':' as const, EQUALS: '=' as const, diff --git a/src/utilities/__tests__/resolveSchemaCoordinate-test.ts b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts new file mode 100644 index 0000000000..ae6435f137 --- /dev/null +++ b/src/utilities/__tests__/resolveSchemaCoordinate-test.ts @@ -0,0 +1,249 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { + GraphQLEnumType, + GraphQLField, + GraphQLInputObjectType, + GraphQLObjectType, +} from '../../type/definition.js'; +import type { GraphQLDirective } from '../../type/directives.js'; + +import { buildSchema } from '../buildASTSchema.js'; +import { resolveSchemaCoordinate } from '../resolveSchemaCoordinate.js'; + +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', + type: schema.getType('Business'), + }); + + expect(resolveSchemaCoordinate(schema, 'String')).to.deep.equal({ + kind: 'NamedType', + type: schema.getType('String'), + }); + + expect(resolveSchemaCoordinate(schema, 'private')).to.deep.equal(undefined); + + expect(resolveSchemaCoordinate(schema, 'Unknown')).to.deep.equal(undefined); + }); + + it('resolves a Type Field', () => { + const type = schema.getType('Business') as GraphQLObjectType; + const field = type.getFields().name; + expect(resolveSchemaCoordinate(schema, 'Business.name')).to.deep.equal({ + kind: 'Field', + type, + field, + }); + + expect(resolveSchemaCoordinate(schema, 'Business.unknown')).to.deep.equal( + undefined, + ); + + expect(() => resolveSchemaCoordinate(schema, 'Unknown.field')).to.throw( + 'Expected "Unknown" to be defined as a type in the schema.', + ); + + expect(() => resolveSchemaCoordinate(schema, 'String.field')).to.throw( + 'Expected "String" to be an Enum, Input Object, Object or Interface type.', + ); + }); + + it('resolves a Input Field', () => { + const type = schema.getType('SearchCriteria') as GraphQLInputObjectType; + const inputField = type.getFields().filter; + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.filter'), + ).to.deep.equal({ + kind: 'InputField', + type, + inputField, + }); + + expect( + resolveSchemaCoordinate(schema, 'SearchCriteria.unknown'), + ).to.deep.equal(undefined); + }); + + it('resolves a Enum Value', () => { + const type = schema.getType('SearchFilter') as GraphQLEnumType; + const enumValue = type.getValue('OPEN_NOW'); + expect( + resolveSchemaCoordinate(schema, 'SearchFilter.OPEN_NOW'), + ).to.deep.equal({ + kind: 'EnumValue', + type, + enumValue, + }); + + expect( + resolveSchemaCoordinate(schema, 'SearchFilter.UNKNOWN'), + ).to.deep.equal(undefined); + }); + + it('resolves a Field Argument', () => { + const type = schema.getType('Query') as GraphQLObjectType; + const field = type.getFields().searchBusiness; + const fieldArgument = field.args.find((arg) => arg.name === 'criteria'); + expect( + resolveSchemaCoordinate(schema, 'Query.searchBusiness(criteria:)'), + ).to.deep.equal({ + kind: 'FieldArgument', + type, + field, + fieldArgument, + }); + + expect( + resolveSchemaCoordinate(schema, 'Business.name(unknown:)'), + ).to.deep.equal(undefined); + + expect(() => + resolveSchemaCoordinate(schema, 'Unknown.field(arg:)'), + ).to.throw('Expected "Unknown" to be defined as a type in the schema.'); + + expect(() => + resolveSchemaCoordinate(schema, 'Business.unknown(arg:)'), + ).to.throw( + 'Expected "unknown" to exist as a field of type "Business" in the schema.', + ); + + expect(() => + resolveSchemaCoordinate(schema, 'SearchCriteria.name(arg:)'), + ).to.throw( + 'Expected "SearchCriteria" to be an object type or interface type.', + ); + }); + + it('resolves a Directive', () => { + expect(resolveSchemaCoordinate(schema, '@private')).to.deep.equal({ + kind: 'Directive', + directive: schema.getDirective('private'), + }); + + expect(resolveSchemaCoordinate(schema, '@deprecated')).to.deep.equal({ + kind: 'Directive', + directive: schema.getDirective('deprecated'), + }); + + expect(resolveSchemaCoordinate(schema, '@unknown')).to.deep.equal( + undefined, + ); + + expect(resolveSchemaCoordinate(schema, '@Business')).to.deep.equal( + undefined, + ); + }); + + it('resolves a Directive Argument', () => { + const directive = schema.getDirective('private') as GraphQLDirective; + const directiveArgument = directive.args.find( + (arg) => arg.name === 'scope', + ); + expect(resolveSchemaCoordinate(schema, '@private(scope:)')).to.deep.equal({ + kind: 'DirectiveArgument', + directive, + directiveArgument, + }); + + expect(resolveSchemaCoordinate(schema, '@private(unknown:)')).to.deep.equal( + undefined, + ); + + expect(() => resolveSchemaCoordinate(schema, '@unknown(arg:)')).to.throw( + 'Expected "unknown" to be defined as a directive in the schema.', + ); + }); +}); + +/* + * 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({ + kind: 'Field', + type, + field, + }); + }); + + it('resolves a meta-field argument', () => { + const type = schema.getType('Query') as GraphQLObjectType; + const field = schema.getField(type, '__type') as GraphQLField; + const fieldArgument = field.args.find((arg) => arg.name === 'name'); + expect( + resolveSchemaCoordinate(schema, 'Query.__type(name:)'), + ).to.deep.equal({ + kind: 'FieldArgument', + type, + field, + fieldArgument, + }); + }); + + it('resolves an Introspection Type', () => { + expect(resolveSchemaCoordinate(schema, '__Type')).to.deep.equal({ + kind: 'NamedType', + type: schema.getType('__Type'), + }); + }); + + it('resolves an Introspection Type Field', () => { + const type = schema.getType('__Directive') as GraphQLObjectType; + const field = type.getFields().name; + expect(resolveSchemaCoordinate(schema, '__Directive.name')).to.deep.equal({ + kind: 'Field', + type, + field, + }); + }); + + it('resolves an Introspection Type Enum Value', () => { + const type = schema.getType('__DirectiveLocation') as GraphQLEnumType; + const enumValue = type.getValue('INLINE_FRAGMENT'); + expect( + resolveSchemaCoordinate(schema, '__DirectiveLocation.INLINE_FRAGMENT'), + ).to.deep.equal({ + kind: 'EnumValue', + type, + enumValue, + }); + }); +}); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 5b891cded1..470ff1ee29 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -127,3 +127,10 @@ export type { // Wrapper type that contains DocumentNode and types that can be deduced from it. export type { TypedQueryDocumentNode } from './typedQueryDocumentNode.js'; + +// Schema coordinates +export { + resolveSchemaCoordinate, + resolveASTSchemaCoordinate, +} from './resolveSchemaCoordinate.js'; +export type { ResolvedSchemaElement } from './resolveSchemaCoordinate.js'; diff --git a/src/utilities/resolveSchemaCoordinate.ts b/src/utilities/resolveSchemaCoordinate.ts new file mode 100644 index 0000000000..018cb0eed4 --- /dev/null +++ b/src/utilities/resolveSchemaCoordinate.ts @@ -0,0 +1,319 @@ +import { inspect } from '../jsutils/inspect.js'; + +import type { + ArgumentCoordinateNode, + DirectiveArgumentCoordinateNode, + DirectiveCoordinateNode, + MemberCoordinateNode, + SchemaCoordinateNode, + TypeCoordinateNode, +} from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; +import { parseSchemaCoordinate } from '../language/parser.js'; +import type { Source } from '../language/source.js'; + +import type { + GraphQLArgument, + GraphQLEnumType, + GraphQLEnumValue, + GraphQLField, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLNamedType, + GraphQLObjectType, +} from '../type/definition.js'; +import { + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, +} from '../type/definition.js'; +import type { GraphQLDirective } from '../type/directives.js'; +import type { GraphQLSchema } from '../type/schema.js'; + +/** + * A resolved schema element may be one of the following kinds: + */ +export interface ResolvedNamedType { + readonly kind: 'NamedType'; + readonly type: GraphQLNamedType; +} + +export interface ResolvedField { + readonly kind: 'Field'; + readonly type: GraphQLObjectType | GraphQLInterfaceType; + readonly field: GraphQLField; +} + +export interface ResolvedInputField { + readonly kind: 'InputField'; + readonly type: GraphQLInputObjectType; + readonly inputField: GraphQLInputField; +} + +export interface ResolvedEnumValue { + readonly kind: 'EnumValue'; + readonly type: GraphQLEnumType; + readonly enumValue: GraphQLEnumValue; +} + +export interface ResolvedFieldArgument { + readonly kind: 'FieldArgument'; + readonly type: GraphQLObjectType | GraphQLInterfaceType; + readonly field: GraphQLField; + readonly fieldArgument: GraphQLArgument; +} + +export interface ResolvedDirective { + readonly kind: 'Directive'; + readonly directive: GraphQLDirective; +} + +export interface ResolvedDirectiveArgument { + readonly kind: 'DirectiveArgument'; + readonly directive: GraphQLDirective; + readonly directiveArgument: GraphQLArgument; +} + +export type ResolvedSchemaElement = + | ResolvedNamedType + | ResolvedField + | ResolvedInputField + | ResolvedEnumValue + | ResolvedFieldArgument + | ResolvedDirective + | ResolvedDirectiveArgument; + +/** + * A schema coordinate is resolved in the context of a GraphQL schema to + * uniquely 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 + */ +export function resolveSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: string | Source, +): ResolvedSchemaElement | undefined { + return resolveASTSchemaCoordinate( + schema, + parseSchemaCoordinate(schemaCoordinate), + ); +} + +/** + * TypeCoordinate : Name + */ +function resolveTypeCoordinate( + schema: GraphQLSchema, + schemaCoordinate: TypeCoordinateNode, +): ResolvedNamedType | undefined { + // 1. Let {typeName} be the value of {Name}. + const typeName = schemaCoordinate.name.value; + const type = schema.getType(typeName); + + // 2. Return the type in the {schema} named {typeName} if it exists. + if (type == null) { + return; + } + + return { kind: 'NamedType', type }; +} + +/** + * MemberCoordinate : Name . Name + */ +function resolveMemberCoordinate( + schema: GraphQLSchema, + schemaCoordinate: MemberCoordinateNode, +): ResolvedField | ResolvedInputField | ResolvedEnumValue | undefined { + // 1. Let {typeName} be the value of the first {Name}. + // 2. Let {type} be the type in the {schema} named {typeName}. + const typeName = schemaCoordinate.name.value; + const type = schema.getType(typeName); + + // 3. Assert: {type} must exist, and must be an Enum, Input Object, Object or Interface type. + if (!type) { + throw new Error( + `Expected ${inspect(typeName)} to be defined as a type in the schema.`, + ); + } + if ( + !isEnumType(type) && + !isInputObjectType(type) && + !isObjectType(type) && + !isInterfaceType(type) + ) { + throw new Error( + `Expected ${inspect(typeName)} to be an Enum, Input Object, Object or Interface type.`, + ); + } + + // 4. If {type} is an Enum type: + if (isEnumType(type)) { + // 1. Let {enumValueName} be the value of the second {Name}. + const enumValueName = schemaCoordinate.memberName.value; + const enumValue = type.getValue(enumValueName); + + // 2. Return the enum value of {type} named {enumValueName} if it exists. + if (enumValue == null) { + return; + } + + return { kind: 'EnumValue', type, enumValue }; + } + + // 5. Otherwise, if {type} is an Input Object type: + if (isInputObjectType(type)) { + // 1. Let {inputFieldName} be the value of the second {Name}. + const inputFieldName = schemaCoordinate.memberName.value; + const inputField = type.getFields()[inputFieldName]; + + // 2. Return the input field of {type} named {inputFieldName} if it exists. + if (inputField == null) { + return; + } + + return { kind: 'InputField', type, inputField }; + } + + // 6. Otherwise: + // 1. Let {fieldName} be the value of the second {Name}. + const fieldName = schemaCoordinate.memberName.value; + const field = schema.getField(type, fieldName); + + // 2. Return the field of {type} named {fieldName} if it exists. + if (field == null) { + return; + } + + return { kind: 'Field', type, field }; +} + +/** + * ArgumentCoordinate : Name . Name ( Name : ) + */ +function resolveArgumentCoordinate( + schema: GraphQLSchema, + schemaCoordinate: ArgumentCoordinateNode, +): ResolvedFieldArgument | undefined { + // 1. Let {typeName} be the value of the first {Name}. + // 2. Let {type} be the type in the {schema} named {typeName}. + const typeName = schemaCoordinate.name.value; + const type = schema.getType(typeName); + + // 3. Assert: {type} must exist, and be an Object or Interface type. + if (type == null) { + throw new Error( + `Expected ${inspect(typeName)} to be defined as a type in the schema.`, + ); + } + if (!isObjectType(type) && !isInterfaceType(type)) { + throw new Error( + `Expected ${inspect(typeName)} to be an object type or interface type.`, + ); + } + + // 4. Let {fieldName} be the value of the second {Name}. + // 5. Let {field} be the field of {type} named {fieldName}. + const fieldName = schemaCoordinate.fieldName.value; + const field = schema.getField(type, fieldName); + + // 7. Assert: {field} must exist. + if (field == null) { + throw new Error( + `Expected ${inspect(fieldName)} to exist as a field of type ${inspect(typeName)} in the schema.`, + ); + } + + // 7. Let {fieldArgumentName} be the value of the third {Name}. + const fieldArgumentName = schemaCoordinate.argumentName.value; + const fieldArgument = field.args.find( + (arg) => arg.name === fieldArgumentName, + ); + + // 8. Return the argument of {field} named {fieldArgumentName} if it exists. + if (fieldArgument == null) { + return; + } + + return { kind: 'FieldArgument', type, field, fieldArgument }; +} + +/** + * DirectiveCoordinate : @ Name + */ +function resolveDirectiveCoordinate( + schema: GraphQLSchema, + schemaCoordinate: DirectiveCoordinateNode, +): ResolvedDirective | undefined { + // 1. Let {directiveName} be the value of {Name}. + const directiveName = schemaCoordinate.name.value; + const directive = schema.getDirective(directiveName); + + // 2. Return the directive in the {schema} named {directiveName} if it 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} if it exists. + if (!directiveArgument) { + return; + } + + return { kind: 'DirectiveArgument', directive, directiveArgument }; +} + +/** + * Resolves schema coordinate from a parsed SchemaCoordinate node. + */ +export function resolveASTSchemaCoordinate( + schema: GraphQLSchema, + schemaCoordinate: SchemaCoordinateNode, +): ResolvedSchemaElement | undefined { + switch (schemaCoordinate.kind) { + case Kind.TYPE_COORDINATE: + return resolveTypeCoordinate(schema, schemaCoordinate); + case Kind.MEMBER_COORDINATE: + return resolveMemberCoordinate(schema, schemaCoordinate); + case Kind.ARGUMENT_COORDINATE: + return resolveArgumentCoordinate(schema, schemaCoordinate); + case Kind.DIRECTIVE_COORDINATE: + return resolveDirectiveCoordinate(schema, schemaCoordinate); + case Kind.DIRECTIVE_ARGUMENT_COORDINATE: + return resolveDirectiveArgumentCoordinate(schema, schemaCoordinate); + } +}