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 87e7b92c34..ae58ca39e0 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -158,11 +158,25 @@ describe('Parser', () => { # This comment has a \u0A0A multi-byte character. { field(arg: "Has a \u0A0A multi-byte character.") } `); - - expect(ast).to.have.nested.property( - 'definitions[0].selectionSet.selections[0].arguments[0].value.value', - 'Has a \u0A0A multi-byte character.', + const opDef = ast.definitions.find( + (d) => d.kind === Kind.OPERATION_DEFINITION, ); + if (!opDef || opDef.kind !== Kind.OPERATION_DEFINITION) { + throw new Error('No operation definition found'); + } + const fieldSel = opDef.selectionSet.selections[0]; + if (fieldSel.kind !== Kind.FIELD) { + throw new Error('Expected a field selection'); + } + const args = fieldSel.arguments; + if (!args || args.length === 0) { + throw new Error('No arguments found'); + } + const argValueNode = args[0].value; + if (argValueNode.kind !== Kind.STRING) { + throw new Error('Expected a string value'); + } + expect(argValueNode.value).to.equal('Has a \u0A0A multi-byte character.'); }); it('parses kitchen sink', () => { @@ -254,6 +268,7 @@ describe('Parser', () => { { kind: Kind.OPERATION_DEFINITION, loc: { start: 0, end: 40 }, + description: undefined, operation: 'query', name: undefined, variableDefinitions: [], @@ -330,6 +345,7 @@ describe('Parser', () => { it('creates ast from nameless query without variables', () => { const result = parse(dedent` + "Query description" query { node { id @@ -339,41 +355,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: [], @@ -652,4 +674,93 @@ 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'); + } + 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'); + // Check type names safely + const typeA = opDef.variableDefinitions?.[0].type; + if (typeA && typeA.kind === Kind.NAMED_TYPE) { + expect(typeA.name.value).to.equal('Int'); + } + const typeB = opDef.variableDefinitions?.[1].type; + if (typeB && 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.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]; + expect(varDef?.description?.value).to.equal('desc'); + expect(varDef?.variable.name.value).to.equal('foo'); + if (varDef?.type.kind === Kind.NAMED_TYPE) { + expect(varDef.type.name.value).to.equal('Int'); + } + if (varDef?.defaultValue && 'value' in varDef.defaultValue) { + 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.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]; + expect(varDef?.description?.value).to.equal('desc'); + expect(varDef?.variable.name.value).to.equal('foo'); + if (varDef?.type.kind === Kind.NAMED_TYPE) { + expect(varDef.type.name.value).to.equal('Int'); + } + }); + }); }); 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..a95fdabefc 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 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 29029342a1..8c464ce5ac 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; @@ -333,6 +342,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; @@ -397,6 +407,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 eb54a0376b..de74c1ae5b 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -261,6 +261,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': @@ -277,24 +283,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(); } } @@ -311,9 +307,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: [], @@ -321,6 +319,8 @@ export class Parser { selectionSet: this.parseSelectionSet(), }); } + + const description = this.parseDescription(); const operation = this.parseOperationType(); let name; if (this.peek(TokenKind.NAME)) { @@ -328,6 +328,7 @@ export class Parser { } return this.node(start, { kind: Kind.OPERATION_DEFINITION, + description, operation, name, variableDefinitions: this.parseVariableDefinitions(), @@ -370,6 +371,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) @@ -517,6 +519,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: @@ -524,6 +527,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()), @@ -533,6 +537,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, ', '), ')')} ` +