From 1615f125229cfffe71bbcb6fc338d4025346c1a8 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 17 Sep 2025 19:06:43 +0200 Subject: [PATCH 1/2] Implement changes for executable descriptions Co-Authored-By: fotoetienne <693596+fotoetienne@users.noreply.github.com> --- src/__testUtils__/kitchenSinkQuery.ts | 10 +- src/language/__tests__/parser-test.ts | 160 +++++++++++++++++++ src/language/__tests__/printer-test.ts | 21 ++- src/language/__tests__/schema-parser-test.ts | 4 +- src/language/__tests__/visitor-test.ts | 6 + src/language/ast.ts | 7 +- src/language/parser.ts | 24 ++- src/language/printer.ts | 11 +- 8 files changed, 228 insertions(+), 15 deletions(-) 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..03e10f8180 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,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..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..b3b871d41a 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 and shorthand queries.', 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 and shorthand queries.', 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..9aa2799157 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -198,12 +198,13 @@ 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 +213,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 +318,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 +336,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 +401,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..2198de7342 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -288,21 +288,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, descriptions are not supported on type extensions and shorthand queries.', ); } switch (keywordToken.value) { - case 'query': - case 'mutation': - case 'subscription': - return this.parseOperationDefinition(); - case 'fragment': - return this.parseFragmentDefinition(); case 'extend': return this.parseTypeSystemExtension(); } @@ -324,12 +327,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 +343,7 @@ export class Parser { return this.node(start, { kind: Kind.OPERATION_DEFINITION, operation, + description, name, variableDefinitions: this.parseVariableDefinitions(), directives: this.parseDirectives(false), @@ -379,6 +385,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 +533,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 +541,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 +551,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..4600e348ab 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -28,8 +28,10 @@ const printDocASTReducer: ASTReducer = { OperationDefinition: { leave(node) { - const varDefs = wrap('(', join(node.variableDefinitions, ', '), ')'); - const prefix = join( + 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]), @@ -45,7 +47,8 @@ const printDocASTReducer: ASTReducer = { }, VariableDefinition: { - leave: ({ variable, type, defaultValue, directives }) => + leave: ({ variable, type, defaultValue, directives, description }) => + wrap('', description, '\n') + variable + ': ' + type + @@ -96,7 +99,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, ', '), ')')} ` + From b6a04a7357d6e7b6c37e3f060825dab8f6e1659d Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 17 Sep 2025 19:22:56 +0200 Subject: [PATCH 2/2] Fix tests and formatting --- src/language/__tests__/parser-test.ts | 259 ++++++++++++++++--- src/language/__tests__/schema-parser-test.ts | 4 +- src/language/ast.ts | 8 +- src/language/parser.ts | 11 +- src/language/printer.ts | 20 +- 5 files changed, 252 insertions(+), 50 deletions(-) diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index 03e10f8180..07ed14963a 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -409,7 +409,7 @@ describe('Parser', () => { expectJSON(result).toDeepEqual({ kind: Kind.DOCUMENT, - loc: { start: 0, end: 43}, + loc: { start: 0, end: 43 }, definitions: [ { kind: Kind.OPERATION_DEFINITION, @@ -729,7 +729,7 @@ describe('Parser', () => { }); }); - describe('operation and variable definition descriptions', () => { + describe('operation and variable definition descriptions', () => { it('parses operation with description and variable descriptions', () => { const result = parse(dedent` "Operation description" @@ -742,6 +742,7 @@ describe('Parser', () => { field(a: $a, b: $b) } `); + // Find the operation definition const opDef = result.definitions.find( (d) => d.kind === Kind.OPERATION_DEFINITION, @@ -749,27 +750,150 @@ describe('Parser', () => { 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', + + 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.', ); - 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', () => { @@ -788,21 +912,58 @@ describe('Parser', () => { 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'); + 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, ); @@ -810,11 +971,37 @@ describe('Parser', () => { 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'); - } + + 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__/schema-parser-test.ts b/src/language/__tests__/schema-parser-test.ts index b3b871d41a..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 not supported on type extensions and shorthand queries.', + '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 not supported on type extensions and shorthand queries.', + 'Syntax Error: Unexpected description, only GraphQL definitions support descriptions.', locations: [{ line: 2, column: 7 }], }); diff --git a/src/language/ast.ts b/src/language/ast.ts index 9aa2799157..2a559a2d4a 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -204,7 +204,13 @@ export const QueryDocumentKeys: { 'directives', 'selectionSet', ], - VariableDefinition: ['description', 'variable', 'type', 'defaultValue', 'directives'], + VariableDefinition: [ + 'description', + 'variable', + 'type', + 'defaultValue', + 'directives', + ], Variable: ['name'], SelectionSet: ['selections'], Field: ['alias', 'name', 'arguments', 'directives', 'selectionSet'], diff --git a/src/language/parser.ts b/src/language/parser.ts index 2198de7342..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': @@ -301,7 +308,7 @@ export class Parser { throw syntaxError( this._lexer.source, this._lexer.token.start, - 'Unexpected description, descriptions are not supported on type extensions and shorthand queries.', + 'Unexpected description, only GraphQL definitions support descriptions.', ); } @@ -551,7 +558,7 @@ export class Parser { } return this.node(start, { kind: Kind.FRAGMENT_DEFINITION, - description, + 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 4600e348ab..ebfa7dea95 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -31,14 +31,16 @@ const printDocASTReducer: ASTReducer = { 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, ' '), - ], - ' ', - ); + 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. @@ -99,7 +101,7 @@ const printDocASTReducer: ASTReducer = { variableDefinitions, directives, selectionSet, - description + description, }) => wrap('', description, '\n') + // Note: fragment variable definitions are experimental and may be changed