diff --git a/src/__testUtils__/kitchenSinkQuery.ts b/src/__testUtils__/kitchenSinkQuery.ts index 9ed9a7e983..73b7eda654 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 10b8c6f2ba..44968b7caf 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -241,6 +241,7 @@ describe('Parser', () => { { kind: Kind.OPERATION_DEFINITION, loc: { start: 0, end: 40 }, + description: undefined, operation: 'query', name: undefined, variableDefinitions: [], @@ -317,6 +318,7 @@ describe('Parser', () => { it('creates ast from nameless query without variables', () => { const result = parse(dedent` + "Query description" query { node { id @@ -326,41 +328,47 @@ describe('Parser', () => { expectJSON(result).toDeepEqual({ kind: Kind.DOCUMENT, - loc: { start: 0, end: 29 }, + loc: { start: 0, end: 49 }, definitions: [ { kind: Kind.OPERATION_DEFINITION, - loc: { start: 0, end: 29 }, + loc: { start: 0, end: 49 }, + description: { + kind: Kind.STRING, + loc: { start: 0, end: 19 }, + block: false, + value: 'Query description', + }, operation: 'query', name: undefined, variableDefinitions: [], directives: [], selectionSet: { kind: Kind.SELECTION_SET, - loc: { start: 6, end: 29 }, + loc: { start: 26, end: 49 }, selections: [ { kind: Kind.FIELD, - loc: { start: 10, end: 27 }, + loc: { start: 30, end: 47 }, alias: undefined, name: { kind: Kind.NAME, - loc: { start: 10, end: 14 }, + loc: { start: 30, end: 34 }, value: 'node', }, arguments: [], directives: [], selectionSet: { kind: Kind.SELECTION_SET, - loc: { start: 15, end: 27 }, + loc: { start: 35, end: 47 }, selections: [ { kind: Kind.FIELD, - loc: { start: 21, end: 23 }, + loc: { start: 41, end: 43 }, alias: undefined, name: { kind: Kind.NAME, - loc: { start: 21, end: 23 }, + loc: { start: 41, end: 43 }, value: 'id', }, arguments: [], diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 227e90dd44..45a93cc2d4 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 eef14f0d11..105a6c7c54 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, descriptions are not supported on type extensions.', 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, descriptions are not supported on type extensions.', locations: [{ line: 2, column: 7 }], }); diff --git a/src/language/__tests__/visitor-test.ts b/src/language/__tests__/visitor-test.ts index f45d16e99e..9ec0e369c9 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 0b30366df0..a0bf04f3da 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', @@ -316,6 +324,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; @@ -332,6 +341,7 @@ export enum OperationTypeNode { export interface VariableDefinitionNode { readonly kind: Kind.VARIABLE_DEFINITION; readonly loc?: Location; + readonly description?: StringValueNode; readonly variable: VariableNode; readonly type: TypeNode; readonly defaultValue?: ConstValueNode; @@ -396,6 +406,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 4c1725cce4..67e225ce62 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -250,6 +250,12 @@ export class Parser { if (keywordToken.kind === TokenKind.NAME) { switch (keywordToken.value) { + case 'query': + case 'mutation': + case 'subscription': + return this.parseOperationDefinition(); + case 'fragment': + return this.parseFragmentDefinition(); case 'schema': return this.parseSchemaDefinition(); case 'scalar': @@ -266,24 +272,14 @@ export class Parser { return this.parseInputObjectTypeDefinition(); case 'directive': return this.parseDirectiveDefinition(); - } - - if (hasDescription) { - throw syntaxError( - this._lexer.source, - this._lexer.token.start, - 'Unexpected description, descriptions are supported only on type definitions.', - ); - } - - switch (keywordToken.value) { - case 'query': - case 'mutation': - case 'subscription': - return this.parseOperationDefinition(); - case 'fragment': - return this.parseFragmentDefinition(); case 'extend': + if (hasDescription) { + throw syntaxError( + this._lexer.source, + this._lexer.token.start, + 'Unexpected description, descriptions are not supported on type extensions.', + ); + } return this.parseTypeSystemExtension(); } } @@ -300,9 +296,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: [], @@ -310,6 +308,8 @@ export class Parser { selectionSet: this.parseSelectionSet(), }); } + + const description = this.parseDescription(); const operation = this.parseOperationType(); let name; if (this.peek(TokenKind.NAME)) { @@ -317,6 +317,7 @@ export class Parser { } return this.node(start, { kind: Kind.OPERATION_DEFINITION, + description, operation, name, variableDefinitions: this.parseVariableDefinitions(), @@ -359,6 +360,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) @@ -506,6 +508,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: @@ -513,6 +516,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()), @@ -522,6 +526,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 9df691ac0e..9579010cdc 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -28,15 +28,20 @@ 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', indent(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 +50,8 @@ const printDocASTReducer: ASTReducer = { }, VariableDefinition: { - leave: ({ variable, type, defaultValue, directives }) => + leave: ({ description, variable, type, defaultValue, directives }) => + wrap('', description, '\n') + variable + ': ' + type + @@ -91,14 +97,15 @@ const printDocASTReducer: ASTReducer = { FragmentDefinition: { leave: ({ + description, name, typeCondition, variableDefinitions, directives, selectionSet, }) => - // Note: fragment variable definitions are experimental and may be changed - // or removed in the future. + wrap('', description, '\n') + + // Note: fragment variable definitions are experimental and may be changed or removed in the future. `fragment ${name}${wrap('(', join(variableDefinitions, ', '), ')')} ` + `on ${typeCondition} ${wrap('', join(directives, ' '), ' ')}` + selectionSet,