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..07ed14963a 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -258,6 +258,7 @@ describe('Parser', () => { definitions: [ { kind: Kind.OPERATION_DEFINITION, + description: undefined, loc: { start: 0, end: 40 }, operation: 'query', name: undefined, @@ -349,6 +350,7 @@ describe('Parser', () => { { kind: Kind.OPERATION_DEFINITION, loc: { start: 0, end: 29 }, + description: undefined, operation: 'query', name: undefined, variableDefinitions: [], @@ -395,6 +397,75 @@ describe('Parser', () => { }); }); + it('creates ast from nameless query with description', () => { + const result = parse(dedent` + "Description" + query { + node { + id + } + } + `); + + expectJSON(result).toDeepEqual({ + kind: Kind.DOCUMENT, + loc: { start: 0, end: 43 }, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + loc: { start: 0, end: 43 }, + description: { + kind: Kind.STRING, + loc: { start: 0, end: 13 }, + value: 'Description', + block: false, + }, + operation: 'query', + name: undefined, + variableDefinitions: [], + directives: [], + selectionSet: { + kind: Kind.SELECTION_SET, + loc: { start: 20, end: 43 }, + selections: [ + { + kind: Kind.FIELD, + loc: { start: 24, end: 41 }, + alias: undefined, + name: { + kind: Kind.NAME, + loc: { start: 24, end: 28 }, + value: 'node', + }, + arguments: [], + directives: [], + selectionSet: { + kind: Kind.SELECTION_SET, + loc: { start: 29, end: 41 }, + selections: [ + { + kind: Kind.FIELD, + loc: { start: 35, end: 37 }, + alias: undefined, + name: { + kind: Kind.NAME, + loc: { start: 35, end: 37 }, + value: 'id', + }, + arguments: [], + directives: [], + selectionSet: undefined, + }, + ], + }, + }, + ], + }, + }, + ], + }); + }); + it('allows parsing without source location information', () => { const result = parse('{ id }', { noLocation: true }); expect('loc' in result).to.equal(false); @@ -657,4 +728,280 @@ 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) + } + `); + + // Find the operation definition + const opDef = result.definitions.find( + (d) => d.kind === Kind.OPERATION_DEFINITION, + ); + if (!opDef || opDef.kind !== Kind.OPERATION_DEFINITION) { + throw new Error('No operation definition found'); + } + + expectJSON(opDef).toDeepEqual({ + kind: Kind.OPERATION_DEFINITION, + operation: 'query', + description: { + kind: Kind.STRING, + value: 'Operation description', + block: false, + loc: { start: 0, end: 23 }, + }, + name: { + kind: Kind.NAME, + value: 'myQuery', + loc: { start: 30, end: 37 }, + }, + variableDefinitions: [ + { + kind: Kind.VARIABLE_DEFINITION, + description: { + kind: Kind.STRING, + value: 'Variable a description', + block: false, + loc: { start: 41, end: 65 }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'a', + loc: { start: 69, end: 70 }, + }, + loc: { start: 68, end: 70 }, + }, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Int', + loc: { start: 72, end: 75 }, + }, + loc: { start: 72, end: 75 }, + }, + defaultValue: undefined, + directives: [], + loc: { start: 41, end: 75 }, + }, + { + kind: Kind.VARIABLE_DEFINITION, + description: { + kind: Kind.STRING, + value: 'Variable b\nmultiline description', + block: true, + loc: { start: 79, end: 117 }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'b', + loc: { start: 121, end: 122 }, + }, + loc: { start: 120, end: 122 }, + }, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'String', + loc: { start: 124, end: 130 }, + }, + loc: { start: 124, end: 130 }, + }, + defaultValue: undefined, + directives: [], + loc: { start: 79, end: 130 }, + }, + ], + directives: [], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + alias: undefined, + name: { + kind: Kind.NAME, + value: 'field', + loc: { start: 137, end: 142 }, + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'a', + loc: { start: 143, end: 144 }, + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'a', + loc: { start: 147, end: 148 }, + }, + loc: { start: 146, end: 148 }, + }, + loc: { start: 143, end: 148 }, + }, + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'b', + loc: { start: 150, end: 151 }, + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'b', + loc: { start: 154, end: 155 }, + }, + loc: { start: 153, end: 155 }, + }, + loc: { start: 150, end: 155 }, + }, + ], + directives: [], + selectionSet: undefined, + loc: { start: 137, end: 156 }, + }, + ], + loc: { start: 133, end: 158 }, + }, + loc: { start: 0, end: 158 }, + }); + }); + + it('descriptions on a short-hand query produce a sensible error', () => { + const input = `"""Invalid""" + { __typename }`; + expect(() => parse(input)).to.throw( + 'Syntax Error: Unexpected description, descriptions are not supported on shorthand queries.', + ); + }); + + 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.find( + (d) => d.kind === Kind.OPERATION_DEFINITION, + ); + if (!opDef || opDef.kind !== Kind.OPERATION_DEFINITION) { + throw new Error('No operation definition found'); + } + const varDef = opDef.variableDefinitions?.[0]; + expectJSON(varDef).toDeepEqual({ + kind: Kind.VARIABLE_DEFINITION, + defaultValue: { + kind: Kind.INT, + value: '42', + loc: { start: 31, end: 33 }, + }, + directives: [ + { + arguments: [], + kind: Kind.DIRECTIVE, + name: { + kind: Kind.NAME, + value: 'dir', + loc: { start: 35, end: 38 }, + }, + loc: { start: 34, end: 38 }, + }, + ], + description: { + kind: Kind.STRING, + value: 'desc', + block: false, + loc: { start: 10, end: 16 }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'foo', + loc: { start: 20, end: 23 }, + }, + loc: { start: 19, end: 23 }, + }, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Int', + loc: { start: 25, end: 28 }, + }, + loc: { start: 25, end: 28 }, + }, + loc: { start: 10, end: 38 }, + }); + }); + + it('parses fragment with variable description (legacy)', () => { + const result = parse('fragment Foo("desc" $foo: Int) on Bar { baz }', { + allowLegacyFragmentVariables: true, + }); + + const fragDef = result.definitions.find( + (d) => d.kind === Kind.FRAGMENT_DEFINITION, + ); + if (!fragDef || fragDef.kind !== Kind.FRAGMENT_DEFINITION) { + throw new Error('No fragment definition found'); + } + const varDef = fragDef.variableDefinitions?.[0]; + + expectJSON(varDef).toDeepEqual({ + kind: Kind.VARIABLE_DEFINITION, + description: { + kind: Kind.STRING, + value: 'desc', + block: false, + loc: { start: 13, end: 19 }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'foo', + loc: { start: 21, end: 24 }, + }, + loc: { start: 20, end: 24 }, + }, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Int', + loc: { start: 26, end: 29 }, + }, + loc: { start: 26, end: 29 }, + }, + defaultValue: undefined, + directives: [], + loc: { start: 13, end: 29 }, + }); + }); + }); }); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 227e90dd44..5815cc4317 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -138,6 +138,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('prints kitchen sink without altering ast', () => { const ast = parse(kitchenSinkQuery, { noLocation: true }); @@ -150,7 +163,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 +210,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..2a559a2d4a 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 description?: StringValueNode; readonly loc?: Location; readonly operation: OperationTypeNode; readonly name?: NameNode; @@ -333,6 +342,7 @@ export { OperationTypeNode }; export interface VariableDefinitionNode { readonly kind: Kind.VARIABLE_DEFINITION; + readonly description?: StringValueNode; readonly loc?: Location; readonly variable: VariableNode; readonly type: TypeNode; @@ -397,6 +407,7 @@ export interface InlineFragmentNode { export interface FragmentDefinitionNode { readonly kind: Kind.FRAGMENT_DEFINITION; + readonly description?: StringValueNode; readonly loc?: Location; readonly name: NameNode; /** @deprecated variableDefinitions will be removed in v17.0.0 */ diff --git a/src/language/parser.ts b/src/language/parser.ts index 03e4166210..92e8bde8f7 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -268,6 +268,13 @@ export class Parser { ? this._lexer.lookahead() : this._lexer.token; + if (hasDescription && keywordToken.kind === TokenKind.BRACE_L) { + throw syntaxError( + this._lexer.source, + this._lexer.token.start, + 'Unexpected description, descriptions are not supported on shorthand queries.', + ); + } if (keywordToken.kind === TokenKind.NAME) { switch (keywordToken.value) { case 'schema': @@ -288,21 +295,24 @@ export class Parser { return this.parseDirectiveDefinition(); } + switch (keywordToken.value) { + 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(); } @@ -324,12 +334,14 @@ export class Parser { return this.node(start, { kind: Kind.OPERATION_DEFINITION, operation: OperationTypeNode.QUERY, + description: undefined, name: undefined, variableDefinitions: [], directives: [], selectionSet: this.parseSelectionSet(), }); } + const description = this.parseDescription(); const operation = this.parseOperationType(); let name; if (this.peek(TokenKind.NAME)) { @@ -338,6 +350,7 @@ export class Parser { return this.node(start, { kind: Kind.OPERATION_DEFINITION, operation, + description, name, variableDefinitions: this.parseVariableDefinitions(), directives: this.parseDirectives(false), @@ -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..ebfa7dea95 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: ({ variable, type, defaultValue, directives, description }) => + wrap('', description, '\n') + variable + ': ' + type + @@ -96,7 +101,9 @@ const printDocASTReducer: ASTReducer = { variableDefinitions, directives, selectionSet, + description, }) => + wrap('', description, '\n') + // Note: fragment variable definitions are experimental and may be changed // or removed in the future. `fragment ${name}${wrap('(', join(variableDefinitions, ', '), ')')} ` +