Skip to content
Draft
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
29 changes: 29 additions & 0 deletions src/language/__tests__/schema-parser-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,35 @@ describe('Schema Parser', () => {
expectJSON(doc).toDeepNestedProperty('definitions[1].fields', []);
});

it('rejects empty interface fields definition by default', () => {
expectSyntaxError('interface Hello {}').to.deep.equal({
message: 'Syntax Error: Expected Name, found "}".',
locations: [{ line: 1, column: 18 }],
});
});

it('parses empty fields definition with experimentalAllowEmptyInterfaceTypes', () => {
const doc = parse('interface Hello {}', {
experimentalAllowEmptyInterfaceTypes: true,
});

expectJSON(doc).toDeepEqual({
kind: 'Document',
definitions: [
{
kind: 'InterfaceTypeDefinition',
name: nameNode('Hello', { start: 10, end: 15 }),
description: undefined,
interfaces: undefined,
directives: undefined,
fields: [],
loc: { start: 0, end: 18 },
},
],
loc: { start: 0, end: 18 },
});
});

it('parses type with description string', () => {
const doc = parse(dedent`
"Description"
Expand Down
20 changes: 18 additions & 2 deletions src/language/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@ export interface ParseOptions {
* This is equivalent to omitting the braces entirely (`type Empty`).
*/
experimentalAllowEmptyObjectTypes?: boolean | undefined;

/**
* EXPERIMENTAL:
*
* If enabled, the parser accepts an empty set of braces as a fields
* definition, as proposed in https://github.com/graphql/graphql-spec/pull/1229
*
* ```graphql
* interface Empty {}
* ```
*
* This is equivalent to omitting the braces entirely (`interface Empty`).
*/
experimentalAllowEmptyInterfaceTypes?: boolean | undefined;
}

/**
Expand Down Expand Up @@ -1158,7 +1172,8 @@ export class Parser {
* FieldsDefinition : { FieldDefinition+ }
* ```
*
* With `experimentalAllowEmptyObjectTypes`:
* With `experimentalAllowEmptyObjectTypes` or
* `experimentalAllowEmptyInterfaceTypes`:
*
* ```
* FieldsDefinition : { FieldDefinition* }
Expand All @@ -1168,7 +1183,8 @@ export class Parser {
*/
parseFieldsDefinition(): Array<FieldDefinitionNode> | undefined {
if (
this._options.experimentalAllowEmptyObjectTypes === true &&
(this._options.experimentalAllowEmptyObjectTypes === true ||
this._options.experimentalAllowEmptyInterfaceTypes === true) &&
this.peek(TokenKind.BRACE_L)
) {
return this.any(
Expand Down
80 changes: 80 additions & 0 deletions src/type/__tests__/validation-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,86 @@ describe('Type System: Objects must have fields', () => {
]);
});

it('accepts an Interface type with no fields when experimentalAllowEmptyInterfaceTypes is enabled', () => {
const schema = buildSchema(
`
type Query {
test: EmptyInterface
other: OtherEmptyInterface
}

interface EmptyInterface

interface OtherEmptyInterface {}

type SomeObject implements EmptyInterface & OtherEmptyInterface {
field: String
}
`,
{ experimentalAllowEmptyInterfaceTypes: true },
);
expect(schema.experimentalAllowEmptyInterfaceTypes).to.equal(true);
expectJSON(validateSchema(schema)).toDeepEqual([]);

const manualSchema = schemaWithFieldType(
new GraphQLInterfaceType({ name: 'EmptyInterface', fields: {} }),
);
expectJSON(validateSchema(manualSchema)).toDeepEqual([
{ message: 'Type EmptyInterface must define one or more fields.' },
]);
const manualSchemaWithFlag = new GraphQLSchema({
...manualSchema.toConfig(),
experimentalAllowEmptyInterfaceTypes: true,
});
expectJSON(validateSchema(manualSchemaWithFlag)).toDeepEqual([]);

const extendedSchema = extendSchema(
schema,
parse('interface AnotherEmptyInterface'),
);
expect(extendedSchema.experimentalAllowEmptyInterfaceTypes).to.equal(true);
expectJSON(validateSchema(extendedSchema)).toDeepEqual([]);
});

it('accepts an Interface implementing an empty Interface when experimentalAllowEmptyInterfaceTypes is enabled', () => {
const schema = buildSchema(
`
type Query {
test: EmptyInterface
}

interface EmptyInterface

interface EmptyChildInterface implements EmptyInterface

type SomeObject implements EmptyChildInterface & EmptyInterface {
field: String
}
`,
{ experimentalAllowEmptyInterfaceTypes: true },
);
expectJSON(validateSchema(schema)).toDeepEqual([]);
});

it('rejects an Object type with no fields when only experimentalAllowEmptyInterfaceTypes is enabled', () => {
const schema = buildSchema(
`
type Query {
test: EmptyObject
}

type EmptyObject {}
`,
{ experimentalAllowEmptyInterfaceTypes: true },
);
expectJSON(validateSchema(schema)).toDeepEqual([
{
message: 'Type EmptyObject must define one or more fields.',
locations: [{ line: 6, column: 7 }],
},
]);
});

it('rejects an Object type with incorrectly named fields', () => {
const schema = schemaWithFieldType(
new GraphQLObjectType({
Expand Down
20 changes: 19 additions & 1 deletion src/type/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export class GraphQLSchema {
assumeValid: boolean;
/** Whether object types are allowed to define no fields. */
experimentalAllowEmptyObjectTypes: boolean;
/** Whether interface types are allowed to define no fields. */
experimentalAllowEmptyInterfaceTypes: boolean;
/**
* Cached schema validation errors, if validation has already run.
* @private
Expand Down Expand Up @@ -323,6 +325,8 @@ export class GraphQLSchema {
this.__validationErrors = config.assumeValid === true ? [] : undefined;
this.experimentalAllowEmptyObjectTypes =
config.experimentalAllowEmptyObjectTypes ?? false;
this.experimentalAllowEmptyInterfaceTypes =
config.experimentalAllowEmptyInterfaceTypes ?? false;

this.description = config.description;
this.extensions = toObjMapWithSymbols(config.extensions);
Expand Down Expand Up @@ -865,6 +869,8 @@ export class GraphQLSchema {
extensionASTNodes: this.extensionASTNodes,
assumeValid: this.assumeValid,
experimentalAllowEmptyObjectTypes: this.experimentalAllowEmptyObjectTypes,
experimentalAllowEmptyInterfaceTypes:
this.experimentalAllowEmptyInterfaceTypes,
};
}
}
Expand All @@ -890,11 +896,22 @@ export interface GraphQLSchemaValidationOptions {
* If enabled, object types are allowed to define no fields, as proposed in
* https://github.com/graphql/graphql-spec/pull/1228
*
* Interface types and input object types must still define one or more fields.
* Interface types (unless `experimentalAllowEmptyInterfaceTypes` is enabled)
* and input object types must still define one or more fields.
*
* Default: false
*/
experimentalAllowEmptyObjectTypes?: boolean | undefined;

/**
* EXPERIMENTAL:
*
* If enabled, interface types are allowed to define no fields, as proposed in
* https://github.com/graphql/graphql-spec/pull/1229
*
* Default: false
*/
experimentalAllowEmptyInterfaceTypes?: boolean | undefined;
}

/** Configuration used to construct a GraphQLSchema. */
Expand Down Expand Up @@ -928,6 +945,7 @@ export interface GraphQLSchemaNormalizedConfig extends GraphQLSchemaConfig {
extensionASTNodes: ReadonlyArray<SchemaExtensionNode>;
assumeValid: boolean;
experimentalAllowEmptyObjectTypes: boolean;
experimentalAllowEmptyInterfaceTypes: boolean;
}

function collectReferencedTypes(
Expand Down
10 changes: 5 additions & 5 deletions src/type/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,11 +479,11 @@ function validateFields(
const fields = Object.values(type.getFields());

// Objects and Interfaces both must define one or more fields, unless empty
// object types are explicitly allowed.
if (
fields.length === 0 &&
!(isObjectType(type) && context.schema.experimentalAllowEmptyObjectTypes)
) {
// object or interface types are explicitly allowed.
const allowEmpty = isObjectType(type)
? context.schema.experimentalAllowEmptyObjectTypes
: context.schema.experimentalAllowEmptyInterfaceTypes;
if (fields.length === 0 && !allowEmpty) {
context.reportError(`Type ${type} must define one or more fields.`, [
type.astNode,
...type.extensionASTNodes,
Expand Down
33 changes: 33 additions & 0 deletions src/utilities/__tests__/buildClientSchema-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,39 @@ describe('Type System: build schema from introspection', () => {
).to.deep.equal({ data: { empty: { __typename: 'Empty' } } });
});

it('builds a schema with an empty interface type', () => {
const sdl = dedent`
type Query {
empty: Empty
}

interface Empty

type Impl implements Empty {
field: String
}
`;
const serverSchema = buildSchema(sdl, {
experimentalAllowEmptyInterfaceTypes: true,
});
const introspection = introspectionFromSchema(serverSchema);
const clientSchema = buildClientSchema(introspection, {
experimentalAllowEmptyInterfaceTypes: true,
});

expect(clientSchema.experimentalAllowEmptyInterfaceTypes).to.equal(true);
expect(printSchema(clientSchema)).to.equal(sdl);
expect(
graphqlSync({
schema: clientSchema,
source: '{ empty { __typename ... on Impl { field } } }',
rootValue: { empty: { __typename: 'Impl', field: 'value' } },
}),
).to.deep.equal({
data: { empty: { __typename: 'Impl', field: 'value' } },
});
});

it('builds a schema without the query type', () => {
const sdl = dedent`
type Query {
Expand Down
5 changes: 5 additions & 0 deletions src/utilities/buildASTSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function buildASTSchema(
extensionASTNodes: [],
assumeValid: false,
experimentalAllowEmptyObjectTypes: false,
experimentalAllowEmptyInterfaceTypes: false,
};
const config = extendSchemaImpl(emptySchemaConfig, documentAST, options);

Expand Down Expand Up @@ -157,12 +158,16 @@ export function buildSchema(
experimentalFragmentArguments: options?.experimentalFragmentArguments,
experimentalAllowEmptyObjectTypes:
options?.experimentalAllowEmptyObjectTypes,
experimentalAllowEmptyInterfaceTypes:
options?.experimentalAllowEmptyInterfaceTypes,
});

return buildASTSchema(document, {
assumeValidSDL: options?.assumeValidSDL,
assumeValid: options?.assumeValid,
experimentalAllowEmptyObjectTypes:
options?.experimentalAllowEmptyObjectTypes,
experimentalAllowEmptyInterfaceTypes:
options?.experimentalAllowEmptyInterfaceTypes,
});
}
2 changes: 2 additions & 0 deletions src/utilities/buildClientSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export function buildClientSchema(
assumeValid: options?.assumeValid,
experimentalAllowEmptyObjectTypes:
options?.experimentalAllowEmptyObjectTypes,
experimentalAllowEmptyInterfaceTypes:
options?.experimentalAllowEmptyInterfaceTypes,
});

// Given a type reference in introspection, return the GraphQLType instance.
Expand Down
3 changes: 3 additions & 0 deletions src/utilities/extendSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ export function extendSchemaImpl(
experimentalAllowEmptyObjectTypes:
options?.experimentalAllowEmptyObjectTypes ??
config.experimentalAllowEmptyObjectTypes,
experimentalAllowEmptyInterfaceTypes:
options?.experimentalAllowEmptyInterfaceTypes ??
config.experimentalAllowEmptyInterfaceTypes,
};
},
[SchemaElementKind.INPUT_OBJECT]: (config) => {
Expand Down
Loading