diff --git a/src/type/__tests__/validation-test.js b/src/type/__tests__/validation-test.js index d3a0c55c68..2105f1bda3 100644 --- a/src/type/__tests__/validation-test.js +++ b/src/type/__tests__/validation-test.js @@ -896,6 +896,117 @@ describe('Type System: Objects can only implement unique interfaces', () => { }); }); +describe('Type System: Interface extensions should be valid', () => { + it('rejects an Object implementing the extended interface due to missing field', () => { + const schema = buildSchema(` + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + `); + const extendedSchema = extendSchema( + schema, + parse(` + extend interface AnotherInterface { + newField: String + } + `), + ); + expect(validateSchema(extendedSchema)).to.containSubset([ + { + message: + 'Interface field AnotherInterface.newField expected but AnotherObject does not provide it.', + locations: [{ line: 3, column: 11 }, { line: 10, column: 7 }], + }, + ]); + }); + + it('rejects an Object implementing the extended interface due to missing field args', () => { + const schema = buildSchema(` + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + `); + const extendedSchema = extendSchema( + schema, + parse(` + extend interface AnotherInterface { + newField(test: Boolean): String + } + + extend type AnotherObject { + newField: String + } + `), + ); + expect(validateSchema(extendedSchema)).to.containSubset([ + { + message: + 'Interface field argument AnotherInterface.newField(test:) expected but AnotherObject.newField does not provide it.', + locations: [{ line: 3, column: 20 }, { line: 7, column: 11 }], + }, + ]); + }); + + it('rejects Objects implementing the extended interface due to mismatching interface type', () => { + const schema = buildSchema(` + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: String + } + + type AnotherObject implements AnotherInterface { + field: String + } + `); + const extendedSchema = extendSchema( + schema, + parse(` + extend interface AnotherInterface { + newInterfaceField: NewInterface + } + + interface NewInterface { + newField: String + } + + interface MismatchingInterface { + newField: String + } + + extend type AnotherObject { + newInterfaceField: MismatchingInterface + } + `), + ); + expect(validateSchema(extendedSchema)).to.containSubset([ + { + message: + 'Interface field AnotherInterface.newInterfaceField expects type NewInterface but AnotherObject.newInterfaceField is type MismatchingInterface.', + locations: [{ line: 3, column: 30 }, { line: 15, column: 30 }], + }, + ]); + }); +}); + describe('Type System: Interface fields must have output types', () => { function schemaWithInterfaceFieldOfType(fieldType) { const BadInterfaceType = new GraphQLInterfaceType({ diff --git a/src/utilities/__tests__/extendSchema-test.js b/src/utilities/__tests__/extendSchema-test.js index 6a971b7f3d..15bc843f9d 100644 --- a/src/utilities/__tests__/extendSchema-test.js +++ b/src/utilities/__tests__/extendSchema-test.js @@ -801,6 +801,220 @@ describe('extendSchema', () => { `); }); + it('extends interfaces by adding new fields', () => { + const ast = parse(` + extend interface SomeInterface { + newField: String + } + + extend type Bar { + newField: String + } + + extend type Foo { + newField: String + } + `); + const originalPrint = printSchema(testSchema); + const extendedSchema = extendSchema(testSchema, ast); + expect(extendedSchema).to.not.equal(testSchema); + expect(printSchema(testSchema)).to.equal(originalPrint); + expect(printSchema(extendedSchema)).to.equal(dedent` + type Bar implements SomeInterface { + name: String + some: SomeInterface + foo: Foo + newField: String + } + + type Biz { + fizz: String + } + + type Foo implements SomeInterface { + name: String + some: SomeInterface + tree: [Foo]! + newField: String + } + + type Query { + foo: Foo + someUnion: SomeUnion + someEnum: SomeEnum + someInterface(id: ID!): SomeInterface + } + + enum SomeEnum { + ONE + TWO + } + + interface SomeInterface { + name: String + some: SomeInterface + newField: String + } + + union SomeUnion = Foo | Biz + `); + }); + + it('allows extension of interface with missing Object fields', () => { + const ast = parse(` + extend interface SomeInterface { + newObject: NewObject + newInterface: NewInterface + newUnion: NewUnion + newScalar: NewScalar + newTree: [Foo]! + newField(arg1: String, arg2: NewEnum!): String + } + + type NewObject implements NewInterface { + baz: String + } + + type NewOtherObject { + fizz: Int + } + + interface NewInterface { + baz: String + } + + union NewUnion = NewObject | NewOtherObject + + scalar NewScalar + + enum NewEnum { + OPTION_A + OPTION_B + } + `); + const originalPrint = printSchema(testSchema); + const extendedSchema = extendSchema(testSchema, ast); + expect(extendedSchema).to.not.equal(testSchema); + expect(printSchema(testSchema)).to.equal(originalPrint); + expect(printSchema(extendedSchema)).to.equal(dedent` + type Bar implements SomeInterface { + name: String + some: SomeInterface + foo: Foo + } + + type Biz { + fizz: String + } + + type Foo implements SomeInterface { + name: String + some: SomeInterface + tree: [Foo]! + } + + enum NewEnum { + OPTION_A + OPTION_B + } + + interface NewInterface { + baz: String + } + + type NewObject implements NewInterface { + baz: String + } + + type NewOtherObject { + fizz: Int + } + + scalar NewScalar + + union NewUnion = NewObject | NewOtherObject + + type Query { + foo: Foo + someUnion: SomeUnion + someEnum: SomeEnum + someInterface(id: ID!): SomeInterface + } + + enum SomeEnum { + ONE + TWO + } + + interface SomeInterface { + name: String + some: SomeInterface + newObject: NewObject + newInterface: NewInterface + newUnion: NewUnion + newScalar: NewScalar + newTree: [Foo]! + newField(arg1: String, arg2: NewEnum!): String + } + + union SomeUnion = Foo | Biz + `); + }); + + it('extends interfaces multiple times', () => { + const ast = parse(` + extend interface SomeInterface { + newFieldA: Int + } + + extend interface SomeInterface { + newFieldB(test: Boolean): String + } + `); + const originalPrint = printSchema(testSchema); + const extendedSchema = extendSchema(testSchema, ast); + expect(extendedSchema).to.not.equal(testSchema); + expect(printSchema(testSchema)).to.equal(originalPrint); + expect(printSchema(extendedSchema)).to.equal(dedent` + type Bar implements SomeInterface { + name: String + some: SomeInterface + foo: Foo + } + + type Biz { + fizz: String + } + + type Foo implements SomeInterface { + name: String + some: SomeInterface + tree: [Foo]! + } + + type Query { + foo: Foo + someUnion: SomeUnion + someEnum: SomeEnum + someInterface(id: ID!): SomeInterface + } + + enum SomeEnum { + ONE + TWO + } + + interface SomeInterface { + name: String + some: SomeInterface + newFieldA: Int + newFieldB(test: Boolean): String + } + + union SomeUnion = Foo | Biz + `); + }); + it('may extend mutations and subscriptions', () => { const mutationSchema = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -994,6 +1208,18 @@ describe('extendSchema', () => { ); }); + it('does not allow extending an unknown interface type', () => { + const ast = parse(` + extend interface UnknownInterfaceType { + baz: String + } + `); + expect(() => extendSchema(testSchema, ast)).to.throw( + 'Cannot extend interface "UnknownInterfaceType" because it does not ' + + 'exist in the existing schema.', + ); + }); + it('maintains configuration of the original schema object', () => { const testSchemaWithLegacyNames = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -1014,7 +1240,7 @@ describe('extendSchema', () => { }); describe('does not allow extending a non-object type', () => { - it('not an interface', () => { + it('not an object', () => { const ast = parse(` extend type SomeInterface { baz: String @@ -1025,6 +1251,17 @@ describe('extendSchema', () => { ); }); + it('not an interface', () => { + const ast = parse(` + extend interface Foo { + baz: String + } + `); + expect(() => extendSchema(testSchema, ast)).to.throw( + 'Cannot extend non-interface type "Foo".', + ); + }); + it('not a scalar', () => { const ast = parse(` extend type String { diff --git a/src/utilities/extendSchema.js b/src/utilities/extendSchema.js index 4ef8deabe1..860e067fde 100644 --- a/src/utilities/extendSchema.js +++ b/src/utilities/extendSchema.js @@ -31,7 +31,11 @@ import { Kind } from '../language/kinds'; import type { GraphQLType, GraphQLNamedType } from '../type/definition'; -import type { DocumentNode, DirectiveDefinitionNode } from '../language/ast'; +import type { + DocumentNode, + DirectiveDefinitionNode, + TypeExtensionNode, +} from '../language/ast'; type Options = {| /** @@ -130,13 +134,33 @@ export function extendSchema( [def], ); } - let extensions = typeExtensionsMap[extendedTypeName]; - if (extensions) { - extensions.push(def); - } else { - extensions = [def]; + typeExtensionsMap[extendedTypeName] = appendExtensionToTypeExtensions( + def, + typeExtensionsMap[extendedTypeName], + ); + break; + case Kind.INTERFACE_TYPE_EXTENSION: + const extendedInterfaceTypeName = def.name.value; + const existingInterfaceType = schema.getType(extendedInterfaceTypeName); + if (!existingInterfaceType) { + throw new GraphQLError( + `Cannot extend interface "${extendedInterfaceTypeName}" because ` + + 'it does not exist in the existing schema.', + [def], + ); } - typeExtensionsMap[extendedTypeName] = extensions; + if (!isInterfaceType(existingInterfaceType)) { + throw new GraphQLError( + `Cannot extend non-interface type "${extendedInterfaceTypeName}".`, + [def], + ); + } + typeExtensionsMap[ + extendedInterfaceTypeName + ] = appendExtensionToTypeExtensions( + def, + typeExtensionsMap[extendedInterfaceTypeName], + ); break; case Kind.DIRECTIVE_DEFINITION: const directiveName = def.name.value; @@ -151,7 +175,6 @@ export function extendSchema( directiveDefinitions.push(def); break; case Kind.SCALAR_TYPE_EXTENSION: - case Kind.INTERFACE_TYPE_EXTENSION: case Kind.UNION_TYPE_EXTENSION: case Kind.ENUM_TYPE_EXTENSION: case Kind.INPUT_OBJECT_TYPE_EXTENSION: @@ -234,6 +257,17 @@ export function extendSchema( schema.__allowedLegacyNames && schema.__allowedLegacyNames.slice(), }); + function appendExtensionToTypeExtensions( + extension: TypeExtensionNode, + existingTypeExtensions: ?Array, + ): Array { + if (!existingTypeExtensions) { + return [extension]; + } + existingTypeExtensions.push(extension); + return existingTypeExtensions; + } + // Below are functions used for producing this schema that have closed over // this scope and have access to the schema, cache, and newly defined types. @@ -288,11 +322,18 @@ export function extendSchema( function extendInterfaceType( type: GraphQLInterfaceType, ): GraphQLInterfaceType { + const name = type.name; + const extensionASTNodes = typeExtensionsMap[name] + ? type.extensionASTNodes + ? type.extensionASTNodes.concat(typeExtensionsMap[name]) + : typeExtensionsMap[name] + : type.extensionASTNodes; return new GraphQLInterfaceType({ name: type.name, description: type.description, fields: () => extendFieldMap(type), astNode: type.astNode, + extensionASTNodes, resolveType: type.resolveType, }); }