Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 { }', {
allowEmptySelectionSets: true,
});

const result = executeSync({ schema, document });
expect(result).to.deep.equal({ data: { a: 'Apple', deep: {} } });

const emptyDocument = parse('{ }', {
allowEmptySelectionSets: 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
72 changes: 70 additions & 2 deletions src/language/__tests__/parser-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ 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 type { ParseOptions } from '../parser.ts';
import {
parse,
parseConstValue,
Expand All @@ -23,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', () => {
Expand Down Expand Up @@ -502,6 +507,69 @@ describe('Parser', () => {
expect(() => parse(document)).to.throw();
});

it('allows parsing empty selection sets', () => {
const document = parse('{ node { } }', {
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('{ }', {
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 { }', {
noLocation: true,
});
const fragment = document.definitions[0] as FragmentDefinitionNode;

expect(fragment.selectionSet.selections).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 }],
});
});

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 { } } }', {
allowEmptySelectionSets: true,
});
expect(print(ast)).to.equal(dedent`
{
foo {}
bar {
... on Baz {}
}
}
`);

const emptyOperationAST = parse('{ }', {
allowEmptySelectionSets: true,
});
expect(print(emptyOperationAST)).to.equal('{}');
});

it('produces helpful error messages', () => {
const badAST = { random: 'Data' };

Expand Down
34 changes: 29 additions & 5 deletions src/language/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,22 @@ export interface ParseOptions {
*/
experimentalFragmentArguments?: boolean | undefined;

/**
* 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
* ```graphql prettier-ignore
* {
* viewer { }
* }
* ```
* @deprecated empty selection sets are now always allowed;
*/
allowEmptySelectionSets?: 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 +594,24 @@ export class Parser {
* SelectionSet : { Selection+ }
* ```
*
* With `allowEmptySelectionSets` enabled (the default):
*
* ```
* SelectionSet : { Selection* }
* ```
*
* `allowEmptySelectionSets` is deprecated and will be removed in v18, at
* which point this will be the only grammar.
*
* @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.allowEmptySelectionSets === false
? this.many(TokenKind.BRACE_L, this.parseSelection, TokenKind.BRACE_R)
: this.any(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
70 changes: 69 additions & 1 deletion src/validation/ValidationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,17 @@ 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.
* @deprecated empty selection sets are now always allowed;
*/
allowEmptySelectionSets?: Maybe<boolean>;
}

/** Validation context passed to query validation rules. */
export class ValidationContext extends ASTValidationContext {
private _schema: GraphQLSchema;
Expand All @@ -190,14 +201,15 @@ export class ValidationContext extends ASTValidationContext {
ReadonlyArray<VariableUsage>
>;
private _hideSuggestions: boolean;
private _allowEmptySelectionSets: 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 +242,61 @@ export class ValidationContext extends ASTValidationContext {
ast: DocumentNode,
typeInfo: TypeInfo,
onError: (error: GraphQLError) => void,
options?: ValidationContextOptions,
);
/**
* 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,
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, allowEmptySelectionSets } = options;
super(ast, onError);
this._schema = schema;
this._typeInfo = typeInfo;
this._variableUsages = new Map();
this._recursiveVariableUsages = new Map();
this._hideSuggestions = hideSuggestions ?? false;
this._allowEmptySelectionSets = allowEmptySelectionSets ?? true;
}

/**
Expand All @@ -256,6 +315,15 @@ export class ValidationContext extends ASTValidationContext {
return this._hideSuggestions;
}

/**
* 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;
}

/**
* Returns the schema being used by this validation context.
* @returns The schema being validated against.
Expand Down
Loading
Loading