From ae05de8c81decb06b6e18c8bda096b1b4f4d7aa1 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Mon, 31 Aug 2026 18:02:53 +0200 Subject: [PATCH 1/8] Allow empty selection sets See https://github.com/graphql/graphql-spec/pull/1227 --- src/execution/__tests__/executor-test.ts | 25 +++++++ src/language/__tests__/parser-test.ts | 68 +++++++++++++++++++ src/language/__tests__/printer-test.ts | 19 ++++++ src/language/parser.ts | 36 ++++++++-- src/language/printer.ts | 5 +- src/validation/ValidationContext.ts | 17 +++++ .../__tests__/ScalarLeafsRule-test.ts | 29 ++++++++ src/validation/rules/ScalarLeafsRule.ts | 5 +- src/validation/validate.ts | 14 ++++ .../experimental-specification-features.mdx | 44 ++++++++++++ 10 files changed, 255 insertions(+), 7 deletions(-) diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index ba8368af43..39643864dc 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -223,6 +223,31 @@ describe('Execute: Handles basic execution tasks', () => { }); }); + it('executes empty selection sets', () => { + const Type: GraphQLObjectType = new GraphQLObjectType({ + name: 'Type', + fields: () => ({ + a: { type: GraphQLString, resolve: () => 'Apple' }, + deep: { type: Type, resolve: () => ({}) }, + }), + }); + const schema = new GraphQLSchema({ query: Type }); + + const document = parse('{ a deep { } ...Frag } fragment Frag on Type { }', { + experimentalEmptySelectionSets: true, + }); + + const result = executeSync({ schema, document }); + expect(result).to.deep.equal({ data: { a: 'Apple', deep: {} } }); + + const emptyDocument = parse('{ }', { + experimentalEmptySelectionSets: true, + }); + expect(executeSync({ schema, document: emptyDocument })).to.deep.equal({ + data: {}, + }); + }); + it('provides info about current execution state', async () => { // eslint-disable-next-line @typescript-eslint/no-invalid-void-type const { promise, resolve } = promiseWithResolvers(); diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index a51cbf4c6b..fbd06f481c 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -12,6 +12,10 @@ import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.ts'; import { inspect } from '../../jsutils/inspect.ts'; +import type { + FragmentDefinitionNode, + OperationDefinitionNode, +} from '../ast.ts'; import { Kind } from '../kinds.ts'; import { parse, @@ -502,6 +506,70 @@ describe('Parser', () => { expect(() => parse(document)).to.throw(); }); + it('allows parsing empty selection sets', () => { + const document = parse('{ node { } }', { + experimentalEmptySelectionSets: true, + noLocation: true, + }); + + expectJSON(document).toDeepEqual({ + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + description: undefined, + operation: 'query', + name: undefined, + variableDefinitions: undefined, + directives: undefined, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + alias: undefined, + name: { kind: Kind.NAME, value: 'node' }, + arguments: undefined, + directives: undefined, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [], + }, + }, + ], + }, + }, + ], + }); + }); + + it('allows parsing an empty operation selection set', () => { + const document = parse('{ }', { + experimentalEmptySelectionSets: true, + noLocation: true, + }); + const operation = document.definitions[0] as OperationDefinitionNode; + + expect(operation.selectionSet.selections).to.deep.equal([]); + }); + + it('allows parsing an empty fragment selection set', () => { + const document = parse('fragment a on t { }', { + experimentalEmptySelectionSets: true, + noLocation: true, + }); + const fragment = document.definitions[0] as FragmentDefinitionNode; + + expect(fragment.selectionSet.selections).to.deep.equal([]); + }); + + it('disallows parsing empty selection sets without experimental flag', () => { + expectSyntaxError('{ node { } }').to.deep.equal({ + message: 'Syntax Error: Expected Name, found "}".', + locations: [{ line: 1, column: 10 }], + }); + }); + it('contains location that can be Object.toStringified, JSON.stringified, or jsutils.inspected', () => { const { loc } = parse('{ id }'); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 3f596ed82f..a3df31e633 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -18,6 +18,25 @@ describe('Printer: Query document', () => { expect(print(ast)).to.equal('foo'); }); + it('prints empty selection sets', () => { + const ast = parse('{ foo { } bar { ... on Baz { } } }', { + experimentalEmptySelectionSets: true, + }); + expect(print(ast)).to.equal(dedent` + { + foo {} + bar { + ... on Baz {} + } + } + `); + + const emptyOperationAST = parse('{ }', { + experimentalEmptySelectionSets: true, + }); + expect(print(emptyOperationAST)).to.equal('{}'); + }); + it('produces helpful error messages', () => { const badAST = { random: 'Data' }; diff --git a/src/language/parser.ts b/src/language/parser.ts index 0902448ff5..6a620fbfa5 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -118,6 +118,23 @@ export interface ParseOptions { */ experimentalFragmentArguments?: boolean | undefined; + /** + * EXPERIMENTAL: + * + * If enabled, the parser accepts selection sets that contain no + * selections, changing the grammar from `SelectionSet : { Selection+ }` to + * `SelectionSet : { Selection* }`. + * + * See https://github.com/graphql/graphql-spec/pull/1227 + * @example + * ```graphql prettier-ignore + * { + * viewer { } + * } + * ``` + */ + experimentalEmptySelectionSets?: boolean | undefined; + /** * Internal parser hook for GraphQL.js entry points that need to parse a * restricted grammar with an alternate lexer. @@ -578,16 +595,25 @@ export class Parser { * SelectionSet : { Selection+ } * ``` * + * With `experimentalEmptySelectionSets` enabled: + * + * ``` + * SelectionSet : { Selection* } + * ``` + * * @internal */ parseSelectionSet(): SelectionSetNode { return this.node(this._lexer.token, { kind: Kind.SELECTION_SET, - selections: this.many( - TokenKind.BRACE_L, - this.parseSelection, - TokenKind.BRACE_R, - ), + selections: + this._options.experimentalEmptySelectionSets === true + ? this.any(TokenKind.BRACE_L, this.parseSelection, TokenKind.BRACE_R) + : this.many( + TokenKind.BRACE_L, + this.parseSelection, + TokenKind.BRACE_R, + ), }); } diff --git a/src/language/printer.ts b/src/language/printer.ts index 758fe64d51..305f74e209 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -70,7 +70,10 @@ const printDocASTReducer: ASTReducer = { wrap(' = ', defaultValue) + wrap(' ', join(directives, ' ')), }, - SelectionSet: { leave: ({ selections }) => block(selections) }, + SelectionSet: { + leave: ({ selections }) => + selections.length === 0 ? '{}' : block(selections), + }, Field: { leave({ alias, name, arguments: args, directives, selectionSet }) { diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index 1047b91fc7..35f8d6d0eb 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -190,6 +190,7 @@ export class ValidationContext extends ASTValidationContext { ReadonlyArray >; private _hideSuggestions: boolean; + private _experimentalEmptySelectionSets: boolean; /** * Creates a ValidationContext instance. @@ -198,6 +199,7 @@ export class ValidationContext extends ASTValidationContext { * @param typeInfo - TypeInfo instance used to track traversal state. * @param onError - Callback invoked for each validation error. * @param hideSuggestions - Whether suggestion text should be omitted from errors. + * @param experimentalEmptySelectionSets - Whether selection sets on composite types may be empty. * @example * ```ts * import { parse } from 'graphql/language'; @@ -225,12 +227,14 @@ export class ValidationContext extends ASTValidationContext { * errors[0].message; // => 'Example validation error.' * ``` */ + // eslint-disable-next-line max-params constructor( schema: GraphQLSchema, ast: DocumentNode, typeInfo: TypeInfo, onError: (error: GraphQLError) => void, hideSuggestions?: Maybe, + experimentalEmptySelectionSets?: Maybe, ) { super(ast, onError); this._schema = schema; @@ -238,6 +242,8 @@ export class ValidationContext extends ASTValidationContext { this._variableUsages = new Map(); this._recursiveVariableUsages = new Map(); this._hideSuggestions = hideSuggestions ?? false; + this._experimentalEmptySelectionSets = + experimentalEmptySelectionSets ?? false; } /** @@ -256,6 +262,17 @@ export class ValidationContext extends ASTValidationContext { return this._hideSuggestions; } + /** + * Returns whether empty selection sets on composite types are allowed. + * + * Note: empty selection sets are experimental and may be changed or removed + * in the future. + * @returns True when a selection set on a composite type may be empty. + */ + get experimentalEmptySelectionSets(): boolean { + return this._experimentalEmptySelectionSets; + } + /** * Returns the schema being used by this validation context. * @returns The schema being validated against. diff --git a/src/validation/__tests__/ScalarLeafsRule-test.ts b/src/validation/__tests__/ScalarLeafsRule-test.ts index 4e55dfa74f..195edc8e20 100644 --- a/src/validation/__tests__/ScalarLeafsRule-test.ts +++ b/src/validation/__tests__/ScalarLeafsRule-test.ts @@ -5,6 +5,7 @@ import { expectJSON } from '../../__testUtils__/expectJSON.ts'; import type { DocumentNode } from '../../language/ast.ts'; import { OperationTypeNode } from '../../language/ast.ts'; import { Kind } from '../../language/kinds.ts'; +import { parse } from '../../language/parser.ts'; import { ScalarLeafsRule } from '../rules/ScalarLeafsRule.ts'; import { validate } from '../validate.ts'; @@ -75,6 +76,34 @@ describe('Validate: Scalar leafs', () => { ]); }); + it('object type having no selections is allowed with experimentalEmptySelectionSets', () => { + const doc = parse('{ human { } }', { + experimentalEmptySelectionSets: true, + }); + + const errors = validate(testSchema, doc, [ScalarLeafsRule], { + experimentalEmptySelectionSets: true, + }); + expectJSON(errors).toDeepEqual([]); + }); + + it('scalar selection is still rejected with experimentalEmptySelectionSets', () => { + const doc = parse('{ human { name { } } }', { + experimentalEmptySelectionSets: true, + }); + + const errors = validate(testSchema, doc, [ScalarLeafsRule], { + experimentalEmptySelectionSets: true, + }); + expectJSON(errors).toDeepEqual([ + { + message: + 'Field "name" must not have a selection since type "String" has no subfields.', + locations: [{ line: 1, column: 16 }], + }, + ]); + }); + it('interface type missing selection', () => { expectErrors(` { diff --git a/src/validation/rules/ScalarLeafsRule.ts b/src/validation/rules/ScalarLeafsRule.ts index 1525b82830..af0abd53d6 100644 --- a/src/validation/rules/ScalarLeafsRule.ts +++ b/src/validation/rules/ScalarLeafsRule.ts @@ -70,7 +70,10 @@ export function ScalarLeafsRule(context: ValidationContext): ASTVisitor { { nodes: node }, ), ); - } else if (selectionSet.selections.length === 0) { + } else if ( + selectionSet.selections.length === 0 && + !context.experimentalEmptySelectionSets + ) { const fieldName = node.name.value; const typeStr = inspect(type); context.reportError( diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 0f617b3666..c25b7f7fd8 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -32,6 +32,17 @@ export interface ValidationOptions { maxErrors?: number; /** Whether suggestion text should be omitted from validation errors. */ hideSuggestions?: Maybe; + /** + * EXPERIMENTAL: + * + * If enabled, a selection set on an object, interface or union type is only + * required to be present, rather than to be non-empty. + * + * Pair this with the `experimentalEmptySelectionSets` parse option. + * + * See https://github.com/graphql/graphql-spec/pull/1227 + */ + experimentalEmptySelectionSets?: boolean | undefined; } // Per the specification, descriptions must not affect validation. @@ -137,6 +148,8 @@ function validateImpl( ): ReadonlyArray { const maxErrors = options?.maxErrors ?? 100; const hideSuggestions = options?.hideSuggestions ?? false; + const experimentalEmptySelectionSets = + options?.experimentalEmptySelectionSets ?? false; // If the schema used for validation is invalid, throw an error. assertValidSchema(schema); @@ -154,6 +167,7 @@ function validateImpl( errors.push(error); }, hideSuggestions, + experimentalEmptySelectionSets, ); // This uses a specialized visitor which runs multiple visitors in parallel, diff --git a/website/pages/docs/experimental-specification-features.mdx b/website/pages/docs/experimental-specification-features.mdx index e7f3000c18..f6e9ee5f88 100644 --- a/website/pages/docs/experimental-specification-features.mdx +++ b/website/pages/docs/experimental-specification-features.mdx @@ -65,3 +65,47 @@ fragment spreads pass values for them. GraphQL.js exposes the syntax through the `FragmentArgumentNode`, and execution supports the resulting values. See [Fragment Arguments](/docs/fragment-arguments). + +## Empty selection sets + +Empty selection sets relax the grammar from `SelectionSet : { Selection+ }` to +`SelectionSet : { Selection* }`, so a selection set on an object, interface, or +union type only has to be present rather than non-empty. Leaf fields are +unaffected: a scalar or enum field must still have no selection set at all, and +`name { }` remains invalid. + +This helps clients that strip fields during query preprocessing. When every +server-directed field of a selection set is handled locally (Apollo's `@client`, +Relay Resolvers, and similar client-side extensions), what remains is an empty +selection set, and the client no longer has to inject a placeholder field such +as `__typename` or rewrite the enclosing selection away. + +GraphQL.js exposes the syntax through the `experimentalEmptySelectionSets` +parser option and the matching `experimentalEmptySelectionSets` validation +option; `graphql()` and `graphqlSync()` accept both from their single arguments +object. Execution needs no flag — an empty selection set resolves to an empty +object. + +```js +import { graphql, buildSchema } from 'graphql'; + +const schema = buildSchema(` + type Query { + viewer: Viewer + } + + type Viewer { + id: ID + } +`); + +const result = await graphql({ + schema, + source: '{ viewer { } }', + rootValue: { viewer: { id: '1' } }, + experimentalEmptySelectionSets: true, +}); +// { data: { viewer: {} } } +``` + +See [the specification proposal](https://github.com/graphql/graphql-spec/pull/1227). From 643553355cf2761a1a195befb52a18070315e136 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Thu, 3 Sep 2026 16:09:45 +0200 Subject: [PATCH 2/8] Update src/validation/ValidationContext.ts Co-authored-by: Benjie --- src/validation/ValidationContext.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index 35f8d6d0eb..59a42005aa 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -228,14 +228,30 @@ export class ValidationContext extends ASTValidationContext { * ``` */ // eslint-disable-next-line max-params + constructor( + schema: GraphQLSchema, + ast: DocumentNode, + typeInfo: TypeInfo, + onError: (error: GraphQLError) => void, + options?: ValidationContextOptions + ); + /** @deprecated Please pass options object; this signature will be removed in V18 */ constructor( schema: GraphQLSchema, ast: DocumentNode, typeInfo: TypeInfo, onError: (error: GraphQLError) => void, hideSuggestions?: Maybe, - experimentalEmptySelectionSets?: Maybe, + ); + constructor( + schema: GraphQLSchema, + ast: DocumentNode, + typeInfo: TypeInfo, + onError: (error: GraphQLError) => void, + hideSuggestionsOrOptions?: Maybe | ValidationContextOptions ) { + const options = typeof hideSuggestionsOrOptions === 'boolean' ? { hideSuggestions: hideSuggestionsOrOptions } : hideSuggestionsOrOptions ?? {}; + const { hideSuggestions = false, experimentalEmptySelectionSets = false } = options; super(ast, onError); this._schema = schema; this._typeInfo = typeInfo; From dc75c6156f6153ba3a211b9b9bf3362319f34e48 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Thu, 3 Sep 2026 16:23:16 +0200 Subject: [PATCH 3/8] FIx build --- src/validation/ValidationContext.ts | 26 +++++++++++++++++++------- src/validation/index.ts | 1 + src/validation/validate.ts | 3 +-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index 59a42005aa..954ec916fc 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -176,6 +176,14 @@ export class SDLValidationContext extends ASTValidationContext { /** @internal */ export type SDLValidationRule = (context: SDLValidationContext) => ASTVisitor; +/** Options used when constructing a {@link ValidationContext}. */ +export interface ValidationContextOptions { + /** Whether suggestion text should be omitted from errors. */ + hideSuggestions?: Maybe; + /** Whether selection sets on composite types may be empty. */ + experimentalEmptySelectionSets?: Maybe; +} + /** Validation context passed to query validation rules. */ export class ValidationContext extends ASTValidationContext { private _schema: GraphQLSchema; @@ -198,8 +206,7 @@ export class ValidationContext extends ASTValidationContext { * @param ast - Document AST being validated. * @param typeInfo - TypeInfo instance used to track traversal state. * @param onError - Callback invoked for each validation error. - * @param hideSuggestions - Whether suggestion text should be omitted from errors. - * @param experimentalEmptySelectionSets - Whether selection sets on composite types may be empty. + * @param options - Validation options. * @example * ```ts * import { parse } from 'graphql/language'; @@ -227,20 +234,21 @@ export class ValidationContext extends ASTValidationContext { * errors[0].message; // => 'Example validation error.' * ``` */ - // eslint-disable-next-line max-params constructor( schema: GraphQLSchema, ast: DocumentNode, typeInfo: TypeInfo, onError: (error: GraphQLError) => void, - options?: ValidationContextOptions + options?: ValidationContextOptions, ); + // eslint-disable-next-line /** @deprecated Please pass options object; this signature will be removed in V18 */ constructor( schema: GraphQLSchema, ast: DocumentNode, typeInfo: TypeInfo, onError: (error: GraphQLError) => void, + // eslint-disable-next-line @typescript-eslint/unified-signatures hideSuggestions?: Maybe, ); constructor( @@ -248,10 +256,14 @@ export class ValidationContext extends ASTValidationContext { ast: DocumentNode, typeInfo: TypeInfo, onError: (error: GraphQLError) => void, - hideSuggestionsOrOptions?: Maybe | ValidationContextOptions + hideSuggestionsOrOptions?: Maybe | ValidationContextOptions, ) { - const options = typeof hideSuggestionsOrOptions === 'boolean' ? { hideSuggestions: hideSuggestionsOrOptions } : hideSuggestionsOrOptions ?? {}; - const { hideSuggestions = false, experimentalEmptySelectionSets = false } = options; + const options = + typeof hideSuggestionsOrOptions === 'boolean' + ? { hideSuggestions: hideSuggestionsOrOptions } + : (hideSuggestionsOrOptions ?? {}); + const { hideSuggestions = false, experimentalEmptySelectionSets = false } = + options; super(ast, onError); this._schema = schema; this._typeInfo = typeInfo; diff --git a/src/validation/index.ts b/src/validation/index.ts index a7882a1f29..fe1d88f8a5 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -9,6 +9,7 @@ export { validate } from './validate.ts'; export type { ValidationOptions } from './validate.ts'; export { ValidationContext } from './ValidationContext.ts'; +export type { ValidationContextOptions } from './ValidationContext.ts'; export type { ValidationRule } from './ValidationContext.ts'; // All validation rules in the GraphQL Specification. diff --git a/src/validation/validate.ts b/src/validation/validate.ts index c25b7f7fd8..a39972bd70 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -166,8 +166,7 @@ function validateImpl( } errors.push(error); }, - hideSuggestions, - experimentalEmptySelectionSets, + { hideSuggestions, experimentalEmptySelectionSets }, ); // This uses a specialized visitor which runs multiple visitors in parallel, From d91a00e012b49166958e651912a17d1bef3087a9 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Thu, 3 Sep 2026 16:55:04 +0200 Subject: [PATCH 4/8] No need to export ValidationContextOptions --- src/validation/ValidationContext.ts | 5 ++--- src/validation/__tests__/ValidationContext-test.ts | 10 ++++++++++ src/validation/index.ts | 1 - 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index 954ec916fc..9bf8940628 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -177,7 +177,7 @@ export class SDLValidationContext extends ASTValidationContext { export type SDLValidationRule = (context: SDLValidationContext) => ASTVisitor; /** Options used when constructing a {@link ValidationContext}. */ -export interface ValidationContextOptions { +interface ValidationContextOptions { /** Whether suggestion text should be omitted from errors. */ hideSuggestions?: Maybe; /** Whether selection sets on composite types may be empty. */ @@ -262,8 +262,7 @@ export class ValidationContext extends ASTValidationContext { typeof hideSuggestionsOrOptions === 'boolean' ? { hideSuggestions: hideSuggestionsOrOptions } : (hideSuggestionsOrOptions ?? {}); - const { hideSuggestions = false, experimentalEmptySelectionSets = false } = - options; + const { hideSuggestions, experimentalEmptySelectionSets } = options; super(ast, onError); this._schema = schema; this._typeInfo = typeInfo; diff --git a/src/validation/__tests__/ValidationContext-test.ts b/src/validation/__tests__/ValidationContext-test.ts index d65f9af1a4..cc5d0c397d 100644 --- a/src/validation/__tests__/ValidationContext-test.ts +++ b/src/validation/__tests__/ValidationContext-test.ts @@ -36,4 +36,14 @@ describe('ValidationContext', () => { '[object ValidationContext]', ); }); + + it('supports the deprecated hideSuggestions boolean argument', () => { + const schema = new GraphQLSchema({}); + const typeInfo = new TypeInfo(schema); + const ast = parse('{ foo }'); + const onError = () => undefined; + + const context = new ValidationContext(schema, ast, typeInfo, onError, true); + expect(context.hideSuggestions).to.equal(true); + }); }); diff --git a/src/validation/index.ts b/src/validation/index.ts index fe1d88f8a5..a7882a1f29 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -9,7 +9,6 @@ export { validate } from './validate.ts'; export type { ValidationOptions } from './validate.ts'; export { ValidationContext } from './ValidationContext.ts'; -export type { ValidationContextOptions } from './ValidationContext.ts'; export type { ValidationRule } from './ValidationContext.ts'; // All validation rules in the GraphQL Specification. From 3e4954e50921b9a3f78f431a491787e8a4174d44 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Fri, 4 Sep 2026 17:24:57 +0200 Subject: [PATCH 5/8] Make allowEmptySelectionSet the default --- src/execution/__tests__/executor-test.ts | 4 +- src/language/__tests__/parser-test.ts | 14 ++--- src/language/__tests__/printer-test.ts | 4 +- src/language/parser.ts | 22 +++---- src/validation/ValidationContext.ts | 16 ++---- .../__tests__/ScalarLeafsRule-test.ts | 57 +++---------------- src/validation/rules/ScalarLeafsRule.ts | 2 +- src/validation/validate.ts | 15 ++--- .../experimental-specification-features.mdx | 44 -------------- 9 files changed, 41 insertions(+), 137 deletions(-) diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 39643864dc..177e386d65 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -234,14 +234,14 @@ describe('Execute: Handles basic execution tasks', () => { const schema = new GraphQLSchema({ query: Type }); const document = parse('{ a deep { } ...Frag } fragment Frag on Type { }', { - experimentalEmptySelectionSets: true, + allowEmptySelectionSets: true, }); const result = executeSync({ schema, document }); expect(result).to.deep.equal({ data: { a: 'Apple', deep: {} } }); const emptyDocument = parse('{ }', { - experimentalEmptySelectionSets: true, + allowEmptySelectionSets: true, }); expect(executeSync({ schema, document: emptyDocument })).to.deep.equal({ data: {}, diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index fbd06f481c..d31b95d6db 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -17,6 +17,7 @@ import type { OperationDefinitionNode, } from '../ast.ts'; import { Kind } from '../kinds.ts'; +import type { ParseOptions } from '../parser.ts'; import { parse, parseConstValue, @@ -27,8 +28,8 @@ import { import { Source } from '../source.ts'; import { TokenKind } from '../tokenKind.ts'; -function expectSyntaxError(text: string) { - return expectToThrowJSON(() => parse(text)); +function expectSyntaxError(text: string, options?: ParseOptions) { + return expectToThrowJSON(() => parse(text, options)); } describe('Parser', () => { @@ -508,7 +509,6 @@ describe('Parser', () => { it('allows parsing empty selection sets', () => { const document = parse('{ node { } }', { - experimentalEmptySelectionSets: true, noLocation: true, }); @@ -545,7 +545,6 @@ describe('Parser', () => { it('allows parsing an empty operation selection set', () => { const document = parse('{ }', { - experimentalEmptySelectionSets: true, noLocation: true, }); const operation = document.definitions[0] as OperationDefinitionNode; @@ -555,7 +554,6 @@ describe('Parser', () => { it('allows parsing an empty fragment selection set', () => { const document = parse('fragment a on t { }', { - experimentalEmptySelectionSets: true, noLocation: true, }); const fragment = document.definitions[0] as FragmentDefinitionNode; @@ -563,8 +561,10 @@ describe('Parser', () => { expect(fragment.selectionSet.selections).to.deep.equal([]); }); - it('disallows parsing empty selection sets without experimental flag', () => { - expectSyntaxError('{ node { } }').to.deep.equal({ + it('disallows parsing empty selection sets when disabled', () => { + expectSyntaxError('{ node { } }', { + allowEmptySelectionSets: false, + }).to.deep.equal({ message: 'Syntax Error: Expected Name, found "}".', locations: [{ line: 1, column: 10 }], }); diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index a3df31e633..87d6a97dc1 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -20,7 +20,7 @@ describe('Printer: Query document', () => { it('prints empty selection sets', () => { const ast = parse('{ foo { } bar { ... on Baz { } } }', { - experimentalEmptySelectionSets: true, + allowEmptySelectionSets: true, }); expect(print(ast)).to.equal(dedent` { @@ -32,7 +32,7 @@ describe('Printer: Query document', () => { `); const emptyOperationAST = parse('{ }', { - experimentalEmptySelectionSets: true, + allowEmptySelectionSets: true, }); expect(print(emptyOperationAST)).to.equal('{}'); }); diff --git a/src/language/parser.ts b/src/language/parser.ts index 6a620fbfa5..2bd2a1190b 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -119,11 +119,9 @@ export interface ParseOptions { experimentalFragmentArguments?: boolean | undefined; /** - * EXPERIMENTAL: - * - * If enabled, the parser accepts selection sets that contain no - * selections, changing the grammar from `SelectionSet : { Selection+ }` to - * `SelectionSet : { Selection* }`. + * If enabled (the default), the parser accepts selection sets that contain + * no selections, changing the grammar from `SelectionSet : { Selection+ }` + * to `SelectionSet : { Selection* }`. * * See https://github.com/graphql/graphql-spec/pull/1227 * @example @@ -133,7 +131,7 @@ export interface ParseOptions { * } * ``` */ - experimentalEmptySelectionSets?: boolean | undefined; + allowEmptySelectionSets?: boolean | undefined; /** * Internal parser hook for GraphQL.js entry points that need to parse a @@ -595,7 +593,7 @@ export class Parser { * SelectionSet : { Selection+ } * ``` * - * With `experimentalEmptySelectionSets` enabled: + * With `allowEmptySelectionSets` enabled (the default): * * ``` * SelectionSet : { Selection* } @@ -607,13 +605,9 @@ export class Parser { return this.node(this._lexer.token, { kind: Kind.SELECTION_SET, selections: - this._options.experimentalEmptySelectionSets === true - ? this.any(TokenKind.BRACE_L, this.parseSelection, TokenKind.BRACE_R) - : this.many( - TokenKind.BRACE_L, - this.parseSelection, - TokenKind.BRACE_R, - ), + this._options.allowEmptySelectionSets === false + ? this.many(TokenKind.BRACE_L, this.parseSelection, TokenKind.BRACE_R) + : this.any(TokenKind.BRACE_L, this.parseSelection, TokenKind.BRACE_R), }); } diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index 9bf8940628..f6fc1ac1d9 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -181,7 +181,7 @@ interface ValidationContextOptions { /** Whether suggestion text should be omitted from errors. */ hideSuggestions?: Maybe; /** Whether selection sets on composite types may be empty. */ - experimentalEmptySelectionSets?: Maybe; + allowEmptySelectionSets?: Maybe; } /** Validation context passed to query validation rules. */ @@ -198,7 +198,7 @@ export class ValidationContext extends ASTValidationContext { ReadonlyArray >; private _hideSuggestions: boolean; - private _experimentalEmptySelectionSets: boolean; + private _allowEmptySelectionSets: boolean; /** * Creates a ValidationContext instance. @@ -262,15 +262,14 @@ export class ValidationContext extends ASTValidationContext { typeof hideSuggestionsOrOptions === 'boolean' ? { hideSuggestions: hideSuggestionsOrOptions } : (hideSuggestionsOrOptions ?? {}); - const { hideSuggestions, experimentalEmptySelectionSets } = options; + const { hideSuggestions, allowEmptySelectionSets } = options; super(ast, onError); this._schema = schema; this._typeInfo = typeInfo; this._variableUsages = new Map(); this._recursiveVariableUsages = new Map(); this._hideSuggestions = hideSuggestions ?? false; - this._experimentalEmptySelectionSets = - experimentalEmptySelectionSets ?? false; + this._allowEmptySelectionSets = allowEmptySelectionSets ?? true; } /** @@ -291,13 +290,10 @@ export class ValidationContext extends ASTValidationContext { /** * Returns whether empty selection sets on composite types are allowed. - * - * Note: empty selection sets are experimental and may be changed or removed - * in the future. * @returns True when a selection set on a composite type may be empty. */ - get experimentalEmptySelectionSets(): boolean { - return this._experimentalEmptySelectionSets; + get allowEmptySelectionSets(): boolean { + return this._allowEmptySelectionSets; } /** diff --git a/src/validation/__tests__/ScalarLeafsRule-test.ts b/src/validation/__tests__/ScalarLeafsRule-test.ts index 195edc8e20..197d93968e 100644 --- a/src/validation/__tests__/ScalarLeafsRule-test.ts +++ b/src/validation/__tests__/ScalarLeafsRule-test.ts @@ -67,7 +67,9 @@ describe('Validate: Scalar leafs', () => { // We can't leverage expectErrors since it doesn't support passing in the // documentNode directly. We have to do this because this is technically // an invalid document. - const errors = validate(testSchema, doc, [ScalarLeafsRule]); + const errors = validate(testSchema, doc, [ScalarLeafsRule], { + allowEmptySelectionSets: false, + }); expectJSON(errors).toDeepEqual([ { message: @@ -76,25 +78,17 @@ describe('Validate: Scalar leafs', () => { ]); }); - it('object type having no selections is allowed with experimentalEmptySelectionSets', () => { - const doc = parse('{ human { } }', { - experimentalEmptySelectionSets: true, - }); + it('object type having no selections is allowed by default', () => { + const doc = parse('{ human { } }'); - const errors = validate(testSchema, doc, [ScalarLeafsRule], { - experimentalEmptySelectionSets: true, - }); + const errors = validate(testSchema, doc, [ScalarLeafsRule]); expectJSON(errors).toDeepEqual([]); }); - it('scalar selection is still rejected with experimentalEmptySelectionSets', () => { - const doc = parse('{ human { name { } } }', { - experimentalEmptySelectionSets: true, - }); + it('scalar selection is still rejected with allowEmptySelectionSets', () => { + const doc = parse('{ human { name { } } }'); - const errors = validate(testSchema, doc, [ScalarLeafsRule], { - experimentalEmptySelectionSets: true, - }); + const errors = validate(testSchema, doc, [ScalarLeafsRule]); expectJSON(errors).toDeepEqual([ { message: @@ -195,37 +189,4 @@ describe('Validate: Scalar leafs', () => { }, ]); }); - - it('object type having only one selection', () => { - const doc: DocumentNode = { - kind: Kind.DOCUMENT, - definitions: [ - { - kind: Kind.OPERATION_DEFINITION, - operation: OperationTypeNode.QUERY, - selectionSet: { - kind: Kind.SELECTION_SET, - selections: [ - { - kind: Kind.FIELD, - name: { kind: Kind.NAME, value: 'human' }, - selectionSet: { kind: Kind.SELECTION_SET, selections: [] }, - }, - ], - }, - }, - ], - }; - - // We can't leverage expectErrors since it doesn't support passing in the - // documentNode directly. We have to do this because this is technically - // an invalid document. - const errors = validate(testSchema, doc, [ScalarLeafsRule]); - expectJSON(errors).toDeepEqual([ - { - message: - 'Field "human" of type "Human" must have at least one field selected.', - }, - ]); - }); }); diff --git a/src/validation/rules/ScalarLeafsRule.ts b/src/validation/rules/ScalarLeafsRule.ts index af0abd53d6..995144aac6 100644 --- a/src/validation/rules/ScalarLeafsRule.ts +++ b/src/validation/rules/ScalarLeafsRule.ts @@ -72,7 +72,7 @@ export function ScalarLeafsRule(context: ValidationContext): ASTVisitor { ); } else if ( selectionSet.selections.length === 0 && - !context.experimentalEmptySelectionSets + !context.allowEmptySelectionSets ) { const fieldName = node.name.value; const typeStr = inspect(type); diff --git a/src/validation/validate.ts b/src/validation/validate.ts index a39972bd70..8665b39ae4 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -33,16 +33,14 @@ export interface ValidationOptions { /** Whether suggestion text should be omitted from validation errors. */ hideSuggestions?: Maybe; /** - * EXPERIMENTAL: + * If enabled (the default), a selection set on an object, interface or + * union type is only required to be present, rather than to be non-empty. * - * If enabled, a selection set on an object, interface or union type is only - * required to be present, rather than to be non-empty. - * - * Pair this with the `experimentalEmptySelectionSets` parse option. + * Pair this with the `allowEmptySelectionSets` parse option. * * See https://github.com/graphql/graphql-spec/pull/1227 */ - experimentalEmptySelectionSets?: boolean | undefined; + allowEmptySelectionSets?: boolean | undefined; } // Per the specification, descriptions must not affect validation. @@ -148,8 +146,7 @@ function validateImpl( ): ReadonlyArray { const maxErrors = options?.maxErrors ?? 100; const hideSuggestions = options?.hideSuggestions ?? false; - const experimentalEmptySelectionSets = - options?.experimentalEmptySelectionSets ?? false; + const allowEmptySelectionSets = options?.allowEmptySelectionSets ?? true; // If the schema used for validation is invalid, throw an error. assertValidSchema(schema); @@ -166,7 +163,7 @@ function validateImpl( } errors.push(error); }, - { hideSuggestions, experimentalEmptySelectionSets }, + { hideSuggestions, allowEmptySelectionSets }, ); // This uses a specialized visitor which runs multiple visitors in parallel, diff --git a/website/pages/docs/experimental-specification-features.mdx b/website/pages/docs/experimental-specification-features.mdx index f6e9ee5f88..e7f3000c18 100644 --- a/website/pages/docs/experimental-specification-features.mdx +++ b/website/pages/docs/experimental-specification-features.mdx @@ -65,47 +65,3 @@ fragment spreads pass values for them. GraphQL.js exposes the syntax through the `FragmentArgumentNode`, and execution supports the resulting values. See [Fragment Arguments](/docs/fragment-arguments). - -## Empty selection sets - -Empty selection sets relax the grammar from `SelectionSet : { Selection+ }` to -`SelectionSet : { Selection* }`, so a selection set on an object, interface, or -union type only has to be present rather than non-empty. Leaf fields are -unaffected: a scalar or enum field must still have no selection set at all, and -`name { }` remains invalid. - -This helps clients that strip fields during query preprocessing. When every -server-directed field of a selection set is handled locally (Apollo's `@client`, -Relay Resolvers, and similar client-side extensions), what remains is an empty -selection set, and the client no longer has to inject a placeholder field such -as `__typename` or rewrite the enclosing selection away. - -GraphQL.js exposes the syntax through the `experimentalEmptySelectionSets` -parser option and the matching `experimentalEmptySelectionSets` validation -option; `graphql()` and `graphqlSync()` accept both from their single arguments -object. Execution needs no flag — an empty selection set resolves to an empty -object. - -```js -import { graphql, buildSchema } from 'graphql'; - -const schema = buildSchema(` - type Query { - viewer: Viewer - } - - type Viewer { - id: ID - } -`); - -const result = await graphql({ - schema, - source: '{ viewer { } }', - rootValue: { viewer: { id: '1' } }, - experimentalEmptySelectionSets: true, -}); -// { data: { viewer: {} } } -``` - -See [the specification proposal](https://github.com/graphql/graphql-spec/pull/1227). From 7a9cc7591e1a91439474ab762c7956a0139d3e8c Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Tue, 8 Sep 2026 11:05:55 +0200 Subject: [PATCH 6/8] Add a validation error if the root selection set is empty for subscriptions --- .../__tests__/SingleFieldSubscriptionsRule-test.ts | 12 ++++++++++++ src/validation/rules/SingleFieldSubscriptionsRule.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts b/src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts index 21346fdf88..ee37186a00 100644 --- a/src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts +++ b/src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts @@ -52,6 +52,18 @@ describe('Validate: Subscriptions with single field', () => { `); }); + it('fails with no root field', () => { + expectErrors(` + subscription NoRootField { + } + `).toDeepEqual([ + { + message: 'Subscription must select exactly one top level field.', + locations: [{ line: 2, column: 32 }], + }, + ]); + }); + it('valid subscription with fragment', () => { // From https://spec.graphql.org/draft/#example-13061 expectValid(` diff --git a/src/validation/rules/SingleFieldSubscriptionsRule.ts b/src/validation/rules/SingleFieldSubscriptionsRule.ts index cf20f70858..f415b6e1d6 100644 --- a/src/validation/rules/SingleFieldSubscriptionsRule.ts +++ b/src/validation/rules/SingleFieldSubscriptionsRule.ts @@ -104,7 +104,14 @@ export function SingleFieldSubscriptionsRule( ); return; } - if (groupedFieldSet.size > 1) { + if (node.selectionSet.selections.length === 0) { + context.reportError( + new GraphQLError( + 'Subscription must select exactly one top level field.', + { nodes: node.selectionSet }, + ), + ); + } else if (groupedFieldSet.size > 1) { const fieldDetailsLists = [...groupedFieldSet.values()]; const extraFieldDetailsLists = fieldDetailsLists.slice(1); const extraFieldSelections = extraFieldDetailsLists.flatMap( From 7550b25f45d8d39486574f2ceab691de707f24a3 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Tue, 8 Sep 2026 11:27:07 +0200 Subject: [PATCH 7/8] fix detecting empty fragments --- src/language/parser.ts | 4 ++ src/validation/ValidationContext.ts | 6 ++- .../SingleFieldSubscriptionsRule-test.ts | 37 ++++++++++++++++++- .../rules/SingleFieldSubscriptionsRule.ts | 6 ++- src/validation/validate.ts | 1 + 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/language/parser.ts b/src/language/parser.ts index 2bd2a1190b..958f9c7773 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -130,6 +130,7 @@ export interface ParseOptions { * viewer { } * } * ``` + * @deprecated empty selection sets are now always allowed; */ allowEmptySelectionSets?: boolean | undefined; @@ -599,6 +600,9 @@ export class Parser { * SelectionSet : { Selection* } * ``` * + * `allowEmptySelectionSets` is deprecated and will be removed in v18, at + * which point this will be the only grammar. + * * @internal */ parseSelectionSet(): SelectionSetNode { diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index f6fc1ac1d9..d4d343912f 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -180,7 +180,10 @@ export type SDLValidationRule = (context: SDLValidationContext) => ASTVisitor; interface ValidationContextOptions { /** Whether suggestion text should be omitted from errors. */ hideSuggestions?: Maybe; - /** Whether selection sets on composite types may be empty. */ + /** + * Whether selection sets on composite types may be empty. + * @deprecated empty selection sets are now always allowed; + */ allowEmptySelectionSets?: Maybe; } @@ -291,6 +294,7 @@ export class ValidationContext extends ASTValidationContext { /** * Returns whether empty selection sets on composite types are allowed. * @returns True when a selection set on a composite type may be empty. + * @deprecated empty selection sets are now always allowed; */ get allowEmptySelectionSets(): boolean { return this._allowEmptySelectionSets; diff --git a/src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts b/src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts index ee37186a00..bf7529c49d 100644 --- a/src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts +++ b/src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts @@ -58,12 +58,39 @@ describe('Validate: Subscriptions with single field', () => { } `).toDeepEqual([ { - message: 'Subscription must select exactly one top level field.', + message: 'Subscription "NoRootField" must select one top level field.', locations: [{ line: 2, column: 32 }], }, ]); }); + it('fails with no root field in anonymous subscription', () => { + expectErrors(` + subscription { + } + `).toDeepEqual([ + { + message: 'Anonymous Subscription must select one top level field.', + locations: [{ line: 2, column: 20 }], + }, + ]); + }); + + it('fails with no root field through empty fragment', () => { + expectErrors(` + subscription { + ...Empty + } + fragment Empty on SubscriptionRoot { + } + `).toDeepEqual([ + { + message: 'Anonymous Subscription must select one top level field.', + locations: [{ line: 2, column: 20 }], + }, + ]); + }); + it('valid subscription with fragment', () => { // From https://spec.graphql.org/draft/#example-13061 expectValid(` @@ -215,7 +242,13 @@ describe('Validate: Subscriptions with single field', () => { fragment A on SubscriptionRoot { ...A } - `).toDeepEqual([]); + `).toDeepEqual([ + { + message: + 'Subscription "NoInfiniteLoop" must select one top level field.', + locations: [{ line: 2, column: 35 }], + }, + ]); }); it('fails with many more than one root field via fragments (anonymous)', () => { diff --git a/src/validation/rules/SingleFieldSubscriptionsRule.ts b/src/validation/rules/SingleFieldSubscriptionsRule.ts index f415b6e1d6..a892f0bd92 100644 --- a/src/validation/rules/SingleFieldSubscriptionsRule.ts +++ b/src/validation/rules/SingleFieldSubscriptionsRule.ts @@ -104,10 +104,12 @@ export function SingleFieldSubscriptionsRule( ); return; } - if (node.selectionSet.selections.length === 0) { + if (groupedFieldSet.size === 0) { context.reportError( new GraphQLError( - 'Subscription must select exactly one top level field.', + operationName != null + ? `Subscription "${operationName}" must select one top level field.` + : 'Anonymous Subscription must select one top level field.', { nodes: node.selectionSet }, ), ); diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 8665b39ae4..4835997ce1 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -39,6 +39,7 @@ export interface ValidationOptions { * Pair this with the `allowEmptySelectionSets` parse option. * * See https://github.com/graphql/graphql-spec/pull/1227 + * @deprecated empty selection sets are now always allowed; */ allowEmptySelectionSets?: boolean | undefined; } From 63783fb2e54f904489db10baf02886bfa2fdccff Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 14 Oct 2026 14:19:30 +0300 Subject: [PATCH 8/8] docs: document deprecated ValidationContext constructor overload --- src/validation/ValidationContext.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/validation/ValidationContext.ts b/src/validation/ValidationContext.ts index d4d343912f..fe23ce8c43 100644 --- a/src/validation/ValidationContext.ts +++ b/src/validation/ValidationContext.ts @@ -244,8 +244,32 @@ export class ValidationContext extends ASTValidationContext { onError: (error: GraphQLError) => void, options?: ValidationContextOptions, ); - // eslint-disable-next-line - /** @deprecated Please pass options object; this signature will be removed in V18 */ + /** + * Creates a ValidationContext instance using the legacy positional options. + * @param schema - Schema used to validate the document. + * @param ast - Document AST being validated. + * @param typeInfo - TypeInfo instance used to track traversal state. + * @param onError - Callback invoked for each validation error. + * @param hideSuggestions - Whether suggestion text should be omitted from errors. + * @example + * ```ts + * import { parse } from 'graphql/language'; + * import { buildSchema, TypeInfo } from 'graphql/utilities'; + * import { ValidationContext } from 'graphql/validation'; + * + * const schema = buildSchema('type Query { greeting: String }'); + * const context = new ValidationContext( + * schema, + * parse('{ greeting }'), + * new TypeInfo(schema), + * () => {}, + * true, + * ); + * + * context.hideSuggestions; // => true + * ``` + * @deprecated Please pass an options object instead; this signature will be removed in v18. + */ constructor( schema: GraphQLSchema, ast: DocumentNode,