From 986b79a94a9053062325dd9ca5bbbb810e7854d5 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 5 Apr 2022 13:25:02 +0300 Subject: [PATCH] allow unions to declare implementation of interfaces WIP: more tests required complete code coverage is already there, but goal is to have a test where union implements an interface wherever there is a test for an interface implementing interface --- src/__testUtils__/kitchenSinkSDL.ts | 22 +++ .../__tests__/union-interface-test.ts | 16 +- src/jsutils/mapValues.ts | 19 ++ src/language/__tests__/schema-parser-test.ts | 117 ++++++++++++ src/language/__tests__/schema-printer-test.ts | 22 +++ src/language/ast.ts | 12 +- src/language/parser.ts | 10 +- src/language/printer.ts | 13 +- src/type/__tests__/schema-test.ts | 25 +++ src/type/__tests__/validation-test.ts | 176 +++++++++++++++++- src/type/definition.ts | 31 ++- src/type/introspection.ts | 5 +- src/type/schema.ts | 33 +++- src/type/validate.ts | 22 ++- src/utilities/TypeInfo.ts | 3 +- .../__tests__/buildASTSchema-test.ts | 38 ++++ .../__tests__/buildClientSchema-test.ts | 35 ++++ src/utilities/__tests__/extendSchema-test.ts | 40 ++++ .../__tests__/findBreakingChanges-test.ts | 80 ++++++++ .../__tests__/lexicographicSortSchema-test.ts | 16 +- src/utilities/__tests__/printSchema-test.ts | 59 +++++- src/utilities/buildClientSchema.ts | 4 +- src/utilities/extendSchema.ts | 7 + src/utilities/findBreakingChanges.ts | 10 +- src/utilities/getIntrospectionQuery.ts | 3 + src/utilities/lexicographicSortSchema.ts | 1 + src/utilities/printSchema.ts | 13 +- src/utilities/typeComparators.ts | 5 +- .../__tests__/FieldsOnCorrectTypeRule-test.ts | 21 ++- .../PossibleFragmentSpreadsRule-test.ts | 9 +- src/validation/__tests__/harness.ts | 2 +- 31 files changed, 827 insertions(+), 42 deletions(-) create mode 100644 src/jsutils/mapValues.ts diff --git a/src/__testUtils__/kitchenSinkSDL.ts b/src/__testUtils__/kitchenSinkSDL.ts index cdf2f9afce..f163a6c675 100644 --- a/src/__testUtils__/kitchenSinkSDL.ts +++ b/src/__testUtils__/kitchenSinkSDL.ts @@ -79,6 +79,28 @@ extend union Feed = Photo | Video extend union Feed @onUnion +interface Node { + id: ID +} + +interface Resource { + url: String +} + +extend type Photo implements Node { + id: ID + url: String +} + +extend type Video implements Node { + id: ID + url: String +} + +union Media implements Node = Photo | Video + +extend union Media implements Resource + scalar CustomScalar scalar AnnotatedScalar @onScalar diff --git a/src/execution/__tests__/union-interface-test.ts b/src/execution/__tests__/union-interface-test.ts index 7ce9f8b3bc..b0b4e1066e 100644 --- a/src/execution/__tests__/union-interface-test.ts +++ b/src/execution/__tests__/union-interface-test.ts @@ -110,6 +110,7 @@ const CatType: GraphQLObjectType = new GraphQLObjectType({ const PetType = new GraphQLUnionType({ name: 'Pet', + interfaces: [MammalType, LifeType, NamedType], types: [DogType, CatType], resolveType(value) { if (value instanceof Dog) { @@ -211,8 +212,13 @@ describe('Execute: Union and intersection types', () => { Pet: { kind: 'UNION', name: 'Pet', - fields: null, - interfaces: null, + fields: [ + { name: 'progeny' }, + { name: 'mother' }, + { name: 'father' }, + { name: 'name' }, + ], + interfaces: [{ name: 'Mammal' }, { name: 'Life' }, { name: 'Named' }], possibleTypes: [{ name: 'Dog' }, { name: 'Cat' }], enumValues: null, inputFields: null, @@ -264,12 +270,11 @@ describe('Execute: Union and intersection types', () => { name pets { __typename + name ... on Dog { - name barks } ... on Cat { - name meows } } @@ -436,12 +441,11 @@ describe('Execute: Union and intersection types', () => { fragment PetFields on Pet { __typename + name ... on Dog { - name barks } ... on Cat { - name meows } } diff --git a/src/jsutils/mapValues.ts b/src/jsutils/mapValues.ts new file mode 100644 index 0000000000..a7584c1190 --- /dev/null +++ b/src/jsutils/mapValues.ts @@ -0,0 +1,19 @@ +import type { ObjMap, ReadOnlyObjMap } from './ObjMap'; + +/** + * Creates an object map from an array of `maps` with the same keys as each `map` + * in `maps` and values generated by running each value of `map` thru `fn`. + */ +export function mapValues( + maps: ReadonlyArray>, + fn: (value: T, key: string) => V, +): ObjMap { + const result = Object.create(null); + + for (const map of maps) { + for (const key of Object.keys(map)) { + result[key] = fn(map[key], key); + } + } + return result; +} diff --git a/src/language/__tests__/schema-parser-test.ts b/src/language/__tests__/schema-parser-test.ts index cbb337c337..25e66e66bc 100644 --- a/src/language/__tests__/schema-parser-test.ts +++ b/src/language/__tests__/schema-parser-test.ts @@ -230,6 +230,24 @@ describe('Schema Parser', () => { }); }); + it('Union extension without types', () => { + const doc = parse('extend union HelloOrGoodbye implements Greeting'); + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'UnionTypeExtension', + name: nameNode('HelloOrGoodbye', { start: 13, end: 27 }), + interfaces: [typeNode('Greeting', { start: 39, end: 47 })], + directives: [], + types: [], + loc: { start: 0, end: 47 }, + }, + ], + loc: { start: 0, end: 47 }, + }); + }); + it('Object extension without fields followed by extension', () => { const doc = parse(` extend type Hello implements Greeting @@ -323,6 +341,36 @@ describe('Schema Parser', () => { }); }); + it('Union extension without types followed by extension', () => { + const doc = parse(` + extend union HelloOrGoodbye implements Greeting + + extend union HelloOrGoodbye implements SecondGreeting + `); + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'UnionTypeExtension', + name: nameNode('HelloOrGoodbye', { start: 20, end: 34 }), + interfaces: [typeNode('Greeting', { start: 46, end: 54 })], + directives: [], + types: [], + loc: { start: 7, end: 54 }, + }, + { + kind: 'UnionTypeExtension', + name: nameNode('HelloOrGoodbye', { start: 75, end: 89 }), + interfaces: [typeNode('SecondGreeting', { start: 101, end: 115 })], + directives: [], + types: [], + loc: { start: 62, end: 115 }, + }, + ], + loc: { start: 0, end: 120 }, + }); + }); + it('Object extension do not include descriptions', () => { expectSyntaxError(` "Description" @@ -517,6 +565,26 @@ describe('Schema Parser', () => { }); }); + it('Simple union inheriting interface', () => { + const doc = parse('union Hello implements World = Subtype'); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'UnionTypeDefinition', + name: nameNode('Hello', { start: 6, end: 11 }), + description: undefined, + interfaces: [typeNode('World', { start: 23, end: 28 })], + directives: [], + types: [typeNode('Subtype', { start: 31, end: 38 })], + loc: { start: 0, end: 38 }, + }, + ], + loc: { start: 0, end: 38 }, + }); + }); + it('Simple type inheriting multiple interfaces', () => { const doc = parse('type Hello implements Wo & rld { field: String }'); @@ -574,6 +642,29 @@ describe('Schema Parser', () => { }); }); + it('Simple union inheriting multiple interfaces', () => { + const doc = parse('union Hello implements Wo & rld = Subtype'); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'UnionTypeDefinition', + name: nameNode('Hello', { start: 6, end: 11 }), + description: undefined, + interfaces: [ + typeNode('Wo', { start: 23, end: 25 }), + typeNode('rld', { start: 28, end: 31 }), + ], + directives: [], + types: [typeNode('Subtype', { start: 34, end: 41 })], + loc: { start: 0, end: 41 }, + }, + ], + loc: { start: 0, end: 41 }, + }); + }); + it('Simple type inheriting multiple interfaces with leading ampersand', () => { const doc = parse('type Hello implements & Wo & rld { field: String }'); @@ -633,6 +724,29 @@ describe('Schema Parser', () => { }); }); + it('Simple union inheriting multiple interfaces with leading ampersand', () => { + const doc = parse('union Hello implements & Wo & rld = Subtype'); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'UnionTypeDefinition', + name: nameNode('Hello', { start: 6, end: 11 }), + description: undefined, + interfaces: [ + typeNode('Wo', { start: 25, end: 27 }), + typeNode('rld', { start: 30, end: 33 }), + ], + directives: [], + types: [typeNode('Subtype', { start: 36, end: 43 })], + loc: { start: 0, end: 43 }, + }, + ], + loc: { start: 0, end: 43 }, + }); + }); + it('Single value enum', () => { const doc = parse('enum Hello { WORLD }'); @@ -880,6 +994,7 @@ describe('Schema Parser', () => { kind: 'UnionTypeDefinition', name: nameNode('Hello', { start: 6, end: 11 }), description: undefined, + interfaces: [], directives: [], types: [typeNode('World', { start: 14, end: 19 })], loc: { start: 0, end: 19 }, @@ -899,6 +1014,7 @@ describe('Schema Parser', () => { kind: 'UnionTypeDefinition', name: nameNode('Hello', { start: 6, end: 11 }), description: undefined, + interfaces: [], directives: [], types: [ typeNode('Wo', { start: 14, end: 16 }), @@ -921,6 +1037,7 @@ describe('Schema Parser', () => { kind: 'UnionTypeDefinition', name: nameNode('Hello', { start: 6, end: 11 }), description: undefined, + interfaces: [], directives: [], types: [ typeNode('Wo', { start: 16, end: 18 }), diff --git a/src/language/__tests__/schema-printer-test.ts b/src/language/__tests__/schema-printer-test.ts index 7272b2f2b8..abbd976560 100644 --- a/src/language/__tests__/schema-printer-test.ts +++ b/src/language/__tests__/schema-printer-test.ts @@ -110,6 +110,28 @@ describe('Printer: SDL document', () => { extend union Feed @onUnion + interface Node { + id: ID + } + + interface Resource { + url: String + } + + extend type Photo implements Node { + id: ID + url: String + } + + extend type Video implements Node { + id: ID + url: String + } + + union Media implements Node = Photo | Video + + extend union Media implements Resource + scalar CustomScalar scalar AnnotatedScalar @onScalar diff --git a/src/language/ast.ts b/src/language/ast.ts index 0b30366df0..996ada8632 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -262,7 +262,13 @@ export const QueryDocumentKeys: { 'directives', 'fields', ], - UnionTypeDefinition: ['description', 'name', 'directives', 'types'], + UnionTypeDefinition: [ + 'description', + 'name', + 'interfaces', + 'directives', + 'types', + ], EnumTypeDefinition: ['description', 'name', 'directives', 'values'], EnumValueDefinition: ['description', 'name', 'directives'], InputObjectTypeDefinition: ['description', 'name', 'directives', 'fields'], @@ -274,7 +280,7 @@ export const QueryDocumentKeys: { ScalarTypeExtension: ['name', 'directives'], ObjectTypeExtension: ['name', 'interfaces', 'directives', 'fields'], InterfaceTypeExtension: ['name', 'interfaces', 'directives', 'fields'], - UnionTypeExtension: ['name', 'directives', 'types'], + UnionTypeExtension: ['name', 'interfaces', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], }; @@ -624,6 +630,7 @@ export interface UnionTypeDefinitionNode { readonly loc?: Location; readonly description?: StringValueNode; readonly name: NameNode; + readonly interfaces?: ReadonlyArray; readonly directives?: ReadonlyArray; readonly types?: ReadonlyArray; } @@ -716,6 +723,7 @@ export interface UnionTypeExtensionNode { readonly kind: Kind.UNION_TYPE_EXTENSION; readonly loc?: Location; readonly name: NameNode; + readonly interfaces?: ReadonlyArray; readonly directives?: ReadonlyArray; readonly types?: ReadonlyArray; } diff --git a/src/language/parser.ts b/src/language/parser.ts index 282ee16859..dcf60dc019 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -970,12 +970,14 @@ export class Parser { const description = this.parseDescription(); this.expectKeyword('union'); const name = this.parseName(); + const interfaces = this.parseImplementsInterfaces(); const directives = this.parseConstDirectives(); const types = this.parseUnionMemberTypes(); return this.node(start, { kind: Kind.UNION_TYPE_DEFINITION, description, name, + interfaces, directives, types, }); @@ -1249,14 +1251,20 @@ export class Parser { this.expectKeyword('extend'); this.expectKeyword('union'); const name = this.parseName(); + const interfaces = this.parseImplementsInterfaces(); const directives = this.parseConstDirectives(); const types = this.parseUnionMemberTypes(); - if (directives.length === 0 && types.length === 0) { + if ( + interfaces.length === 0 && + directives.length === 0 && + types.length === 0 + ) { throw this.unexpected(); } return this.node(start, { kind: Kind.UNION_TYPE_EXTENSION, name, + interfaces, directives, types, }); diff --git a/src/language/printer.ts b/src/language/printer.ts index 38cb25444b..90dfab6e29 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -202,10 +202,16 @@ const printDocASTReducer: ASTReducer = { }, UnionTypeDefinition: { - leave: ({ description, name, directives, types }) => + leave: ({ description, name, interfaces, directives, types }) => wrap('', description, '\n') + join( - ['union', name, join(directives, ' '), wrap('= ', join(types, ' | '))], + [ + 'union', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + wrap('= ', join(types, ' | ')), + ], ' ', ), }, @@ -282,11 +288,12 @@ const printDocASTReducer: ASTReducer = { }, UnionTypeExtension: { - leave: ({ name, directives, types }) => + leave: ({ name, interfaces, directives, types }) => join( [ 'extend union', name, + wrap('implements ', join(interfaces, ' & ')), join(directives, ' '), wrap('= ', join(types, ' | ')), ], diff --git a/src/type/__tests__/schema-test.ts b/src/type/__tests__/schema-test.ts index 8a31b50ada..823db386ef 100644 --- a/src/type/__tests__/schema-test.ts +++ b/src/type/__tests__/schema-test.ts @@ -13,6 +13,7 @@ import { GraphQLList, GraphQLObjectType, GraphQLScalarType, + GraphQLUnionType, } from '../definition'; import { GraphQLDirective } from '../directives'; import { GraphQLBoolean, GraphQLInt, GraphQLString } from '../scalars'; @@ -212,6 +213,30 @@ describe('Type System: Schema', () => { expect(schema.getType('SomeSubtype')).to.equal(SomeSubtype); }); + it("includes unions's thunk subtypes in the type map", () => { + const SomeUnion = new GraphQLUnionType({ + name: 'SomeUnion', + types: () => [SomeSubtype], + interfaces: () => [SomeInterface], + }); + + const SomeInterface = new GraphQLInterfaceType({ + name: 'SomeInterface', + fields: {}, + }); + + const SomeSubtype = new GraphQLObjectType({ + name: 'SomeSubtype', + fields: {}, + }); + + const schema = new GraphQLSchema({ types: [SomeUnion] }); + + expect(schema.getType('SomeUnion')).to.equal(SomeUnion); + expect(schema.getType('SomeInterface')).to.equal(SomeInterface); + expect(schema.getType('SomeSubtype')).to.equal(SomeSubtype); + }); + it('includes nested input objects in the map', () => { const NestedInputObject = new GraphQLInputObjectType({ name: 'NestedInputObject', diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index af82d30fbb..c75998d75f 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -1828,7 +1828,33 @@ describe('Objects must adhere to Interface they implement', () => { ]); }); - it('rejects an Object with an incorrectly typed Interface field', () => { + it('rejects an Object with an incorrectly typed Interface field (scalar not matching scalar)', () => { + const schema = buildSchema(` + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field(input: String): String + } + + type AnotherObject implements AnotherInterface { + field(input: String): Int + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Interface field AnotherInterface.field expects type String but AnotherObject.field is type Int.', + locations: [ + { line: 7, column: 31 }, + { line: 11, column: 31 }, + ], + }, + ]); + }); + + it('rejects an Object with an incorrectly typed Interface field (object not matching interface)', () => { const schema = buildSchema(` type Query { test: AnotherObject @@ -1883,7 +1909,7 @@ describe('Objects must adhere to Interface they implement', () => { ]); }); - it('accepts an Object with a subtyped Interface field (interface)', () => { + it('accepts an Object with a subtyped Interface field (interface with object subtype)', () => { const schema = buildSchema(` type Query { test: AnotherObject @@ -1900,7 +1926,26 @@ describe('Objects must adhere to Interface they implement', () => { expectJSON(validateSchema(schema)).toDeepEqual([]); }); - it('accepts an Object with a subtyped Interface field (union)', () => { + it('accepts an Object with a subtyped Interface field (interface with union subtype)', () => { + const schema = buildSchema(` + type Query { + test: AnotherObject + } + + interface AnotherInterface { + field: AnotherInterface + } + + union AnotherUnion implements AnotherInterface = AnotherObject + + type AnotherObject implements AnotherInterface { + field: AnotherUnion + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts an Object with a subtyped Interface field (union with object subtype)', () => { const schema = buildSchema(` type Query { test: AnotherObject @@ -2317,7 +2362,7 @@ describe('Interfaces must adhere to Interface they implement', () => { ]); }); - it('accepts an Interface with a subtyped Interface field (interface)', () => { + it('accepts an Interface with a subtyped Interface field (interface with interface subtype)', () => { const schema = buildSchema(` type Query { test: ChildInterface @@ -2334,7 +2379,30 @@ describe('Interfaces must adhere to Interface they implement', () => { expectJSON(validateSchema(schema)).toDeepEqual([]); }); - it('accepts an Interface with a subtyped Interface field (union)', () => { + it('accepts an Interface with a subtyped Interface field (interface with union subtype)', () => { + const schema = buildSchema(` + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: ParentInterface + } + + union AnotherUnion implements ParentInterface = AnotherObject + + type AnotherObject implements ParentInterface { + field: ParentInterface + } + + interface ChildInterface implements ParentInterface { + field: AnotherUnion + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts an Interface with a subtyped Interface field (union with object subtype)', () => { const schema = buildSchema(` type Query { test: ChildInterface @@ -2695,6 +2763,104 @@ describe('Interfaces must adhere to Interface they implement', () => { }); }); +describe('Unions must adhere to Interface they implement', () => { + it('accepts a Union which implements an Interface', () => { + const schema = buildSchema(` + type Query { + test: SomeUnion + } + + interface SomeInterface { + field(input: String): String + } + + type SomeType implements SomeInterface { + field(input: String): String + } + + union SomeUnion implements SomeInterface = SomeType + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts a Union which implements multiple Interfaces', () => { + const schema = buildSchema(` + type Query { + test: SomeUnion + } + + interface SomeInterface { + field(input: String): String + } + + interface AnotherInterface { + field(input: String): String + } + + type SomeType implements SomeInterface & AnotherInterface { + field(input: String): String + } + + union SomeUnion implements SomeInterface & AnotherInterface = SomeType + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('rejects a Union with an interface if a member type does not explicitly implement the interface', () => { + const schema = buildSchema(` + type Query { + test: SomeUnion + } + + interface SomeInterface { + field(input: String): String + } + + type SomeType { + field(input: String): String + } + + union SomeUnion implements SomeInterface = SomeType + `); + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Union type SomeUnion cannot implement interface SomeInterface, member type SomeType does not implement SomeInterface.', + locations: [{ line: 14, column: 34 }], + }, + ]); + }); + + it('rejects a Union with multiple interface if a member type does not explicitly implement one of the interfaces', () => { + const schema = buildSchema(` + type Query { + test: SomeUnion + } + + interface SomeInterface { + field(input: String): String + } + + interface AnotherInterface { + field(input: String): String + } + + type SomeType implements SomeInterface { + field(input: String): String + } + + union SomeUnion implements SomeInterface & AnotherInterface = SomeType + `); + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Union type SomeUnion cannot implement interface AnotherInterface, member type SomeType does not implement AnotherInterface.', + locations: [{ line: 18, column: 50 }], + }, + ]); + }); +}); + describe('assertValidSchema', () => { it('does not throw on valid schemas', () => { const schema = buildSchema(` diff --git a/src/type/definition.ts b/src/type/definition.ts index 9eea02e8ea..f79dd0248e 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -7,6 +7,7 @@ import { isObjectLike } from '../jsutils/isObjectLike'; import { keyMap } from '../jsutils/keyMap'; import { keyValMap } from '../jsutils/keyValMap'; import { mapValue } from '../jsutils/mapValue'; +import { mapValues } from '../jsutils/mapValues'; import type { Maybe } from '../jsutils/Maybe'; import type { ObjMap } from '../jsutils/ObjMap'; import type { Path } from '../jsutils/Path'; @@ -794,7 +795,9 @@ export class GraphQLObjectType { function defineInterfaces( config: Readonly< - GraphQLObjectTypeConfig | GraphQLInterfaceTypeConfig + | GraphQLObjectTypeConfig + | GraphQLInterfaceTypeConfig + | GraphQLUnionTypeConfig >, ): ReadonlyArray { const interfaces = resolveReadonlyArrayThunk(config.interfaces ?? []); @@ -1214,7 +1217,9 @@ export class GraphQLUnionType { astNode: Maybe; extensionASTNodes: ReadonlyArray; + private _fields: GraphQLFieldMap | undefined; private _types: ThunkReadonlyArray; + private _interfaces: ThunkReadonlyArray; constructor(config: Readonly>) { this.name = assertName(config.name); @@ -1225,6 +1230,7 @@ export class GraphQLUnionType { this.extensionASTNodes = config.extensionASTNodes ?? []; this._types = defineTypes.bind(undefined, config); + this._interfaces = defineInterfaces.bind(undefined, config); devAssert( config.resolveType == null || typeof config.resolveType === 'function', `${this.name} must provide "resolveType" as a function, ` + @@ -1236,6 +1242,19 @@ export class GraphQLUnionType { return 'GraphQLUnionType'; } + getFields(): GraphQLFieldMap { + if (this._fields !== undefined) { + return this._fields; + } + + this._fields = mapValues( + this.getInterfaces().map((iface) => iface.getFields()), + identityFunc, + ); + + return this._fields; + } + getTypes(): ReadonlyArray { if (typeof this._types === 'function') { this._types = this._types(); @@ -1243,10 +1262,18 @@ export class GraphQLUnionType { return this._types; } + getInterfaces(): ReadonlyArray { + if (typeof this._interfaces === 'function') { + this._interfaces = this._interfaces(); + } + return this._interfaces; + } + toConfig(): GraphQLUnionTypeNormalizedConfig { return { name: this.name, description: this.description, + interfaces: this.getInterfaces(), types: this.getTypes(), resolveType: this.resolveType, extensions: this.extensions, @@ -1278,6 +1305,7 @@ function defineTypes( export interface GraphQLUnionTypeConfig { name: string; description?: Maybe; + interfaces?: ThunkReadonlyArray; types: ThunkReadonlyArray; /** * Optionally provide a custom type resolver function. If one is not provided, @@ -1292,6 +1320,7 @@ export interface GraphQLUnionTypeConfig { interface GraphQLUnionTypeNormalizedConfig extends GraphQLUnionTypeConfig { + interfaces: ReadonlyArray; types: ReadonlyArray; extensions: Readonly; extensionASTNodes: ReadonlyArray; diff --git a/src/type/introspection.ts b/src/type/introspection.ts index e5fce6f241..265a2544cb 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -20,6 +20,7 @@ import { GraphQLNonNull, GraphQLObjectType, isAbstractType, + isCompositeType, isEnumType, isInputObjectType, isInterfaceType, @@ -264,7 +265,7 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ includeDeprecated: { type: GraphQLBoolean, defaultValue: false }, }, resolve(type, { includeDeprecated }) { - if (isObjectType(type) || isInterfaceType(type)) { + if (isCompositeType(type)) { const fields = Object.values(type.getFields()); return includeDeprecated ? fields @@ -275,7 +276,7 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ interfaces: { type: new GraphQLList(new GraphQLNonNull(__Type)), resolve(type) { - if (isObjectType(type) || isInterfaceType(type)) { + if (isCompositeType(type)) { return type.getInterfaces(); } }, diff --git a/src/type/schema.ts b/src/type/schema.ts index 6b44e1fc1a..a10d9e61f5 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -16,10 +16,12 @@ import { OperationTypeNode } from '../language/ast'; import type { GraphQLAbstractType, + GraphQLCompositeType, GraphQLInterfaceType, GraphQLNamedType, GraphQLObjectType, GraphQLType, + GraphQLUnionType, } from './definition'; import { getNamedType, @@ -145,6 +147,7 @@ export class GraphQLSchema { private _implementationsMap: ObjMap<{ objects: Array; interfaces: Array; + unions: Array; }>; constructor(config: Readonly) { @@ -239,6 +242,7 @@ export class GraphQLSchema { implementations = this._implementationsMap[iface.name] = { objects: [], interfaces: [], + unions: [], }; } @@ -254,12 +258,29 @@ export class GraphQLSchema { implementations = this._implementationsMap[iface.name] = { objects: [], interfaces: [], + unions: [], }; } implementations.objects.push(namedType); } } + } else if (isUnionType(namedType)) { + // Store implementations by unions. + for (const iface of namedType.getInterfaces()) { + if (isInterfaceType(iface)) { + let implementations = this._implementationsMap[iface.name]; + if (implementations === undefined) { + implementations = this._implementationsMap[iface.name] = { + objects: [], + interfaces: [], + unions: [], + }; + } + + implementations.unions.push(namedType); + } + } } } } @@ -310,14 +331,15 @@ export class GraphQLSchema { getImplementations(interfaceType: GraphQLInterfaceType): { objects: ReadonlyArray; interfaces: ReadonlyArray; + unions: ReadonlyArray; } { const implementations = this._implementationsMap[interfaceType.name]; - return implementations ?? { objects: [], interfaces: [] }; + return implementations ?? { objects: [], interfaces: [], unions: [] }; } isSubType( abstractType: GraphQLAbstractType, - maybeSubType: GraphQLObjectType | GraphQLInterfaceType, + maybeSubType: GraphQLCompositeType, ): boolean { let map = this._subTypeMap[abstractType.name]; if (map === undefined) { @@ -335,6 +357,9 @@ export class GraphQLSchema { for (const type of implementations.interfaces) { map[type.name] = true; } + for (const type of implementations.unions) { + map[type.name] = true; + } } this._subTypeMap[abstractType.name] = map; @@ -412,6 +437,10 @@ function collectReferencedTypes( if (!typeSet.has(namedType)) { typeSet.add(namedType); if (isUnionType(namedType)) { + for (const interfaceType of namedType.getInterfaces()) { + collectReferencedTypes(interfaceType, typeSet); + } + for (const memberType of namedType.getTypes()) { collectReferencedTypes(memberType, typeSet); } diff --git a/src/type/validate.ts b/src/type/validate.ts index c6385d27fb..e1cc84952e 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -22,6 +22,7 @@ import { OperationTypeNode } from '../language/ast'; import { isEqualType, isTypeSubTypeOf } from '../utilities/typeComparators'; import type { + GraphQLCompositeType, GraphQLEnumType, GraphQLInputField, GraphQLInputObjectType, @@ -470,6 +471,7 @@ function validateUnionMembers( ); } + const unionInterfaces = union.getInterfaces(); const includedTypeNames = Object.create(null); for (const memberType of memberTypes) { if (includedTypeNames[memberType.name]) { @@ -480,6 +482,7 @@ function validateUnionMembers( continue; } includedTypeNames[memberType.name] = true; + if (!isObjectType(memberType)) { context.reportError( `Union type ${union.name} can only include Object types, ` + @@ -487,6 +490,21 @@ function validateUnionMembers( getUnionMemberTypeNodes(union, String(memberType)), ); } + + for (const unionInterface of unionInterfaces) { + const unionInterfaceName = unionInterface.name; + if ( + !memberType + .getInterfaces() + .some((iface) => iface.name === unionInterfaceName) + ) { + context.reportError( + `Union type ${union.name} cannot implement interface ${unionInterfaceName}, ` + + `member type ${memberType.name} does not implement ${unionInterfaceName}.`, + getAllImplementsInterfaceNodes(union, unionInterface), + ); + } + } } } @@ -598,7 +616,7 @@ function createInputObjectCircularRefsValidator( } function getAllImplementsInterfaceNodes( - type: GraphQLObjectType | GraphQLInterfaceType, + type: GraphQLCompositeType, iface: GraphQLInterfaceType, ): ReadonlyArray { const { astNode, extensionASTNodes } = type; @@ -607,6 +625,8 @@ function getAllImplementsInterfaceNodes( | ObjectTypeExtensionNode | InterfaceTypeDefinitionNode | InterfaceTypeExtensionNode + | UnionTypeDefinitionNode + | UnionTypeExtensionNode > = astNode != null ? [astNode, ...extensionASTNodes] : extensionASTNodes; // FIXME: https://github.com/graphql/graphql-js/issues/2203 diff --git a/src/utilities/TypeInfo.ts b/src/utilities/TypeInfo.ts index e72dfb01fb..6723b0378d 100644 --- a/src/utilities/TypeInfo.ts +++ b/src/utilities/TypeInfo.ts @@ -23,7 +23,6 @@ import { isEnumType, isInputObjectType, isInputType, - isInterfaceType, isListType, isObjectType, isOutputType, @@ -320,7 +319,7 @@ function getFieldDef( if (name === TypeNameMetaFieldDef.name && isCompositeType(parentType)) { return TypeNameMetaFieldDef; } - if (isObjectType(parentType) || isInterfaceType(parentType)) { + if (isCompositeType(parentType)) { return parentType.getFields()[name]; } } diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 32658c13d6..f664a5c5ec 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -472,6 +472,25 @@ describe('Schema Builder', () => { expect(errors).to.have.lengthOf.above(0); }); + it('Union implementing Interface', () => { + const sdl = dedent` + union Hello implements Parent = World + + type Query { + hello: Hello + } + + interface Parent { + str: String + } + + type World implements Parent { + str: String + } + `; + expect(cycleSDL(sdl)).to.equal(sdl); + }); + it('Custom Scalar', () => { const sdl = dedent` scalar CustomScalar @@ -597,6 +616,25 @@ describe('Schema Builder', () => { expect(cycleSDL(sdl)).to.equal(sdl); }); + it('Unreferenced union implementing referenced interface', () => { + const sdl = dedent` + union Union implements Interface = Concrete + + type Concrete implements Interface { + key: String + } + + interface Interface { + key: String + } + + type Query { + interface: Interface + } + `; + expect(cycleSDL(sdl)).to.equal(sdl); + }); + it('Unreferenced type implementing referenced union', () => { const sdl = dedent` type Concrete { diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index 8c043f0e77..5bc00c91d2 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -242,6 +242,41 @@ describe('Type System: build schema from introspection', () => { expect(cycleIntrospection(sdl)).to.equal(sdl); }); + it('builds a schema with unions implementing interfaces', () => { + const sdl = dedent` + union HousePet implements Pet & Named = Dog | Cat + + type Dog implements Pet & Named { + name: String + owner: Human + } + + type Cat implements Pet & Named { + name: String + owner: Human + } + + interface Pet implements Named { + name: String + owner: Human + } + + type Human implements Named { + name: String + } + + interface Named { + name: String + } + + type Query { + housePet: HousePet + } + `; + + expect(cycleIntrospection(sdl)).to.equal(sdl); + }); + it('builds a schema with an implicit interface', () => { const sdl = dedent` type Dog implements Friendly { diff --git a/src/utilities/__tests__/extendSchema-test.ts b/src/utilities/__tests__/extendSchema-test.ts index 2f4f80e6bc..e8234a818c 100644 --- a/src/utilities/__tests__/extendSchema-test.ts +++ b/src/utilities/__tests__/extendSchema-test.ts @@ -223,6 +223,46 @@ describe('extendSchema', () => { `); }); + it('extends unions by adding new interfaces', () => { + const schema = buildSchema(` + type Query { + someUnion: SomeUnion + } + + union SomeUnion implements SomeInterface = Foo | Biz | Bar + + interface SomeInterface { someField: String } + interface AnotherInterface { anotherField: String } + + type Foo implements SomeInterface & AnotherInterface { + someField: String + anotherField: String + foo: String + } + + type Biz implements SomeInterface & AnotherInterface { + someField: String + anotherField: String + biz: String + } + + type Bar implements SomeInterface & AnotherInterface { + someField: String + anotherField: String + bar: String + } + `); + const extendAST = parse(` + extend union SomeUnion implements AnotherInterface + `); + const extendedSchema = extendSchema(schema, extendAST); + + expect(validateSchema(extendedSchema)).to.deep.equal([]); + expectSchemaChanges(schema, extendedSchema).to.equal(dedent` + union SomeUnion implements SomeInterface & AnotherInterface = Foo | Biz | Bar + `); + }); + it('allows extension of union by adding itself', () => { const schema = buildSchema(` union SomeUnion diff --git a/src/utilities/__tests__/findBreakingChanges-test.ts b/src/utilities/__tests__/findBreakingChanges-test.ts index 6c26e24ad0..c3d18bd62b 100644 --- a/src/utilities/__tests__/findBreakingChanges-test.ts +++ b/src/utilities/__tests__/findBreakingChanges-test.ts @@ -618,6 +618,31 @@ describe('findBreakingChanges', () => { ]); }); + it('should detect interfaces removed from unions', () => { + const oldSchema = buildSchema(` + interface Interface + + type Type implements Interface + + union Union implements Interface = Type + `); + + const newSchema = buildSchema(` + interface Interface + + type Type implements Interface + + union Union = Type + `); + + expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ + { + type: BreakingChangeType.IMPLEMENTED_INTERFACE_REMOVED, + description: 'Union no longer implements interface Interface.', + }, + ]); + }); + it('should ignore changes in order of interfaces', () => { const oldSchema = buildSchema(` interface FirstInterface @@ -1101,6 +1126,61 @@ describe('findDangerousChanges', () => { ]); }); + it('should detect interfaces added to unions', () => { + const oldSchema = buildSchema(` + interface OldInterface + interface NewInterface + + type Type implements OldInterface & NewInterface + + union Union implements OldInterface = Type + `); + + const newSchema = buildSchema(` + interface OldInterface + interface NewInterface + + type Type implements OldInterface & NewInterface + + union Union implements OldInterface & NewInterface = Type + `); + + expect(findDangerousChanges(oldSchema, newSchema)).to.deep.equal([ + { + type: DangerousChangeType.IMPLEMENTED_INTERFACE_ADDED, + description: 'NewInterface added to interfaces implemented by Union.', + }, + ]); + }); + + it('should detect interfaces added to unions', () => { + const oldSchema = buildSchema(` + type Type + + interface OldInterface + interface NewInterface + + union UnionType implements OldInterface = Type + `); + + const newSchema = buildSchema(` + type Type + + interface OldInterface + interface NewInterface + + union UnionType implements OldInterface & NewInterface = Type + `); + + expect(findDangerousChanges(oldSchema, newSchema)).to.deep.equal([ + { + type: DangerousChangeType.IMPLEMENTED_INTERFACE_ADDED, + description: + 'NewInterface added to interfaces implemented by UnionType.', + }, + ]); + }); + it('should detect if a type was added to a union type', () => { const oldSchema = buildSchema(` type Type1 diff --git a/src/utilities/__tests__/lexicographicSortSchema-test.ts b/src/utilities/__tests__/lexicographicSortSchema-test.ts index bce12e3ac5..2cb928a66a 100644 --- a/src/utilities/__tests__/lexicographicSortSchema-test.ts +++ b/src/utilities/__tests__/lexicographicSortSchema-test.ts @@ -77,9 +77,15 @@ describe('lexicographicSortSchema', () => { dummy: String } - type Query implements FooB & FooA & FooC { + type Query { + field: Type + } + + type Type implements FooB & FooA & FooC { dummy: String } + + union Union implements FooB & FooA & FooC = Type `); expect(sorted).to.equal(dedent` @@ -95,9 +101,15 @@ describe('lexicographicSortSchema', () => { dummy: String } - type Query implements FooA & FooB & FooC { + type Query { + field: Type + } + + type Type implements FooA & FooB & FooC { dummy: String } + + union Union implements FooA & FooB & FooC = Type `); }); diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts index d09153a2e6..326fdb7476 100644 --- a/src/utilities/__tests__/printSchema-test.ts +++ b/src/utilities/__tests__/printSchema-test.ts @@ -17,7 +17,12 @@ import { GraphQLUnionType, } from '../../type/definition'; import { GraphQLDirective } from '../../type/directives'; -import { GraphQLBoolean, GraphQLInt, GraphQLString } from '../../type/scalars'; +import { + GraphQLBoolean, + GraphQLID, + GraphQLInt, + GraphQLString, +} from '../../type/scalars'; import { GraphQLSchema } from '../../type/schema'; import { buildSchema } from '../buildASTSchema'; @@ -487,6 +492,58 @@ describe('Type System Printer', () => { `); }); + it('Print Unions implementing Interfaces', () => { + const FooType = new GraphQLObjectType({ + name: 'Foo', + interfaces: () => [Interface], + fields: { + id: { type: GraphQLID }, + bool: { type: GraphQLBoolean }, + }, + }); + + const BarType = new GraphQLObjectType({ + name: 'Bar', + interfaces: () => [Interface], + fields: { + id: { type: GraphQLID }, + str: { type: GraphQLString }, + }, + }); + + const Union = new GraphQLUnionType({ + name: 'Union', + interfaces: () => [Interface], + types: [FooType, BarType], + }); + + const Interface = new GraphQLInterfaceType({ + name: 'Interface', + fields: { + id: { type: GraphQLID }, + }, + }); + + const schema = new GraphQLSchema({ types: [Union] }); + expectPrintedSchema(schema).to.equal(dedent` + union Union implements Interface = Foo | Bar + + interface Interface { + id: ID + } + + type Foo implements Interface { + id: ID + bool: Boolean + } + + type Bar implements Interface { + id: ID + str: String + } + `); + }); + it('Print Input Type', () => { const InputType = new GraphQLInputObjectType({ name: 'InputType', diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 18e6110b2b..31cf7b3b52 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -209,7 +209,8 @@ export function buildClientSchema( function buildImplementationsList( implementingIntrospection: | IntrospectionObjectType - | IntrospectionInterfaceType, + | IntrospectionInterfaceType + | IntrospectionUnionType, ): Array { // TODO: Temporary workaround until GraphQL ecosystem will fully support // 'interfaces' on interface types. @@ -264,6 +265,7 @@ export function buildClientSchema( return new GraphQLUnionType({ name: unionIntrospection.name, description: unionIntrospection.description, + interfaces: () => buildImplementationsList(unionIntrospection), types: () => unionIntrospection.possibleTypes.map(getObjectType), }); } diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 998847e9f1..f7933e1c4f 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -364,6 +364,10 @@ export function extendSchemaImpl( return new GraphQLUnionType({ ...config, + interfaces: () => [ + ...type.getInterfaces().map(replaceNamedType), + ...buildInterfaces(extensions), + ], types: () => [ ...type.getTypes().map(replaceNamedType), ...buildUnionTypes(extensions), @@ -552,6 +556,8 @@ export function extendSchemaImpl( | InterfaceTypeExtensionNode | ObjectTypeDefinitionNode | ObjectTypeExtensionNode + | UnionTypeDefinitionNode + | UnionTypeExtensionNode >, ): Array { // Note: While this could make assertions to get the correctly typed @@ -623,6 +629,7 @@ export function extendSchemaImpl( return new GraphQLUnionType({ name, description: astNode.description?.value, + interfaces: () => buildInterfaces(allNodes), types: () => buildUnionTypes(allNodes), astNode, extensionASTNodes, diff --git a/src/utilities/findBreakingChanges.ts b/src/utilities/findBreakingChanges.ts index 0bf0d453b4..99af83932e 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -5,6 +5,7 @@ import { keyMap } from '../jsutils/keyMap'; import { print } from '../language/printer'; import type { + GraphQLCompositeType, GraphQLEnumType, GraphQLField, GraphQLInputObjectType, @@ -191,7 +192,10 @@ function findTypeChanges( if (isEnumType(oldType) && isEnumType(newType)) { schemaChanges.push(...findEnumTypeChanges(oldType, newType)); } else if (isUnionType(oldType) && isUnionType(newType)) { - schemaChanges.push(...findUnionTypeChanges(oldType, newType)); + schemaChanges.push( + ...findUnionTypeChanges(oldType, newType), + ...findImplementedInterfacesChanges(oldType, newType), + ); } else if (isInputObjectType(oldType) && isInputObjectType(newType)) { schemaChanges.push(...findInputObjectTypeChanges(oldType, newType)); } else if (isObjectType(oldType) && isObjectType(newType)) { @@ -315,8 +319,8 @@ function findEnumTypeChanges( } function findImplementedInterfacesChanges( - oldType: GraphQLObjectType | GraphQLInterfaceType, - newType: GraphQLObjectType | GraphQLInterfaceType, + oldType: GraphQLCompositeType, + newType: GraphQLCompositeType, ): Array { const schemaChanges = []; const interfacesDiff = diff(oldType.getInterfaces(), newType.getInterfaces()); diff --git a/src/utilities/getIntrospectionQuery.ts b/src/utilities/getIntrospectionQuery.ts index c21fe9a1bb..72d204e5ca 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -234,6 +234,9 @@ export interface IntrospectionUnionType { readonly kind: 'UNION'; readonly name: string; readonly description?: Maybe; + readonly interfaces: ReadonlyArray< + IntrospectionNamedTypeRef + >; readonly possibleTypes: ReadonlyArray< IntrospectionNamedTypeRef >; diff --git a/src/utilities/lexicographicSortSchema.ts b/src/utilities/lexicographicSortSchema.ts index 26b6908c9f..2da28be1f9 100644 --- a/src/utilities/lexicographicSortSchema.ts +++ b/src/utilities/lexicographicSortSchema.ts @@ -138,6 +138,7 @@ export function lexicographicSortSchema(schema: GraphQLSchema): GraphQLSchema { const config = type.toConfig(); return new GraphQLUnionType({ ...config, + interfaces: () => sortTypes(config.interfaces), types: () => sortTypes(config.types), }); } diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index 83859feee8..d3dee006aa 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -8,6 +8,7 @@ import { print } from '../language/printer'; import type { GraphQLArgument, + GraphQLCompositeType, GraphQLEnumType, GraphQLInputField, GraphQLInputObjectType, @@ -158,9 +159,7 @@ function printScalar(type: GraphQLScalarType): string { ); } -function printImplementedInterfaces( - type: GraphQLObjectType | GraphQLInterfaceType, -): string { +function printImplementedInterfaces(type: GraphQLCompositeType): string { const interfaces = type.getInterfaces(); return interfaces.length ? ' implements ' + interfaces.map((i) => i.name).join(' & ') @@ -188,7 +187,13 @@ function printInterface(type: GraphQLInterfaceType): string { function printUnion(type: GraphQLUnionType): string { const types = type.getTypes(); const possibleTypes = types.length ? ' = ' + types.join(' | ') : ''; - return printDescription(type) + 'union ' + type.name + possibleTypes; + return ( + printDescription(type) + + 'union ' + + type.name + + printImplementedInterfaces(type) + + possibleTypes + ); } function printEnum(type: GraphQLEnumType): string { diff --git a/src/utilities/typeComparators.ts b/src/utilities/typeComparators.ts index 287be40bfe..025dd210cc 100644 --- a/src/utilities/typeComparators.ts +++ b/src/utilities/typeComparators.ts @@ -1,10 +1,9 @@ import type { GraphQLCompositeType, GraphQLType } from '../type/definition'; import { isAbstractType, - isInterfaceType, + isCompositeType, isListType, isNonNullType, - isObjectType, } from '../type/definition'; import type { GraphQLSchema } from '../type/schema'; @@ -73,7 +72,7 @@ export function isTypeSubTypeOf( // Otherwise, the child type is not a valid subtype of the parent type. return ( isAbstractType(superType) && - (isInterfaceType(maybeSubType) || isObjectType(maybeSubType)) && + isCompositeType(maybeSubType) && schema.isSubType(superType, maybeSubType) ); } diff --git a/src/validation/__tests__/FieldsOnCorrectTypeRule-test.ts b/src/validation/__tests__/FieldsOnCorrectTypeRule-test.ts index 70473fa685..3604b4e58e 100644 --- a/src/validation/__tests__/FieldsOnCorrectTypeRule-test.ts +++ b/src/validation/__tests__/FieldsOnCorrectTypeRule-test.ts @@ -43,11 +43,21 @@ const testSchema = buildSchema(` union CatOrDog = Cat | Dog - type Human { + interface Named { + name: String + } + + type Human implements Named { name: String pets: [Pet] } + type Alien implements Named { + name: String + } + + union NamedHumanOrAlien implements Named = Human | Alien + type Query { human: Human } @@ -81,6 +91,15 @@ describe('Validate: Fields on correct type', () => { `); }); + it('Union implementing interface field selection', () => { + expectValid(` + fragment unionImplementingInterfaceFieldSelection on HumanOrAlien { + __typename + name + } + `); + }); + it('Aliased interface field selection', () => { expectValid(` fragment interfaceFieldSelection on Pet { diff --git a/src/validation/__tests__/PossibleFragmentSpreadsRule-test.ts b/src/validation/__tests__/PossibleFragmentSpreadsRule-test.ts index 3e52f234b5..b1e38e75b2 100644 --- a/src/validation/__tests__/PossibleFragmentSpreadsRule-test.ts +++ b/src/validation/__tests__/PossibleFragmentSpreadsRule-test.ts @@ -37,7 +37,7 @@ const testSchema = buildSchema(` meowVolume: Int } - union CatOrDog = Cat | Dog + union CatOrDog implements Being & Pet = Cat | Dog interface Intelligent { iq: Int @@ -121,6 +121,13 @@ describe('Validate: Possible fragment spreads', () => { `); }); + it('interface into implemented union', () => { + expectValid(` + fragment interfaceWithinUnion on DogOrCat { ...petFragment } + fragment petFragment on Pet { name } + `); + }); + it('interface into overlapping interface', () => { expectValid(` fragment interfaceWithinInterface on Pet { ...beingFragment } diff --git a/src/validation/__tests__/harness.ts b/src/validation/__tests__/harness.ts index 661256c56d..503ea1583f 100644 --- a/src/validation/__tests__/harness.ts +++ b/src/validation/__tests__/harness.ts @@ -53,7 +53,7 @@ export const testSchema: GraphQLSchema = buildSchema(` furColor: FurColor } - union CatOrDog = Cat | Dog + union CatOrDog implements Pet = Cat | Dog type Human { name(surname: Boolean): String