Skip to content

Commit 70923e8

Browse files
committed
Allow empty selection sets
1 parent 9c24501 commit 70923e8

7 files changed

Lines changed: 113 additions & 73 deletions

File tree

src/execution/__tests__/executor-test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,27 @@ describe('Execute: Handles basic execution tasks', () => {
223223
});
224224
});
225225

226+
it('executes empty selection sets', () => {
227+
const Type: GraphQLObjectType = new GraphQLObjectType({
228+
name: 'Type',
229+
fields: () => ({
230+
a: { type: GraphQLString, resolve: () => 'Apple' },
231+
deep: { type: Type, resolve: () => ({}) },
232+
}),
233+
});
234+
const schema = new GraphQLSchema({ query: Type });
235+
236+
const document = parse('{ a deep { } ...Frag } fragment Frag on Type { }');
237+
238+
const result = executeSync({ schema, document });
239+
expect(result).to.deep.equal({ data: { a: 'Apple', deep: {} } });
240+
241+
const emptyDocument = parse('{ }');
242+
expect(executeSync({ schema, document: emptyDocument })).to.deep.equal({
243+
data: {},
244+
});
245+
});
246+
226247
it('provides info about current execution state', async () => {
227248
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
228249
const { promise, resolve } = promiseWithResolvers<void>();

src/language/__tests__/parser-test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.ts';
1212

1313
import { inspect } from '../../jsutils/inspect.ts';
1414

15+
import type {
16+
FragmentDefinitionNode,
17+
OperationDefinitionNode,
18+
} from '../ast.ts';
1519
import { Kind } from '../kinds.ts';
1620
import {
1721
parse,
@@ -502,6 +506,60 @@ describe('Parser', () => {
502506
expect(() => parse(document)).to.throw();
503507
});
504508

509+
it('allows parsing empty selection sets', () => {
510+
const document = parse('{ node { } }', {
511+
noLocation: true,
512+
});
513+
514+
expectJSON(document).toDeepEqual({
515+
kind: Kind.DOCUMENT,
516+
definitions: [
517+
{
518+
kind: Kind.OPERATION_DEFINITION,
519+
description: undefined,
520+
operation: 'query',
521+
name: undefined,
522+
variableDefinitions: undefined,
523+
directives: undefined,
524+
selectionSet: {
525+
kind: Kind.SELECTION_SET,
526+
selections: [
527+
{
528+
kind: Kind.FIELD,
529+
alias: undefined,
530+
name: { kind: Kind.NAME, value: 'node' },
531+
arguments: undefined,
532+
directives: undefined,
533+
selectionSet: {
534+
kind: Kind.SELECTION_SET,
535+
selections: [],
536+
},
537+
},
538+
],
539+
},
540+
},
541+
],
542+
});
543+
});
544+
545+
it('allows parsing an empty operation selection set', () => {
546+
const document = parse('{ }', {
547+
noLocation: true,
548+
});
549+
const operation = document.definitions[0] as OperationDefinitionNode;
550+
551+
expect(operation.selectionSet.selections).to.deep.equal([]);
552+
});
553+
554+
it('allows parsing an empty fragment selection set', () => {
555+
const document = parse('fragment a on t { }', {
556+
noLocation: true,
557+
});
558+
const fragment = document.definitions[0] as FragmentDefinitionNode;
559+
560+
expect(fragment.selectionSet.selections).to.deep.equal([]);
561+
});
562+
505563
it('contains location that can be Object.toStringified, JSON.stringified, or jsutils.inspected', () => {
506564
const { loc } = parse('{ id }');
507565

src/language/__tests__/printer-test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ describe('Printer: Query document', () => {
1818
expect(print(ast)).to.equal('foo');
1919
});
2020

21+
it('prints empty selection sets', () => {
22+
const ast = parse('{ foo { } bar { ... on Baz { } } }');
23+
expect(print(ast)).to.equal(dedent`
24+
{
25+
foo {}
26+
bar {
27+
... on Baz {}
28+
}
29+
}
30+
`);
31+
32+
const emptyOperationAST = parse('{ }');
33+
expect(print(emptyOperationAST)).to.equal('{}');
34+
});
35+
2136
it('produces helpful error messages', () => {
2237
const badAST = { random: 'Data' };
2338

src/language/parser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,15 +575,15 @@ export class Parser {
575575

576576
/**
577577
* ```
578-
* SelectionSet : { Selection+ }
578+
* SelectionSet : { Selection* }
579579
* ```
580580
*
581581
* @internal
582582
*/
583583
parseSelectionSet(): SelectionSetNode {
584584
return this.node<SelectionSetNode>(this._lexer.token, {
585585
kind: Kind.SELECTION_SET,
586-
selections: this.many(
586+
selections: this.any(
587587
TokenKind.BRACE_L,
588588
this.parseSelection,
589589
TokenKind.BRACE_R,

src/language/printer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ const printDocASTReducer: ASTReducer<string> = {
7070
wrap(' = ', defaultValue) +
7171
wrap(' ', join(directives, ' ')),
7272
},
73-
SelectionSet: { leave: ({ selections }) => block(selections) },
73+
SelectionSet: {
74+
leave: ({ selections }) =>
75+
selections.length === 0 ? '{}' : block(selections),
76+
},
7477

7578
Field: {
7679
leave({ alias, name, arguments: args, directives, selectionSet }) {

src/validation/__tests__/ScalarLeafsRule-test.ts

Lines changed: 13 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ import { describe, it } from 'node:test';
22

33
import { expectJSON } from '../../__testUtils__/expectJSON.ts';
44

5-
import type { DocumentNode } from '../../language/ast.ts';
6-
import { OperationTypeNode } from '../../language/ast.ts';
7-
import { Kind } from '../../language/kinds.ts';
5+
import { parse } from '../../language/parser.ts';
86

97
import { ScalarLeafsRule } from '../rules/ScalarLeafsRule.ts';
108
import { validate } from '../validate.ts';
@@ -42,35 +40,22 @@ describe('Validate: Scalar leafs', () => {
4240
]);
4341
});
4442

45-
it('object type having only one selection', () => {
46-
const doc: DocumentNode = {
47-
kind: Kind.DOCUMENT,
48-
definitions: [
49-
{
50-
kind: Kind.OPERATION_DEFINITION,
51-
operation: OperationTypeNode.QUERY,
52-
selectionSet: {
53-
kind: Kind.SELECTION_SET,
54-
selections: [
55-
{
56-
kind: Kind.FIELD,
57-
name: { kind: Kind.NAME, value: 'human' },
58-
selectionSet: { kind: Kind.SELECTION_SET, selections: [] },
59-
},
60-
],
61-
},
62-
},
63-
],
64-
};
65-
66-
// We can't leverage expectErrors since it doesn't support passing in the
67-
// documentNode directly. We have to do this because this is technically
68-
// an invalid document.
43+
it('object type having no selections is allowed', () => {
44+
const doc = parse('{ human { } }');
45+
46+
const errors = validate(testSchema, doc, [ScalarLeafsRule]);
47+
expectJSON(errors).toDeepEqual([]);
48+
});
49+
50+
it('scalar selection is still rejected with an empty selection set', () => {
51+
const doc = parse('{ human { name { } } }');
52+
6953
const errors = validate(testSchema, doc, [ScalarLeafsRule]);
7054
expectJSON(errors).toDeepEqual([
7155
{
7256
message:
73-
'Field "human" of type "Human" must have at least one field selected.',
57+
'Field "name" must not have a selection since type "String" has no subfields.',
58+
locations: [{ line: 1, column: 16 }],
7459
},
7560
]);
7661
});
@@ -166,37 +151,4 @@ describe('Validate: Scalar leafs', () => {
166151
},
167152
]);
168153
});
169-
170-
it('object type having only one selection', () => {
171-
const doc: DocumentNode = {
172-
kind: Kind.DOCUMENT,
173-
definitions: [
174-
{
175-
kind: Kind.OPERATION_DEFINITION,
176-
operation: OperationTypeNode.QUERY,
177-
selectionSet: {
178-
kind: Kind.SELECTION_SET,
179-
selections: [
180-
{
181-
kind: Kind.FIELD,
182-
name: { kind: Kind.NAME, value: 'human' },
183-
selectionSet: { kind: Kind.SELECTION_SET, selections: [] },
184-
},
185-
],
186-
},
187-
},
188-
],
189-
};
190-
191-
// We can't leverage expectErrors since it doesn't support passing in the
192-
// documentNode directly. We have to do this because this is technically
193-
// an invalid document.
194-
const errors = validate(testSchema, doc, [ScalarLeafsRule]);
195-
expectJSON(errors).toDeepEqual([
196-
{
197-
message:
198-
'Field "human" of type "Human" must have at least one field selected.',
199-
},
200-
]);
201-
});
202154
});

src/validation/rules/ScalarLeafsRule.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,6 @@ export function ScalarLeafsRule(context: ValidationContext): ASTVisitor {
7070
{ nodes: node },
7171
),
7272
);
73-
} else if (selectionSet.selections.length === 0) {
74-
const fieldName = node.name.value;
75-
const typeStr = inspect(type);
76-
context.reportError(
77-
new GraphQLError(
78-
`Field "${fieldName}" of type "${typeStr}" must have at least one field selected.`,
79-
{ nodes: node },
80-
),
81-
);
8273
}
8374
}
8475
},

0 commit comments

Comments
 (0)