diff --git a/src/__testUtils__/kitchenSinkQuery.ts b/src/__testUtils__/kitchenSinkQuery.ts index 9ed9a7e983..2da909f497 100644 --- a/src/__testUtils__/kitchenSinkQuery.ts +++ b/src/__testUtils__/kitchenSinkQuery.ts @@ -1,5 +1,10 @@ export const kitchenSinkQuery: string = String.raw` -query queryName($foo: ComplexType, $site: Site = MOBILE) @onQuery { +"Query description" +query queryName( + "Very complex variable" + $foo: ComplexType, + $site: Site = MOBILE +) @onQuery { whoever123is: node(id: [123, 456]) { id ... on User @onInlineFragment { @@ -44,6 +49,9 @@ subscription StoryLikeSubscription( } } +""" + Fragment description +""" fragment frag on Friend @onFragmentDefinition { foo( size: $size diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index caa922a27d..1fcd12408f 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -1,4 +1,4 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; import { dedent } from '../../__testUtils__/dedent'; @@ -57,6 +57,15 @@ describe('Parser', () => { locations: [{ line: 1, column: 1 }], }); + // Throws on first error, the unexpected description. + expectSyntaxError(` + "Unexpected description" + notAnOperation Foo { field } + `).to.deep.include({ + message: 'Syntax Error: Unexpected description, only GraphQL definitions support descriptions.', + locations: [{ line: 2, column: 7 }], + }); + expectSyntaxError('...').to.deep.include({ message: 'Syntax Error: Unexpected "...".', locations: [{ line: 1, column: 1 }], @@ -259,6 +268,7 @@ describe('Parser', () => { { kind: Kind.OPERATION_DEFINITION, loc: { start: 0, end: 40 }, + description: undefined, operation: 'query', name: undefined, variableDefinitions: [], @@ -349,6 +359,7 @@ describe('Parser', () => { { kind: Kind.OPERATION_DEFINITION, loc: { start: 0, end: 29 }, + description: undefined, operation: 'query', name: undefined, variableDefinitions: [], @@ -657,4 +668,99 @@ describe('Parser', () => { }); }); }); + + describe('operation and variable definition descriptions', () => { + it('parses operation with description and variable descriptions', () => { + const result = parse(dedent` + "Operation description" + query myQuery( + "Variable a description" + $a: Int, + """Variable b\nmultiline description""" + $b: String + ) { + field(a: $a, b: $b) + } + `); + + const opDef = result.definitions[0]; + assert(opDef.kind === Kind.OPERATION_DEFINITION); + + expect(opDef.description?.value).to.equal('Operation description'); + expect(opDef.name?.value).to.equal('myQuery'); + expect(opDef.variableDefinitions?.[0].description?.value).to.equal( + 'Variable a description', + ); + expect(opDef.variableDefinitions?.[0].description?.block).to.equal(false); + expect(opDef.variableDefinitions?.[1].description?.value).to.equal( + 'Variable b\nmultiline description', + ); + expect(opDef.variableDefinitions?.[1].description?.block).to.equal(true); + expect(opDef.variableDefinitions?.[0].variable.name.value).to.equal('a'); + expect(opDef.variableDefinitions?.[1].variable.name.value).to.equal('b'); + + const typeA = opDef.variableDefinitions?.[0].type; + assert(typeA?.kind === Kind.NAMED_TYPE); + + expect(typeA.name.value).to.equal('Int'); + + const typeB = opDef.variableDefinitions?.[1].type; + assert(typeB?.kind === Kind.NAMED_TYPE); + + expect(typeB.name.value).to.equal('String'); + }); + + it('parses variable definition with description, default value, and directives', () => { + const result = parse(dedent` + query ( + "desc" + $foo: Int = 42 @dir + ) { + field(foo: $foo) + } + `); + + const opDef = result.definitions[0]; + assert(opDef.kind === Kind.OPERATION_DEFINITION); + const varDef = opDef.variableDefinitions?.[0]; + expect(varDef?.description?.value).to.equal('desc'); + expect(varDef?.variable.name.value).to.equal('foo'); + + assert(varDef?.type.kind === Kind.NAMED_TYPE); + expect(varDef.type.name.value).to.equal('Int'); + + assert(varDef?.defaultValue?.kind === Kind.INT); + expect(varDef.defaultValue.value).to.equal('42'); + + expect(varDef?.directives?.[0].name.value).to.equal('dir'); + }); + + it('parses fragment with variable description (legacy)', () => { + const result = parse('fragment Foo("desc" $foo: Int) on Bar { baz }', { + allowLegacyFragmentVariables: true, + }); + const fragDef = result.definitions[0] + assert(fragDef.kind === Kind.FRAGMENT_DEFINITION); + + const varDef = fragDef.variableDefinitions?.[0]; + expect(varDef?.description?.value).to.equal('desc'); + expect(varDef?.variable.name.value).to.equal('foo'); + + assert(varDef?.type.kind === Kind.NAMED_TYPE); + expect(varDef.type.name.value).to.equal('Int'); + }); + + it('produces sensible error for description on shorthand query', () => { + expect(() => + parse(dedent` + "This is a description" + { + field + } + `), + ).to.throw( + 'Syntax Error: Unexpected description, shorthand queries do not support descriptions.', + ); + }); + }); }); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 227e90dd44..270cbcf097 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -44,9 +44,10 @@ describe('Printer: Query document', () => { `); const queryASTWithArtifacts = parse( - 'query ($foo: TestType) @testDirective { id, name }', + '"Query description" query ($foo: TestType) @testDirective { id, name }', ); expect(print(queryASTWithArtifacts)).to.equal(dedent` + "Query description" query ($foo: TestType) @testDirective { id name @@ -54,9 +55,10 @@ describe('Printer: Query document', () => { `); const mutationASTWithArtifacts = parse( - 'mutation ($foo: TestType) @testDirective { id, name }', + '"Mutation description" mutation ($foo: TestType) @testDirective { id, name }', ); expect(print(mutationASTWithArtifacts)).to.equal(dedent` + "Mutation description" mutation ($foo: TestType) @testDirective { id name @@ -66,10 +68,13 @@ describe('Printer: Query document', () => { it('prints query with variable directives', () => { const queryASTWithVariableDirective = parse( - 'query ($foo: TestType = {a: 123} @testDirective(if: true) @test) { id }', + 'query ("Variable description" $foo: TestType = {a: 123} @testDirective(if: true) @test) { id }', ); expect(print(queryASTWithVariableDirective)).to.equal(dedent` - query ($foo: TestType = {a: 123} @testDirective(if: true) @test) { + query ( + "Variable description" + $foo: TestType = {a: 123} @testDirective(if: true) @test + ) { id } `); @@ -110,6 +115,19 @@ describe('Printer: Query document', () => { `); }); + it('prints fragment', () => { + const printed = print( + parse('"Fragment description" fragment Foo on Bar { baz }'), + ); + + expect(printed).to.equal(dedent` + "Fragment description" + fragment Foo on Bar { + baz + } + `); + }); + it('Legacy: prints fragment with variable directives', () => { const queryASTWithVariableDirective = parse( 'fragment Foo($foo: TestType @test) on TestType @testDirective { id }', @@ -150,7 +168,12 @@ describe('Printer: Query document', () => { expect(printed).to.equal( dedentString(String.raw` - query queryName($foo: ComplexType, $site: Site = MOBILE) @onQuery { + "Query description" + query queryName( + "Very complex variable" + $foo: ComplexType + $site: Site = MOBILE + ) @onQuery { whoever123is: node(id: [123, 456]) { id ... on User @onInlineFragment { @@ -192,6 +215,7 @@ describe('Printer: Query document', () => { } } + """Fragment description""" fragment frag on Friend @onFragmentDefinition { foo( size: $size diff --git a/src/language/__tests__/schema-parser-test.ts b/src/language/__tests__/schema-parser-test.ts index cbb337c337..5159939cfd 100644 --- a/src/language/__tests__/schema-parser-test.ts +++ b/src/language/__tests__/schema-parser-test.ts @@ -331,7 +331,7 @@ describe('Schema Parser', () => { } `).to.deep.equal({ message: - 'Syntax Error: Unexpected description, descriptions are supported only on type definitions.', + 'Syntax Error: Unexpected description, only GraphQL definitions support descriptions.', locations: [{ line: 2, column: 7 }], }); @@ -353,7 +353,7 @@ describe('Schema Parser', () => { } `).to.deep.equal({ message: - 'Syntax Error: Unexpected description, descriptions are supported only on type definitions.', + 'Syntax Error: Unexpected description, only GraphQL definitions support descriptions.', locations: [{ line: 2, column: 7 }], }); diff --git a/src/language/__tests__/visitor-test.ts b/src/language/__tests__/visitor-test.ts index 9149b103e3..930a3be555 100644 --- a/src/language/__tests__/visitor-test.ts +++ b/src/language/__tests__/visitor-test.ts @@ -539,9 +539,13 @@ describe('Visitor', () => { expect(visited).to.deep.equal([ ['enter', 'Document', undefined, undefined], ['enter', 'OperationDefinition', 0, undefined], + ['enter', 'StringValue', 'description', 'OperationDefinition'], + ['leave', 'StringValue', 'description', 'OperationDefinition'], ['enter', 'Name', 'name', 'OperationDefinition'], ['leave', 'Name', 'name', 'OperationDefinition'], ['enter', 'VariableDefinition', 0, undefined], + ['enter', 'StringValue', 'description', 'VariableDefinition'], + ['leave', 'StringValue', 'description', 'VariableDefinition'], ['enter', 'Variable', 'variable', 'VariableDefinition'], ['enter', 'Name', 'name', 'Variable'], ['leave', 'Name', 'name', 'Variable'], @@ -793,6 +797,8 @@ describe('Visitor', () => { ['leave', 'SelectionSet', 'selectionSet', 'OperationDefinition'], ['leave', 'OperationDefinition', 2, undefined], ['enter', 'FragmentDefinition', 3, undefined], + ['enter', 'StringValue', 'description', 'FragmentDefinition'], + ['leave', 'StringValue', 'description', 'FragmentDefinition'], ['enter', 'Name', 'name', 'FragmentDefinition'], ['leave', 'Name', 'name', 'FragmentDefinition'], ['enter', 'NamedType', 'typeCondition', 'FragmentDefinition'], diff --git a/src/language/ast.ts b/src/language/ast.ts index 6137eb6c1a..7abf058824 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -198,12 +198,19 @@ export const QueryDocumentKeys: { Document: ['definitions'], OperationDefinition: [ + 'description', 'name', 'variableDefinitions', 'directives', 'selectionSet', ], - VariableDefinition: ['variable', 'type', 'defaultValue', 'directives'], + VariableDefinition: [ + 'description', + 'variable', + 'type', + 'defaultValue', + 'directives', + ], Variable: ['name'], SelectionSet: ['selections'], Field: ['alias', 'name', 'arguments', 'directives', 'selectionSet'], @@ -212,6 +219,7 @@ export const QueryDocumentKeys: { FragmentSpread: ['name', 'directives'], InlineFragment: ['typeCondition', 'directives', 'selectionSet'], FragmentDefinition: [ + 'description', 'name', // Note: fragment variable definitions are deprecated and will removed in v17.0.0 'variableDefinitions', @@ -317,6 +325,7 @@ export type ExecutableDefinitionNode = export interface OperationDefinitionNode { readonly kind: Kind.OPERATION_DEFINITION; readonly loc?: Location; + readonly description?: StringValueNode; readonly operation: OperationTypeNode; readonly name?: NameNode; readonly variableDefinitions?: ReadonlyArray; @@ -334,6 +343,7 @@ export { OperationTypeNode }; export interface VariableDefinitionNode { readonly kind: Kind.VARIABLE_DEFINITION; readonly loc?: Location; + readonly description?: StringValueNode; readonly variable: VariableNode; readonly type: TypeNode; readonly defaultValue?: ConstValueNode; @@ -398,6 +408,7 @@ export interface InlineFragmentNode { export interface FragmentDefinitionNode { readonly kind: Kind.FRAGMENT_DEFINITION; readonly loc?: Location; + readonly description?: StringValueNode; readonly name: NameNode; /** @deprecated variableDefinitions will be removed in v17.0.0 */ readonly variableDefinitions?: ReadonlyArray; diff --git a/src/language/parser.ts b/src/language/parser.ts index 03e4166210..406538cc0a 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -258,9 +258,6 @@ export class Parser { * - InputObjectTypeDefinition */ parseDefinition(): DefinitionNode { - if (this.peek(TokenKind.BRACE_L)) { - return this.parseOperationDefinition(); - } // Many definitions begin with a description and require a lookahead. const hasDescription = this.peekDescription(); @@ -268,6 +265,18 @@ export class Parser { ? this._lexer.lookahead() : this._lexer.token; + if (keywordToken.kind === TokenKind.BRACE_L) { + // Check for shorthand query with description + if (hasDescription) { + throw syntaxError( + this._lexer.source, + this._lexer.token.start, + 'Unexpected description, shorthand queries do not support descriptions.', + ); + } + return this.parseOperationDefinition(); + } + if (keywordToken.kind === TokenKind.NAME) { switch (keywordToken.value) { case 'schema': @@ -286,25 +295,24 @@ export class Parser { return this.parseInputObjectTypeDefinition(); case 'directive': return this.parseDirectiveDefinition(); + case 'query': + case 'mutation': + case 'subscription': + return this.parseOperationDefinition(); + case 'fragment': + return this.parseFragmentDefinition(); } if (hasDescription) { throw syntaxError( this._lexer.source, this._lexer.token.start, - 'Unexpected description, descriptions are supported only on type definitions.', + 'Unexpected description, only GraphQL definitions support descriptions.', ); } - switch (keywordToken.value) { - case 'query': - case 'mutation': - case 'subscription': - return this.parseOperationDefinition(); - case 'fragment': - return this.parseFragmentDefinition(); - case 'extend': - return this.parseTypeSystemExtension(); + if (keywordToken.value === 'extend') { + return this.parseTypeSystemExtension(); } } @@ -320,9 +328,11 @@ export class Parser { */ parseOperationDefinition(): OperationDefinitionNode { const start = this._lexer.token; + if (this.peek(TokenKind.BRACE_L)) { return this.node(start, { kind: Kind.OPERATION_DEFINITION, + description: undefined, operation: OperationTypeNode.QUERY, name: undefined, variableDefinitions: [], @@ -330,6 +340,8 @@ export class Parser { selectionSet: this.parseSelectionSet(), }); } + + const description = this.parseDescription(); const operation = this.parseOperationType(); let name; if (this.peek(TokenKind.NAME)) { @@ -337,6 +349,7 @@ export class Parser { } return this.node(start, { kind: Kind.OPERATION_DEFINITION, + description, operation, name, variableDefinitions: this.parseVariableDefinitions(), @@ -379,6 +392,7 @@ export class Parser { parseVariableDefinition(): VariableDefinitionNode { return this.node(this._lexer.token, { kind: Kind.VARIABLE_DEFINITION, + description: this.parseDescription(), variable: this.parseVariable(), type: (this.expectToken(TokenKind.COLON), this.parseTypeReference()), defaultValue: this.expectOptionalToken(TokenKind.EQUALS) @@ -526,6 +540,7 @@ export class Parser { */ parseFragmentDefinition(): FragmentDefinitionNode { const start = this._lexer.token; + const description = this.parseDescription(); this.expectKeyword('fragment'); // Legacy support for defining variables within fragments changes // the grammar of FragmentDefinition: @@ -533,6 +548,7 @@ export class Parser { if (this._options.allowLegacyFragmentVariables === true) { return this.node(start, { kind: Kind.FRAGMENT_DEFINITION, + description, name: this.parseFragmentName(), variableDefinitions: this.parseVariableDefinitions(), typeCondition: (this.expectKeyword('on'), this.parseNamedType()), @@ -542,6 +558,7 @@ export class Parser { } return this.node(start, { kind: Kind.FRAGMENT_DEFINITION, + description, name: this.parseFragmentName(), typeCondition: (this.expectKeyword('on'), this.parseNamedType()), directives: this.parseDirectives(false), diff --git a/src/language/printer.ts b/src/language/printer.ts index e95c118d8b..d49dc9d655 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -28,15 +28,19 @@ const printDocASTReducer: ASTReducer = { OperationDefinition: { leave(node) { - const varDefs = wrap('(', join(node.variableDefinitions, ', '), ')'); - const prefix = join( - [ - node.operation, - join([node.name, varDefs]), - join(node.directives, ' '), - ], - ' ', - ); + const varDefs = hasMultilineItems(node.variableDefinitions) + ? wrap('(\n', join(node.variableDefinitions, '\n'), '\n)') + : wrap('(', join(node.variableDefinitions, ', '), ')'); + const prefix = + wrap('', node.description, '\n') + + join( + [ + node.operation, + join([node.name, varDefs]), + join(node.directives, ' '), + ], + ' ', + ); // Anonymous queries with no directives or variable definitions can use // the query short form. @@ -45,7 +49,8 @@ const printDocASTReducer: ASTReducer = { }, VariableDefinition: { - leave: ({ variable, type, defaultValue, directives }) => + leave: ({ description, variable, type, defaultValue, directives }) => + wrap('', description, '\n') + variable + ': ' + type + @@ -91,12 +96,14 @@ const printDocASTReducer: ASTReducer = { FragmentDefinition: { leave: ({ + description, name, typeCondition, variableDefinitions, directives, selectionSet, }) => + wrap('', description, '\n') + // Note: fragment variable definitions are experimental and may be changed // or removed in the future. `fragment ${name}${wrap('(', join(variableDefinitions, ', '), ')')} ` +