Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/execution/__tests__/executor-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
Expand Down
68 changes: 68 additions & 0 deletions src/language/__tests__/parser-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }');

Expand Down
19 changes: 19 additions & 0 deletions src/language/__tests__/printer-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand Down
36 changes: 31 additions & 5 deletions src/language/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -578,16 +595,25 @@ export class Parser {
* SelectionSet : { Selection+ }
* ```
*
* With `experimentalEmptySelectionSets` enabled:
*
* ```
* SelectionSet : { Selection* }
* ```
*
* @internal
*/
parseSelectionSet(): SelectionSetNode {
return this.node<SelectionSetNode>(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,
),
});
}

Expand Down
5 changes: 4 additions & 1 deletion src/language/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ const printDocASTReducer: ASTReducer<string> = {
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 }) {
Expand Down
46 changes: 45 additions & 1 deletion src/validation/ValidationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ export class SDLValidationContext extends ASTValidationContext {
/** @internal */
export type SDLValidationRule = (context: SDLValidationContext) => ASTVisitor;

/** Options used when constructing a {@link ValidationContext}. */
interface ValidationContextOptions {
/** Whether suggestion text should be omitted from errors. */
hideSuggestions?: Maybe<boolean>;
/** Whether selection sets on composite types may be empty. */
experimentalEmptySelectionSets?: Maybe<boolean>;
}

/** Validation context passed to query validation rules. */
export class ValidationContext extends ASTValidationContext {
private _schema: GraphQLSchema;
Expand All @@ -190,14 +198,15 @@ export class ValidationContext extends ASTValidationContext {
ReadonlyArray<VariableUsage>
>;
private _hideSuggestions: boolean;
private _experimentalEmptySelectionSets: boolean;

/**
* Creates a ValidationContext instance.
* @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.
* @param options - Validation options.
* @example
* ```ts
* import { parse } from 'graphql/language';
Expand Down Expand Up @@ -230,14 +239,38 @@ export class ValidationContext extends ASTValidationContext {
ast: DocumentNode,
typeInfo: TypeInfo,
onError: (error: GraphQLError) => void,
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<boolean>,
);
constructor(
schema: GraphQLSchema,
ast: DocumentNode,
typeInfo: TypeInfo,
onError: (error: GraphQLError) => void,
hideSuggestionsOrOptions?: Maybe<boolean> | ValidationContextOptions,
) {
Comment thread
martinbonnin marked this conversation as resolved.
Resolved
const options =
typeof hideSuggestionsOrOptions === 'boolean'
? { hideSuggestions: hideSuggestionsOrOptions }
: (hideSuggestionsOrOptions ?? {});
const { hideSuggestions, experimentalEmptySelectionSets } = 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;
}

/**
Expand All @@ -256,6 +289,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.
Expand Down
29 changes: 29 additions & 0 deletions src/validation/__tests__/ScalarLeafsRule-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(`
{
Expand Down
10 changes: 10 additions & 0 deletions src/validation/__tests__/ValidationContext-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 4 additions & 1 deletion src/validation/rules/ScalarLeafsRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading