diff --git a/src/language/__tests__/schema-parser-test.ts b/src/language/__tests__/schema-parser-test.ts index cbedcb3030..b256fa91ba 100644 --- a/src/language/__tests__/schema-parser-test.ts +++ b/src/language/__tests__/schema-parser-test.ts @@ -110,6 +110,109 @@ describe('Schema Parser', () => { }); }); + it('rejects empty fields definition by default', () => { + expectSyntaxError('type Hello {}').to.deep.equal({ + message: 'Syntax Error: Expected Name, found "}".', + locations: [{ line: 1, column: 13 }], + }); + }); + + it('parses empty fields definition with experimentalAllowEmptyTypes', () => { + const doc = parse('type Hello {}', { + experimentalAllowEmptyTypes: true, + }); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'ObjectTypeDefinition', + name: nameNode('Hello', { start: 5, end: 10 }), + description: undefined, + interfaces: undefined, + directives: undefined, + fields: [], + loc: { start: 0, end: 13 }, + }, + ], + loc: { start: 0, end: 13 }, + }); + }); + + it('parses empty fields definition on interfaces and extensions with experimentalAllowEmptyTypes', () => { + const doc = parse('interface Hello {} extend type World {}', { + experimentalAllowEmptyTypes: true, + }); + + expectJSON(doc).toDeepNestedProperty('definitions[0].fields', []); + 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 interface fields definition with experimentalAllowEmptyTypes', () => { + const doc = parse('interface Hello {}', { + experimentalAllowEmptyTypes: 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('rejects empty input fields definition by default', () => { + expectSyntaxError('input Hello {}').to.deep.equal({ + message: 'Syntax Error: Expected Name, found "}".', + locations: [{ line: 1, column: 14 }], + }); + }); + + it('parses empty input fields definition with experimentalAllowEmptyTypes', () => { + const doc = parse('input Hello {}', { + experimentalAllowEmptyTypes: true, + }); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'InputObjectTypeDefinition', + name: nameNode('Hello', { start: 6, end: 11 }), + description: undefined, + directives: undefined, + fields: [], + loc: { start: 0, end: 14 }, + }, + ], + loc: { start: 0, end: 14 }, + }); + }); + + it('parses empty input fields definition on extensions with experimentalAllowEmptyTypes', () => { + const doc = parse('extend input Hello {}', { + experimentalAllowEmptyTypes: true, + }); + + expectJSON(doc).toDeepNestedProperty('definitions[0].fields', []); + }); + it('parses type with description string', () => { const doc = parse(dedent` "Description" diff --git a/src/language/parser.ts b/src/language/parser.ts index a5cba948cc..c5a18e428a 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -161,6 +161,23 @@ export interface ParseOptions { * @internal */ lexer?: LexerInterface | undefined; + + /** + * EXPERIMENTAL: + * + * If enabled, the parser accepts an empty set of braces as a fields + * definition or an input fields definition, as proposed in + * https://github.com/graphql/graphql-spec/pull/1228, + * https://github.com/graphql/graphql-spec/pull/1229 and + * https://github.com/graphql/graphql-spec/pull/1249 + * + * ```graphql + * type Empty {} + * interface Empty {} + * input Empty {} + * ``` + */ + experimentalAllowEmptyTypes?: boolean | undefined; } /** @@ -1204,9 +1221,25 @@ export class Parser { * FieldsDefinition : { FieldDefinition+ } * ``` * + * With `experimentalAllowEmptyTypes`: + * + * ``` + * FieldsDefinition : { FieldDefinition* } + * ``` + * * @internal */ parseFieldsDefinition(): Array | undefined { + if ( + this._options.experimentalAllowEmptyTypes === true && + this.peek(TokenKind.BRACE_L) + ) { + return this.any( + TokenKind.BRACE_L, + this.parseFieldDefinition, + TokenKind.BRACE_R, + ); + } return this.optionalMany( TokenKind.BRACE_L, this.parseFieldDefinition, @@ -1441,9 +1474,25 @@ export class Parser { * InputFieldsDefinition : { InputValueDefinition+ } * ``` * + * With `experimentalAllowEmptyTypes`: + * + * ``` + * InputFieldsDefinition : { InputValueDefinition* } + * ``` + * * @internal */ parseInputFieldsDefinition(): Array | undefined { + if ( + this._options.experimentalAllowEmptyTypes === true && + this.peek(TokenKind.BRACE_L) + ) { + return this.any( + TokenKind.BRACE_L, + this.parseInputValueDef, + TokenKind.BRACE_R, + ); + } return this.optionalMany( TokenKind.BRACE_L, this.parseInputValueDef, diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index a5bd4012fe..16b2ccc710 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -589,6 +589,151 @@ describe('Type System: Objects must have fields', () => { ]); }); + it('accepts an Object type with no fields when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema( + ` + type Query { + test: EmptyObject + } + + type EmptyObject + `, + { experimentalAllowEmptyTypes: true }, + ); + expect(schema.experimentalAllowEmptyTypes).to.equal(true); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const manualSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + test: { + type: new GraphQLObjectType({ name: 'EmptyObject', fields: {} }), + }, + }, + }), + experimentalAllowEmptyTypes: true, + }); + expectJSON(validateSchema(manualSchema)).toDeepEqual([]); + + const extendedSchema = extendSchema( + schema, + parse('type AnotherEmptyObject'), + ); + expect(extendedSchema.experimentalAllowEmptyTypes).to.equal(true); + expectJSON(validateSchema(extendedSchema)).toDeepEqual([]); + }); + + it('accepts an Object type with empty braces when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema( + ` + type Query { + test: EmptyObject + } + + type EmptyObject {} + `, + { experimentalAllowEmptyTypes: true }, + ); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts an empty Query root type when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema('type Query', { + experimentalAllowEmptyTypes: true, + }); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts an Interface type with no fields when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema( + ` + type Query { + test: EmptyInterface + other: OtherEmptyInterface + } + + interface EmptyInterface + + interface OtherEmptyInterface {} + + type SomeObject implements EmptyInterface & OtherEmptyInterface { + field: String + } + `, + { experimentalAllowEmptyTypes: true }, + ); + expect(schema.experimentalAllowEmptyTypes).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(), + experimentalAllowEmptyTypes: true, + }); + expectJSON(validateSchema(manualSchemaWithFlag)).toDeepEqual([]); + + const extendedSchema = extendSchema( + schema, + parse('interface AnotherEmptyInterface'), + ); + expect(extendedSchema.experimentalAllowEmptyTypes).to.equal(true); + expectJSON(validateSchema(extendedSchema)).toDeepEqual([]); + }); + + it('accepts an Interface implementing an empty Interface when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema( + ` + type Query { + test: EmptyInterface + } + + interface EmptyInterface + + interface EmptyChildInterface implements EmptyInterface + + type SomeObject implements EmptyChildInterface & EmptyInterface { + field: String + } + `, + { experimentalAllowEmptyTypes: true }, + ); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts a Union type with no members when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema( + ` + type Query { + test: EmptyUnion + } + + union EmptyUnion + `, + { experimentalAllowEmptyTypes: true }, + ); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const manualSchema = schemaWithFieldType( + new GraphQLUnionType({ name: 'EmptyUnion', types: [] }), + ); + expectJSON(validateSchema(manualSchema)).toDeepEqual([ + { + message: 'Union type EmptyUnion must define one or more member types.', + }, + ]); + const manualSchemaWithFlag = new GraphQLSchema({ + ...manualSchema.toConfig(), + experimentalAllowEmptyTypes: true, + }); + expectJSON(validateSchema(manualSchemaWithFlag)).toDeepEqual([]); + }); + it('rejects an Object type with incorrectly named fields', () => { const schema = schemaWithFieldType( new GraphQLObjectType({ @@ -890,6 +1035,83 @@ describe('Type System: Input Objects must have fields', () => { ]); }); + it('accepts an Input Object type with no fields when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema( + ` + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject + `, + { experimentalAllowEmptyTypes: true }, + ); + expect(schema.experimentalAllowEmptyTypes).to.equal(true); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const manualSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + field: { + type: GraphQLString, + args: { + arg: { + type: new GraphQLInputObjectType({ + name: 'SomeInputObject', + fields: {}, + }), + }, + }, + }, + }, + }), + experimentalAllowEmptyTypes: true, + }); + expectJSON(validateSchema(manualSchema)).toDeepEqual([]); + + const extendedSchema = extendSchema( + schema, + parse('input AnotherInputObject'), + ); + expect(extendedSchema.experimentalAllowEmptyTypes).to.equal(true); + expectJSON(validateSchema(extendedSchema)).toDeepEqual([]); + }); + + it('accepts an Input Object type with empty braces when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema( + ` + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject {} + `, + { experimentalAllowEmptyTypes: true }, + ); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('rejects a OneOf Input Object type with no fields even when experimentalAllowEmptyTypes is enabled', () => { + const schema = buildSchema( + ` + type Query { + field(arg: SomeInputObject): String + } + + input SomeInputObject @oneOf + `, + { experimentalAllowEmptyTypes: true }, + ); + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Input Object type SomeInputObject must define one or more fields.', + locations: [{ line: 6, column: 7 }], + }, + ]); + }); + it('accepts an Input Object with breakable circular reference', () => { const schema = buildSchema(` type Query { diff --git a/src/type/schema.ts b/src/type/schema.ts index 59cf87011e..a49290ba06 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -229,6 +229,11 @@ export class GraphQLSchema { /** Whether this schema instance skips validation checks. */ assumeValid: boolean; + /** + * Whether object, interface and input object types may define no fields and + * union types may define no member types. + */ + experimentalAllowEmptyTypes: boolean; /** * Cached schema validation errors, if validation has already run. * @private @@ -345,6 +350,8 @@ export class GraphQLSchema { this.assumeValid = config.assumeValid ?? false; // Used as a cache for validateSchema(). this.__validationErrors = config.assumeValid === true ? [] : undefined; + this.experimentalAllowEmptyTypes = + config.experimentalAllowEmptyTypes ?? false; this.description = config.description; this.extensions = toObjMapWithSymbols(config.extensions); @@ -886,6 +893,7 @@ export class GraphQLSchema { astNode: this.astNode, extensionASTNodes: this.extensionASTNodes, assumeValid: this.assumeValid, + experimentalAllowEmptyTypes: this.experimentalAllowEmptyTypes, }; } } @@ -904,6 +912,22 @@ export interface GraphQLSchemaValidationOptions { * @internal */ assumeValid?: boolean | undefined; + + /** + * EXPERIMENTAL: + * + * If enabled, object, interface and input object types are allowed to define + * no fields, and union types are allowed to define no member types, as + * proposed in https://github.com/graphql/graphql-spec/pull/1228, + * https://github.com/graphql/graphql-spec/pull/1229 and + * https://github.com/graphql/graphql-spec/pull/1249 + * + * OneOf input object types must still define one or more fields, because an + * empty OneOf input object can never be given a valid value. + * + * Default: false + */ + experimentalAllowEmptyTypes?: boolean | undefined; } /** Configuration used to construct a GraphQLSchema. */ @@ -936,6 +960,7 @@ export interface GraphQLSchemaNormalizedConfig extends GraphQLSchemaConfig { extensions: Readonly; extensionASTNodes: ReadonlyArray; assumeValid: boolean; + experimentalAllowEmptyTypes: boolean; } function collectReferencedTypes( diff --git a/src/type/validate.ts b/src/type/validate.ts index 8289b9df31..252ac4782a 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -476,8 +476,9 @@ function validateFields( ): void { const fields = Object.values(type.getFields()); - // Objects and Interfaces both must define one or more fields. - if (fields.length === 0) { + // Objects and Interfaces both must define one or more fields, unless empty + // types are explicitly allowed. + if (fields.length === 0 && !context.schema.experimentalAllowEmptyTypes) { context.reportError(`Type ${type} must define one or more fields.`, [ type.astNode, ...type.extensionASTNodes, @@ -675,7 +676,7 @@ function validateUnionMembers( ): void { const memberTypes = union.getTypes(); - if (memberTypes.length === 0) { + if (memberTypes.length === 0 && !context.schema.experimentalAllowEmptyTypes) { context.reportError( `Union type ${union} must define one or more member types.`, [union.astNode, ...union.extensionASTNodes], @@ -727,7 +728,13 @@ function validateInputFields( ): void { const fields = Object.values(inputObj.getFields()); - if (fields.length === 0) { + // Input Objects must define one or more fields, unless empty input object + // types are explicitly allowed. OneOf Input Objects must always define one or + // more fields, because an empty OneOf Input Object has no valid value. + if ( + fields.length === 0 && + !(context.schema.experimentalAllowEmptyTypes && !inputObj.isOneOf) + ) { context.reportError( `Input Object type ${inputObj} must define one or more fields.`, [inputObj.astNode, ...inputObj.extensionASTNodes], diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index 356da136ec..97d34a68f6 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -66,6 +66,93 @@ describe('Type System: build schema from introspection', () => { expect(cycleIntrospection(sdl)).to.equal(sdl); }); + it('builds a schema with an empty object type', () => { + const sdl = dedent` + type Query { + empty: Empty + } + + type Empty + `; + const serverSchema = buildSchema(sdl, { + experimentalAllowEmptyTypes: true, + }); + const introspection = introspectionFromSchema(serverSchema); + const clientSchema = buildClientSchema(introspection, { + experimentalAllowEmptyTypes: true, + }); + + expect(clientSchema.experimentalAllowEmptyTypes).to.equal(true); + expect(printSchema(clientSchema)).to.equal(sdl); + expect( + graphqlSync({ + schema: clientSchema, + source: '{ empty { __typename } }', + rootValue: { empty: {} }, + }), + ).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, { + experimentalAllowEmptyTypes: true, + }); + const introspection = introspectionFromSchema(serverSchema); + const clientSchema = buildClientSchema(introspection, { + experimentalAllowEmptyTypes: true, + }); + + expect(clientSchema.experimentalAllowEmptyTypes).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 with an empty input object type', () => { + const sdl = dedent` + type Query { + field(arg: Empty): String + } + + input Empty + `; + const serverSchema = buildSchema(sdl, { + experimentalAllowEmptyTypes: true, + }); + const introspection = introspectionFromSchema(serverSchema); + const clientSchema = buildClientSchema(introspection, { + experimentalAllowEmptyTypes: true, + }); + + expect(clientSchema.experimentalAllowEmptyTypes).to.equal(true); + expect(printSchema(clientSchema)).to.equal(sdl); + expect( + graphqlSync({ + schema: clientSchema, + source: '{ field(arg: {}) }', + rootValue: { field: 'ok' }, + }), + ).to.deep.equal({ data: { field: 'ok' } }); + }); + it('builds a schema without the query type', () => { const sdl = dedent` type Query { diff --git a/src/utilities/buildASTSchema.ts b/src/utilities/buildASTSchema.ts index 50a8a5993d..5343939d18 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -75,6 +75,7 @@ export function buildASTSchema( extensions: Object.create(null), extensionASTNodes: [], assumeValid: false, + experimentalAllowEmptyTypes: false, }; const config = extendSchemaImpl(emptySchemaConfig, documentAST, options); @@ -156,5 +157,6 @@ export function buildSchema( return buildASTSchema(document, { assumeValidSDL: options?.assumeValidSDL, assumeValid: options?.assumeValid, + experimentalAllowEmptyTypes: options?.experimentalAllowEmptyTypes, }); } diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 2a1baea37c..73a0d811c5 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -143,6 +143,7 @@ export function buildClientSchema( types: [...typeMap.values()], directives, assumeValid: options?.assumeValid, + experimentalAllowEmptyTypes: options?.experimentalAllowEmptyTypes, }); // Given a type reference in introspection, return the GraphQLType instance. diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 381407e9cf..7a50852bc5 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -301,6 +301,9 @@ export function extendSchemaImpl( astNode: schemaDef ?? config.astNode, extensionASTNodes: config.extensionASTNodes.concat(schemaExtensions), assumeValid: options?.assumeValid ?? false, + experimentalAllowEmptyTypes: + options?.experimentalAllowEmptyTypes ?? + config.experimentalAllowEmptyTypes, }; }, [SchemaElementKind.INPUT_OBJECT]: (config) => {