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 #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)