Skip to content

Commit 6f6e20e

Browse files
committed
Descriptions on executable documents
Enhance GraphQL parser and printer to support descriptions for operations, variables, and fragments This commit is based on graphql#3402 , rebased onto graphql-js 16 and with added tests Implements graphql/graphql-spec#1170
1 parent 4a82557 commit 6f6e20e

File tree

8 files changed

+220
-48
lines changed

8 files changed

+220
-48
lines changed

src/__testUtils__/kitchenSinkQuery.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export const kitchenSinkQuery: string = String.raw`
2-
query queryName($foo: ComplexType, $site: Site = MOBILE) @onQuery {
2+
"Query description"
3+
query queryName(
4+
"Very complex variable"
5+
$foo: ComplexType,
6+
$site: Site = MOBILE
7+
) @onQuery {
38
whoever123is: node(id: [123, 456]) {
49
id
510
... on User @onInlineFragment {
@@ -44,6 +49,9 @@ subscription StoryLikeSubscription(
4449
}
4550
}
4651
52+
"""
53+
Fragment description
54+
"""
4755
fragment frag on Friend @onFragmentDefinition {
4856
foo(
4957
size: $size

src/language/__tests__/parser-test.ts

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,25 @@ describe('Parser', () => {
158158
# This comment has a \u0A0A multi-byte character.
159159
{ field(arg: "Has a \u0A0A multi-byte character.") }
160160
`);
161-
162-
expect(ast).to.have.nested.property(
163-
'definitions[0].selectionSet.selections[0].arguments[0].value.value',
164-
'Has a \u0A0A multi-byte character.',
161+
const opDef = ast.definitions.find(
162+
(d) => d.kind === Kind.OPERATION_DEFINITION,
165163
);
164+
if (!opDef || opDef.kind !== Kind.OPERATION_DEFINITION) {
165+
throw new Error('No operation definition found');
166+
}
167+
const fieldSel = opDef.selectionSet.selections[0];
168+
if (fieldSel.kind !== Kind.FIELD) {
169+
throw new Error('Expected a field selection');
170+
}
171+
const args = fieldSel.arguments;
172+
if (!args || args.length === 0) {
173+
throw new Error('No arguments found');
174+
}
175+
const argValueNode = args[0].value;
176+
if (argValueNode.kind !== Kind.STRING) {
177+
throw new Error('Expected a string value');
178+
}
179+
expect(argValueNode.value).to.equal('Has a \u0A0A multi-byte character.');
166180
});
167181

168182
it('parses kitchen sink', () => {
@@ -254,6 +268,7 @@ describe('Parser', () => {
254268
{
255269
kind: Kind.OPERATION_DEFINITION,
256270
loc: { start: 0, end: 40 },
271+
description: undefined,
257272
operation: 'query',
258273
name: undefined,
259274
variableDefinitions: [],
@@ -330,6 +345,7 @@ describe('Parser', () => {
330345

331346
it('creates ast from nameless query without variables', () => {
332347
const result = parse(dedent`
348+
"Query description"
333349
query {
334350
node {
335351
id
@@ -339,41 +355,47 @@ describe('Parser', () => {
339355

340356
expectJSON(result).toDeepEqual({
341357
kind: Kind.DOCUMENT,
342-
loc: { start: 0, end: 29 },
358+
loc: { start: 0, end: 49 },
343359
definitions: [
344360
{
345361
kind: Kind.OPERATION_DEFINITION,
346-
loc: { start: 0, end: 29 },
362+
loc: { start: 0, end: 49 },
363+
description: {
364+
kind: Kind.STRING,
365+
loc: { start: 0, end: 19 },
366+
block: false,
367+
value: 'Query description',
368+
},
347369
operation: 'query',
348370
name: undefined,
349371
variableDefinitions: [],
350372
directives: [],
351373
selectionSet: {
352374
kind: Kind.SELECTION_SET,
353-
loc: { start: 6, end: 29 },
375+
loc: { start: 26, end: 49 },
354376
selections: [
355377
{
356378
kind: Kind.FIELD,
357-
loc: { start: 10, end: 27 },
379+
loc: { start: 30, end: 47 },
358380
alias: undefined,
359381
name: {
360382
kind: Kind.NAME,
361-
loc: { start: 10, end: 14 },
383+
loc: { start: 30, end: 34 },
362384
value: 'node',
363385
},
364386
arguments: [],
365387
directives: [],
366388
selectionSet: {
367389
kind: Kind.SELECTION_SET,
368-
loc: { start: 15, end: 27 },
390+
loc: { start: 35, end: 47 },
369391
selections: [
370392
{
371393
kind: Kind.FIELD,
372-
loc: { start: 21, end: 23 },
394+
loc: { start: 41, end: 43 },
373395
alias: undefined,
374396
name: {
375397
kind: Kind.NAME,
376-
loc: { start: 21, end: 23 },
398+
loc: { start: 41, end: 43 },
377399
value: 'id',
378400
},
379401
arguments: [],
@@ -652,4 +674,93 @@ describe('Parser', () => {
652674
});
653675
});
654676
});
677+
678+
describe('operation and variable definition descriptions', () => {
679+
it('parses operation with description and variable descriptions', () => {
680+
const result = parse(dedent`
681+
"Operation description"
682+
query myQuery(
683+
"Variable a description"
684+
$a: Int,
685+
"""Variable b\nmultiline description"""
686+
$b: String
687+
) {
688+
field(a: $a, b: $b)
689+
}
690+
`);
691+
// Find the operation definition
692+
const opDef = result.definitions.find(
693+
(d) => d.kind === Kind.OPERATION_DEFINITION,
694+
);
695+
if (!opDef || opDef.kind !== Kind.OPERATION_DEFINITION) {
696+
throw new Error('No operation definition found');
697+
}
698+
expect(opDef.description?.value).to.equal('Operation description');
699+
expect(opDef.name?.value).to.equal('myQuery');
700+
expect(opDef.variableDefinitions?.[0].description?.value).to.equal(
701+
'Variable a description',
702+
);
703+
expect(opDef.variableDefinitions?.[0].description?.block).to.equal(false);
704+
expect(opDef.variableDefinitions?.[1].description?.value).to.equal(
705+
'Variable b\nmultiline description',
706+
);
707+
expect(opDef.variableDefinitions?.[1].description?.block).to.equal(true);
708+
expect(opDef.variableDefinitions?.[0].variable.name.value).to.equal('a');
709+
expect(opDef.variableDefinitions?.[1].variable.name.value).to.equal('b');
710+
// Check type names safely
711+
const typeA = opDef.variableDefinitions?.[0].type;
712+
if (typeA && typeA.kind === Kind.NAMED_TYPE) {
713+
expect(typeA.name.value).to.equal('Int');
714+
}
715+
const typeB = opDef.variableDefinitions?.[1].type;
716+
if (typeB && typeB.kind === Kind.NAMED_TYPE) {
717+
expect(typeB.name.value).to.equal('String');
718+
}
719+
});
720+
721+
it('parses variable definition with description, default value, and directives', () => {
722+
const result = parse(dedent`
723+
query (
724+
"desc"
725+
$foo: Int = 42 @dir
726+
) {
727+
field(foo: $foo)
728+
}
729+
`);
730+
const opDef = result.definitions.find(
731+
(d) => d.kind === Kind.OPERATION_DEFINITION,
732+
);
733+
if (!opDef || opDef.kind !== Kind.OPERATION_DEFINITION) {
734+
throw new Error('No operation definition found');
735+
}
736+
const varDef = opDef.variableDefinitions?.[0];
737+
expect(varDef?.description?.value).to.equal('desc');
738+
expect(varDef?.variable.name.value).to.equal('foo');
739+
if (varDef?.type.kind === Kind.NAMED_TYPE) {
740+
expect(varDef.type.name.value).to.equal('Int');
741+
}
742+
if (varDef?.defaultValue && 'value' in varDef.defaultValue) {
743+
expect(varDef.defaultValue.value).to.equal('42');
744+
}
745+
expect(varDef?.directives?.[0].name.value).to.equal('dir');
746+
});
747+
748+
it('parses fragment with variable description (legacy)', () => {
749+
const result = parse('fragment Foo("desc" $foo: Int) on Bar { baz }', {
750+
allowLegacyFragmentVariables: true,
751+
});
752+
const fragDef = result.definitions.find(
753+
(d) => d.kind === Kind.FRAGMENT_DEFINITION,
754+
);
755+
if (!fragDef || fragDef.kind !== Kind.FRAGMENT_DEFINITION) {
756+
throw new Error('No fragment definition found');
757+
}
758+
const varDef = fragDef.variableDefinitions?.[0];
759+
expect(varDef?.description?.value).to.equal('desc');
760+
expect(varDef?.variable.name.value).to.equal('foo');
761+
if (varDef?.type.kind === Kind.NAMED_TYPE) {
762+
expect(varDef.type.name.value).to.equal('Int');
763+
}
764+
});
765+
});
655766
});

src/language/__tests__/printer-test.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,19 +44,21 @@ describe('Printer: Query document', () => {
4444
`);
4545

4646
const queryASTWithArtifacts = parse(
47-
'query ($foo: TestType) @testDirective { id, name }',
47+
'"Query description" query ($foo: TestType) @testDirective { id, name }',
4848
);
4949
expect(print(queryASTWithArtifacts)).to.equal(dedent`
50+
"Query description"
5051
query ($foo: TestType) @testDirective {
5152
id
5253
name
5354
}
5455
`);
5556

5657
const mutationASTWithArtifacts = parse(
57-
'mutation ($foo: TestType) @testDirective { id, name }',
58+
'"Mutation description" mutation ($foo: TestType) @testDirective { id, name }',
5859
);
5960
expect(print(mutationASTWithArtifacts)).to.equal(dedent`
61+
"Mutation description"
6062
mutation ($foo: TestType) @testDirective {
6163
id
6264
name
@@ -66,10 +68,13 @@ describe('Printer: Query document', () => {
6668

6769
it('prints query with variable directives', () => {
6870
const queryASTWithVariableDirective = parse(
69-
'query ($foo: TestType = {a: 123} @testDirective(if: true) @test) { id }',
71+
'query ("Variable description" $foo: TestType = {a: 123} @testDirective(if: true) @test) { id }',
7072
);
7173
expect(print(queryASTWithVariableDirective)).to.equal(dedent`
72-
query ($foo: TestType = {a: 123} @testDirective(if: true) @test) {
74+
query (
75+
"Variable description"
76+
$foo: TestType = {a: 123} @testDirective(if: true) @test
77+
) {
7378
id
7479
}
7580
`);
@@ -110,6 +115,19 @@ describe('Printer: Query document', () => {
110115
`);
111116
});
112117

118+
it('prints fragment', () => {
119+
const printed = print(
120+
parse('"Fragment description" fragment Foo on Bar { baz }'),
121+
);
122+
123+
expect(printed).to.equal(dedent`
124+
"Fragment description"
125+
fragment Foo on Bar {
126+
baz
127+
}
128+
`);
129+
});
130+
113131
it('Legacy: prints fragment with variable directives', () => {
114132
const queryASTWithVariableDirective = parse(
115133
'fragment Foo($foo: TestType @test) on TestType @testDirective { id }',
@@ -150,7 +168,12 @@ describe('Printer: Query document', () => {
150168

151169
expect(printed).to.equal(
152170
dedentString(String.raw`
153-
query queryName($foo: ComplexType, $site: Site = MOBILE) @onQuery {
171+
"Query description"
172+
query queryName(
173+
"Very complex variable"
174+
$foo: ComplexType
175+
$site: Site = MOBILE
176+
) @onQuery {
154177
whoever123is: node(id: [123, 456]) {
155178
id
156179
... on User @onInlineFragment {
@@ -192,6 +215,7 @@ describe('Printer: Query document', () => {
192215
}
193216
}
194217
218+
"""Fragment description"""
195219
fragment frag on Friend @onFragmentDefinition {
196220
foo(
197221
size: $size

src/language/__tests__/schema-parser-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ describe('Schema Parser', () => {
331331
}
332332
`).to.deep.equal({
333333
message:
334-
'Syntax Error: Unexpected description, descriptions are supported only on type definitions.',
334+
'Syntax Error: Unexpected description, descriptions are not supported on type extensions.',
335335
locations: [{ line: 2, column: 7 }],
336336
});
337337

@@ -353,7 +353,7 @@ describe('Schema Parser', () => {
353353
}
354354
`).to.deep.equal({
355355
message:
356-
'Syntax Error: Unexpected description, descriptions are supported only on type definitions.',
356+
'Syntax Error: Unexpected description, descriptions are not supported on type extensions.',
357357
locations: [{ line: 2, column: 7 }],
358358
});
359359

src/language/__tests__/visitor-test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,13 @@ describe('Visitor', () => {
539539
expect(visited).to.deep.equal([
540540
['enter', 'Document', undefined, undefined],
541541
['enter', 'OperationDefinition', 0, undefined],
542+
['enter', 'StringValue', 'description', 'OperationDefinition'],
543+
['leave', 'StringValue', 'description', 'OperationDefinition'],
542544
['enter', 'Name', 'name', 'OperationDefinition'],
543545
['leave', 'Name', 'name', 'OperationDefinition'],
544546
['enter', 'VariableDefinition', 0, undefined],
547+
['enter', 'StringValue', 'description', 'VariableDefinition'],
548+
['leave', 'StringValue', 'description', 'VariableDefinition'],
545549
['enter', 'Variable', 'variable', 'VariableDefinition'],
546550
['enter', 'Name', 'name', 'Variable'],
547551
['leave', 'Name', 'name', 'Variable'],
@@ -793,6 +797,8 @@ describe('Visitor', () => {
793797
['leave', 'SelectionSet', 'selectionSet', 'OperationDefinition'],
794798
['leave', 'OperationDefinition', 2, undefined],
795799
['enter', 'FragmentDefinition', 3, undefined],
800+
['enter', 'StringValue', 'description', 'FragmentDefinition'],
801+
['leave', 'StringValue', 'description', 'FragmentDefinition'],
796802
['enter', 'Name', 'name', 'FragmentDefinition'],
797803
['leave', 'Name', 'name', 'FragmentDefinition'],
798804
['enter', 'NamedType', 'typeCondition', 'FragmentDefinition'],

src/language/ast.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,19 @@ export const QueryDocumentKeys: {
198198

199199
Document: ['definitions'],
200200
OperationDefinition: [
201+
'description',
201202
'name',
202203
'variableDefinitions',
203204
'directives',
204205
'selectionSet',
205206
],
206-
VariableDefinition: ['variable', 'type', 'defaultValue', 'directives'],
207+
VariableDefinition: [
208+
'description',
209+
'variable',
210+
'type',
211+
'defaultValue',
212+
'directives',
213+
],
207214
Variable: ['name'],
208215
SelectionSet: ['selections'],
209216
Field: ['alias', 'name', 'arguments', 'directives', 'selectionSet'],
@@ -212,6 +219,7 @@ export const QueryDocumentKeys: {
212219
FragmentSpread: ['name', 'directives'],
213220
InlineFragment: ['typeCondition', 'directives', 'selectionSet'],
214221
FragmentDefinition: [
222+
'description',
215223
'name',
216224
// Note: fragment variable definitions are deprecated and will removed in v17.0.0
217225
'variableDefinitions',
@@ -316,6 +324,7 @@ export type ExecutableDefinitionNode =
316324
export interface OperationDefinitionNode {
317325
readonly kind: Kind.OPERATION_DEFINITION;
318326
readonly loc?: Location;
327+
readonly description?: StringValueNode;
319328
readonly operation: OperationTypeNode;
320329
readonly name?: NameNode;
321330
readonly variableDefinitions?: ReadonlyArray<VariableDefinitionNode>;
@@ -333,6 +342,7 @@ export { OperationTypeNode };
333342
export interface VariableDefinitionNode {
334343
readonly kind: Kind.VARIABLE_DEFINITION;
335344
readonly loc?: Location;
345+
readonly description?: StringValueNode;
336346
readonly variable: VariableNode;
337347
readonly type: TypeNode;
338348
readonly defaultValue?: ConstValueNode;
@@ -397,6 +407,7 @@ export interface InlineFragmentNode {
397407
export interface FragmentDefinitionNode {
398408
readonly kind: Kind.FRAGMENT_DEFINITION;
399409
readonly loc?: Location;
410+
readonly description?: StringValueNode;
400411
readonly name: NameNode;
401412
/** @deprecated variableDefinitions will be removed in v17.0.0 */
402413
readonly variableDefinitions?: ReadonlyArray<VariableDefinitionNode>;

0 commit comments

Comments
 (0)