From da58022c5f9db491028fc97362877ffd75fd5e45 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Fri, 29 Apr 2022 15:36:35 +0300 Subject: [PATCH] introduce new Intersection type Intersections of unions and interfaces can be considered to "implement" their unions and interface members. A type with a field of type Intersection will satisfy an interface where the field defined in the interface is one of the member types of the intersection. Alternative to #3527 --- docs-old/APIReference-GraphQL.md | 6 + docs-old/APIReference-TypeSystem.md | 6 + src/__testUtils__/kitchenSinkSDL.ts | 12 + src/execution/__tests__/abstract-test.ts | 161 ++++ .../__tests__/union-interface-test.ts | 237 +++++- src/index.ts | 5 + src/language/__tests__/predicates-test.ts | 6 + src/language/__tests__/schema-parser-test.ts | 96 +++ src/language/__tests__/schema-printer-test.ts | 12 + src/language/ast.ts | 23 + src/language/directiveLocation.ts | 1 + src/language/kinds.ts | 2 + src/language/parser.ts | 62 ++ src/language/predicates.ts | 2 + src/language/printer.ts | 27 + src/type/__tests__/definition-test.ts | 80 ++ src/type/__tests__/introspection-test.ts | 40 + src/type/__tests__/predicate-test.ts | 28 + src/type/__tests__/validation-test.ts | 725 +++++++++++++++++- src/type/definition.ts | 176 ++++- src/type/index.ts | 5 + src/type/introspection.ts | 24 +- src/type/schema.ts | 255 +++++- src/type/validate.ts | 61 ++ .../__tests__/buildASTSchema-test.ts | 97 +++ .../__tests__/buildClientSchema-test.ts | 45 ++ src/utilities/__tests__/extendSchema-test.ts | 82 +- .../__tests__/findBreakingChanges-test.ts | 97 +++ .../__tests__/lexicographicSortSchema-test.ts | 54 ++ src/utilities/__tests__/printSchema-test.ts | 69 +- src/utilities/buildClientSchema.ts | 32 + src/utilities/extendSchema.ts | 50 ++ src/utilities/findBreakingChanges.ts | 33 + src/utilities/getIntrospectionQuery.ts | 24 + src/utilities/lexicographicSortSchema.ts | 9 + src/utilities/printSchema.ts | 11 + src/utilities/typeComparators.ts | 5 +- .../__tests__/KnownDirectivesRule-test.ts | 28 +- .../PossibleFragmentSpreadsRule-test.ts | 133 ++++ .../PossibleTypeExtensionsRule-test.ts | 69 +- src/validation/__tests__/harness.ts | 2 + src/validation/rules/KnownDirectivesRule.ts | 3 + .../rules/PossibleTypeExtensionsRule.ts | 8 + 43 files changed, 2847 insertions(+), 56 deletions(-) diff --git a/docs-old/APIReference-GraphQL.md b/docs-old/APIReference-GraphQL.md index 3aea9e87ba9..470de306f6d 100644 --- a/docs-old/APIReference-GraphQL.md +++ b/docs-old/APIReference-GraphQL.md @@ -66,6 +66,12 @@ _Type Definitions_ A union type within GraphQL that defines a list of implementations. +
  • + +
    class GraphQLIntersectionType
    + An intersection type within GraphQL that defines a list of constraining types. +
    +
  • class GraphQLEnumType
    diff --git a/docs-old/APIReference-TypeSystem.md b/docs-old/APIReference-TypeSystem.md index 5b5047c349c..f6e81b360ce 100644 --- a/docs-old/APIReference-TypeSystem.md +++ b/docs-old/APIReference-TypeSystem.md @@ -54,6 +54,12 @@ _Definitions_ A union type within GraphQL that defines a list of implementations.
  • +
  • + +
    class GraphQLIntersectionType
    + An intersection type within GraphQL that defines a list of constraining types. +
    +
  • class GraphQLEnumType
    diff --git a/src/__testUtils__/kitchenSinkSDL.ts b/src/__testUtils__/kitchenSinkSDL.ts index cdf2f9afcea..14552f2806e 100644 --- a/src/__testUtils__/kitchenSinkSDL.ts +++ b/src/__testUtils__/kitchenSinkSDL.ts @@ -79,6 +79,18 @@ extend union Feed = Photo | Video extend union Feed @onUnion +intersection Resource = Feed & Node + +intersection AnnotatedIntersection @onIntersection = Feed & Node + +intersection AnnotatedIntersectionTwo @onIntersection = Feed & Node + +intersection UndefinedIntersection + +extend intersection Resource = Media & Accessible + +extend intersection Resource @onIntersection + scalar CustomScalar scalar AnnotatedScalar @onScalar diff --git a/src/execution/__tests__/abstract-test.ts b/src/execution/__tests__/abstract-test.ts index 5253d0d9e05..2439edef3d0 100644 --- a/src/execution/__tests__/abstract-test.ts +++ b/src/execution/__tests__/abstract-test.ts @@ -8,6 +8,7 @@ import { parse } from '../../language/parser'; import { assertInterfaceType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLObjectType, GraphQLUnionType, @@ -352,6 +353,94 @@ describe('Execute: Handles execution of abstract types', () => { }); }); + it('isTypeOf used to resolve runtime type for Intersection', async () => { + const DogType = new GraphQLObjectType({ + name: 'Dog', + isTypeOf(obj, context) { + const isDog = obj instanceof Dog; + return context.async ? Promise.resolve(isDog) : isDog; + }, + interfaces: () => [PetType], + fields: { + name: { type: GraphQLString }, + woofs: { type: GraphQLBoolean }, + }, + }); + + const CatType = new GraphQLObjectType({ + name: 'Cat', + isTypeOf(obj, context) { + const isCat = obj instanceof Cat; + return context.async ? Promise.resolve(isCat) : isCat; + }, + interfaces: () => [PetType], + fields: { + name: { type: GraphQLString }, + meows: { type: GraphQLBoolean }, + }, + }); + + const PetType = new GraphQLInterfaceType({ + name: 'Pet', + fields: { + name: { type: GraphQLString }, + }, + }); + + const CatOrDogType = new GraphQLUnionType({ + name: 'CatOrDog', + types: [DogType, CatType], + }); + + const CatOrDogPetType = new GraphQLIntersectionType({ + name: 'CatOrDogPet', + types: [CatOrDogType, PetType], + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + catOrDogPets: { + type: new GraphQLList(CatOrDogPetType), + resolve() { + return [new Dog('Odie', true), new Cat('Garfield', false)]; + }, + }, + }, + }), + }); + + const query = `{ + catOrDogPets { + ... on Pet { + name + } + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + }`; + + expect(await executeQuery({ schema, query })).to.deep.equal({ + data: { + catOrDogPets: [ + { + name: 'Odie', + woofs: true, + }, + { + name: 'Garfield', + meows: false, + }, + ], + }, + }); + }); + it('resolveType can throw', async () => { const PetType = new GraphQLInterfaceType({ name: 'Pet', @@ -497,6 +586,78 @@ describe('Execute: Handles execution of abstract types', () => { }); }); + it('resolve Intersection type using __typename on source object', async () => { + const schema = buildSchema(` + type Query { + catOrDogPets: [CatOrDogPet] + } + + union CatOrDog = Cat | Dog + + interface Pet { + name: String + } + + intersection CatOrDogPet = CatOrDog & Pet + + type Cat implements Pet { + name: String + meows: Boolean + } + + type Dog implements Pet { + name: String + woofs: Boolean + } + `); + + const query = ` + { + catOrDogPets { + ... on Pet { + name + } + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + } + `; + + const rootValue = { + catOrDogPets: [ + { + __typename: 'Dog', + name: 'Odie', + woofs: true, + }, + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + }, + ], + }; + + expect(await executeQuery({ schema, query, rootValue })).to.deep.equal({ + data: { + catOrDogPets: [ + { + name: 'Odie', + woofs: true, + }, + { + name: 'Garfield', + meows: false, + }, + ], + }, + }); + }); + it('resolve Interface type using __typename on source object', async () => { const schema = buildSchema(` type Query { diff --git a/src/execution/__tests__/union-interface-test.ts b/src/execution/__tests__/union-interface-test.ts index 7ce9f8b3bc7..c04ee202fdd 100644 --- a/src/execution/__tests__/union-interface-test.ts +++ b/src/execution/__tests__/union-interface-test.ts @@ -5,6 +5,7 @@ import { parse } from '../../language/parser'; import { GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLObjectType, GraphQLUnionType, @@ -45,15 +46,18 @@ class Cat { class Person { name: string; pets?: ReadonlyArray; - friends?: ReadonlyArray; + namedMammalPets?: ReadonlyArray; + friends?: ReadonlyArray; constructor( name: string, pets?: ReadonlyArray, + namedMammalPets?: ReadonlyArray, friends?: ReadonlyArray, ) { this.name = name; this.pets = pets; + this.namedMammalPets = namedMammalPets; this.friends = friends; } } @@ -130,6 +134,7 @@ const PersonType: GraphQLObjectType = new GraphQLObjectType({ fields: () => ({ name: { type: GraphQLString }, pets: { type: new GraphQLList(PetType) }, + namedMammalPets: { type: new GraphQLList(NamedMammalPetType) }, friends: { type: new GraphQLList(NamedType) }, progeny: { type: new GraphQLList(PersonType) }, mother: { type: PersonType }, @@ -138,6 +143,22 @@ const PersonType: GraphQLObjectType = new GraphQLObjectType({ isTypeOf: (value) => value instanceof Person, }); +const NamedMammalPetType = new GraphQLIntersectionType({ + name: 'NamedMammalPet', + types: [NamedType, MammalType, PetType], + resolveType(value) { + if (value instanceof Dog) { + return DogType.name; + } + if (value instanceof Cat) { + return CatType.name; + } + /* c8 ignore next 3 */ + // Not reachable, all possible types have been considered. + expect.fail('Not reachable'); + }, +}); + const schema = new GraphQLSchema({ query: PersonType, types: [PetType], @@ -152,10 +173,15 @@ odie.mother = new Dog("Odie's Mom", true); odie.mother.progeny = [odie]; const liz = new Person('Liz'); -const john = new Person('John', [garfield, odie], [liz, odie]); - -describe('Execute: Union and intersection types', () => { - it('can introspect on union and intersection types', () => { +const john = new Person( + 'John', + [garfield, odie], + [garfield, odie], + [liz, odie], +); + +describe('Execute: Union, interface and intersection types', () => { + it('can introspect on union, interface and intersection types', () => { const document = parse(` { Named: __type(name: "Named") { @@ -163,6 +189,7 @@ describe('Execute: Union and intersection types', () => { name fields { name } interfaces { name } + memberTypes { name } possibleTypes { name } enumValues { name } inputFields { name } @@ -172,6 +199,7 @@ describe('Execute: Union and intersection types', () => { name fields { name } interfaces { name } + memberTypes { name } possibleTypes { name } enumValues { name } inputFields { name } @@ -181,6 +209,17 @@ describe('Execute: Union and intersection types', () => { name fields { name } interfaces { name } + memberTypes { name } + possibleTypes { name } + enumValues { name } + inputFields { name } + } + NamedMammalPet: __type(name: "NamedMammalPet") { + kind + name + fields { name } + interfaces { name } + memberTypes { name } possibleTypes { name } enumValues { name } inputFields { name } @@ -195,6 +234,7 @@ describe('Execute: Union and intersection types', () => { name: 'Named', fields: [{ name: 'name' }], interfaces: [], + memberTypes: null, possibleTypes: [{ name: 'Dog' }, { name: 'Cat' }, { name: 'Person' }], enumValues: null, inputFields: null, @@ -204,6 +244,7 @@ describe('Execute: Union and intersection types', () => { name: 'Mammal', fields: [{ name: 'progeny' }, { name: 'mother' }, { name: 'father' }], interfaces: [{ name: 'Life' }], + memberTypes: null, possibleTypes: [{ name: 'Dog' }, { name: 'Cat' }, { name: 'Person' }], enumValues: null, inputFields: null, @@ -213,6 +254,17 @@ describe('Execute: Union and intersection types', () => { name: 'Pet', fields: null, interfaces: null, + memberTypes: null, + possibleTypes: [{ name: 'Dog' }, { name: 'Cat' }], + enumValues: null, + inputFields: null, + }, + NamedMammalPet: { + kind: 'INTERSECTION', + name: 'NamedMammalPet', + fields: null, + interfaces: null, + memberTypes: [{ name: 'Named' }, { name: 'Mammal' }, { name: 'Pet' }], possibleTypes: [{ name: 'Dog' }, { name: 'Cat' }], enumValues: null, inputFields: null, @@ -418,12 +470,158 @@ describe('Execute: Union and intersection types', () => { }); }); + it('executes using intersection types', () => { + // NOTE: This is an *invalid* query, but it should be an *executable* query. + const document = parse(` + { + __typename + name + namedMammalPets { + __typename + name + barks + meows + } + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + namedMammalPets: [ + { __typename: 'Cat', name: 'Garfield', meows: false }, + { __typename: 'Dog', name: 'Odie', barks: true }, + ], + }, + }); + }); + + it('executes intersection types with inline fragments', () => { + // This is the valid version of the query in the above test. + const document = parse(` + { + __typename + name + namedMammalPets { + __typename + ... on Named { + name + } + ... on Dog { + barks + } + ... on Cat { + meows + } + + ... on Mammal { + mother { + __typename + ... on Dog { + name + barks + } + ... on Cat { + name + meows + } + } + } + } + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + namedMammalPets: [ + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + mother: { + __typename: 'Cat', + name: "Garfield's Mom", + meows: false, + }, + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + mother: { + __typename: 'Dog', + name: "Odie's Mom", + barks: true, + }, + }, + ], + }, + }); + }); + + it('executes intersection types with named fragments', () => { + const document = parse(` + { + __typename + name + namedMammalPets { + __typename + ...Name + ...DogBarks + ...CatMeows + } + } + + fragment Name on Named { + name + } + + fragment DogBarks on Dog { + barks + } + + fragment CatMeows on Cat { + meows + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + namedMammalPets: [ + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + }, + ], + }, + }); + }); + it('allows fragment conditions to be abstract types', () => { const document = parse(` { __typename name pets { + ...NamedMammalPetFields, + ...on Mammal { + mother { + ...ProgenyFields + } + } + } + namedMammalPets { ...PetFields, ...on Mammal { mother { @@ -434,6 +632,19 @@ describe('Execute: Union and intersection types', () => { friends { ...FriendFields } } + fragment NamedMammalPetFields on NamedMammalPet { + __typename + ... on Named { + name + } + ... on Dog { + barks + } + ... on Cat { + meows + } + } + fragment PetFields on Pet { __typename ... on Dog { @@ -482,6 +693,20 @@ describe('Execute: Union and intersection types', () => { mother: { progeny: [{ __typename: 'Dog' }] }, }, ], + namedMammalPets: [ + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + mother: { progeny: [{ __typename: 'Cat' }] }, + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + mother: { progeny: [{ __typename: 'Dog' }] }, + }, + ], friends: [ { __typename: 'Person', @@ -525,7 +750,7 @@ describe('Execute: Union and intersection types', () => { }); const schema2 = new GraphQLSchema({ query: PersonType2 }); const document = parse('{ name, friends { name } }'); - const rootValue = new Person('John', [], [liz]); + const rootValue = new Person('John', [], [], [liz]); const contextValue = { authToken: '123abc' }; const result = executeSync({ diff --git a/src/index.ts b/src/index.ts index 7fbf4d6d683..b5442362d92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export { GraphQLObjectType, GraphQLInterfaceType, GraphQLUnionType, + GraphQLIntersectionType, GraphQLEnumType, GraphQLInputObjectType, GraphQLList, @@ -90,6 +91,7 @@ export { isObjectType, isInterfaceType, isUnionType, + isIntersectionType, isEnumType, isInputObjectType, isListType, @@ -115,6 +117,7 @@ export { assertObjectType, assertInterfaceType, assertUnionType, + assertIntersectionType, assertEnumType, assertInputObjectType, assertListType, @@ -181,6 +184,8 @@ export type { GraphQLInputObjectTypeExtensions, GraphQLInterfaceTypeConfig, GraphQLInterfaceTypeExtensions, + GraphQLIntersectionTypeConfig, + GraphQLIntersectionTypeExtensions, GraphQLIsTypeOfFn, GraphQLObjectTypeConfig, GraphQLObjectTypeExtensions, diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 13477f8de97..49ae95e4902 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -34,6 +34,7 @@ describe('AST node predicates', () => { 'ObjectTypeDefinition', 'InterfaceTypeDefinition', 'UnionTypeDefinition', + 'IntersectionTypeDefinition', 'EnumTypeDefinition', 'InputObjectTypeDefinition', 'DirectiveDefinition', @@ -42,6 +43,7 @@ describe('AST node predicates', () => { 'ObjectTypeExtension', 'InterfaceTypeExtension', 'UnionTypeExtension', + 'IntersectionTypeExtension', 'EnumTypeExtension', 'InputObjectTypeExtension', ]); @@ -102,6 +104,7 @@ describe('AST node predicates', () => { 'ObjectTypeDefinition', 'InterfaceTypeDefinition', 'UnionTypeDefinition', + 'IntersectionTypeDefinition', 'EnumTypeDefinition', 'InputObjectTypeDefinition', 'DirectiveDefinition', @@ -114,6 +117,7 @@ describe('AST node predicates', () => { 'ObjectTypeDefinition', 'InterfaceTypeDefinition', 'UnionTypeDefinition', + 'IntersectionTypeDefinition', 'EnumTypeDefinition', 'InputObjectTypeDefinition', ]); @@ -126,6 +130,7 @@ describe('AST node predicates', () => { 'ObjectTypeExtension', 'InterfaceTypeExtension', 'UnionTypeExtension', + 'IntersectionTypeExtension', 'EnumTypeExtension', 'InputObjectTypeExtension', ]); @@ -137,6 +142,7 @@ describe('AST node predicates', () => { 'ObjectTypeExtension', 'InterfaceTypeExtension', 'UnionTypeExtension', + 'IntersectionTypeExtension', 'EnumTypeExtension', 'InputObjectTypeExtension', ]); diff --git a/src/language/__tests__/schema-parser-test.ts b/src/language/__tests__/schema-parser-test.ts index cbb337c337a..64509ab9b08 100644 --- a/src/language/__tests__/schema-parser-test.ts +++ b/src/language/__tests__/schema-parser-test.ts @@ -282,6 +282,11 @@ describe('Schema Parser', () => { locations: [{ line: 1, column: 19 }], }); + expectSyntaxError('extend intersection Hello').to.deep.equal({ + message: 'Syntax Error: Unexpected .', + locations: [{ line: 1, column: 26 }], + }); + expectSyntaxError('extend enum Hello').to.deep.equal({ message: 'Syntax Error: Unexpected .', locations: [{ line: 1, column: 18 }], @@ -961,6 +966,97 @@ describe('Schema Parser', () => { }); }); + it('Simple union', () => { + const doc = parse('intersection Hello = World'); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'IntersectionTypeDefinition', + name: nameNode('Hello', { start: 13, end: 18 }), + description: undefined, + directives: [], + types: [typeNode('World', { start: 21, end: 26 })], + loc: { start: 0, end: 26 }, + }, + ], + loc: { start: 0, end: 26 }, + }); + }); + + it('Intersection with two types', () => { + const doc = parse('intersection Hello = Wo & Rld'); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'IntersectionTypeDefinition', + name: nameNode('Hello', { start: 13, end: 18 }), + description: undefined, + directives: [], + types: [ + typeNode('Wo', { start: 21, end: 23 }), + typeNode('Rld', { start: 26, end: 29 }), + ], + loc: { start: 0, end: 29 }, + }, + ], + loc: { start: 0, end: 29 }, + }); + }); + + it('Intersection with two types and leading ampersand', () => { + const doc = parse('intersection Hello = & Wo & Rld'); + + expectJSON(doc).toDeepEqual({ + kind: 'Document', + definitions: [ + { + kind: 'IntersectionTypeDefinition', + name: nameNode('Hello', { start: 13, end: 18 }), + description: undefined, + directives: [], + types: [ + typeNode('Wo', { start: 23, end: 25 }), + typeNode('Rld', { start: 28, end: 31 }), + ], + loc: { start: 0, end: 31 }, + }, + ], + loc: { start: 0, end: 31 }, + }); + }); + + it('Intersection fails with no types', () => { + expectSyntaxError('intersection Hello = &').to.deep.equal({ + message: 'Syntax Error: Expected Name, found .', + locations: [{ line: 1, column: 23 }], + }); + }); + + it('Intersection fails with leading double ampersand', () => { + expectSyntaxError('intersection Hello = && Wo & Rld').to.deep.equal({ + message: 'Syntax Error: Expected Name, found "&".', + locations: [{ line: 1, column: 23 }], + }); + }); + + it('Intersection fails with double ampersand', () => { + expectSyntaxError('intersection Hello = Wo && Rld').to.deep.equal({ + message: 'Syntax Error: Expected Name, found "&".', + locations: [{ line: 1, column: 26 }], + }); + }); + + it('Intersection fails with trailing ampersand', () => { + expectSyntaxError('intersection Hello = & Wo & Rld &').to.deep.equal({ + message: 'Syntax Error: Expected Name, found .', + locations: [{ line: 1, column: 34 }], + }); + }); + it('Scalar', () => { const doc = parse('scalar Hello'); diff --git a/src/language/__tests__/schema-printer-test.ts b/src/language/__tests__/schema-printer-test.ts index 7272b2f2b89..830ca62f065 100644 --- a/src/language/__tests__/schema-printer-test.ts +++ b/src/language/__tests__/schema-printer-test.ts @@ -110,6 +110,18 @@ describe('Printer: SDL document', () => { extend union Feed @onUnion + intersection Resource = Feed & Node + + intersection AnnotatedIntersection @onIntersection = Feed & Node + + intersection AnnotatedIntersectionTwo @onIntersection = Feed & Node + + intersection UndefinedIntersection + + extend intersection Resource = Media & Accessible + + extend intersection Resource @onIntersection + scalar CustomScalar scalar AnnotatedScalar @onScalar diff --git a/src/language/ast.ts b/src/language/ast.ts index 0b30366df09..97a9c4e5122 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -169,6 +169,7 @@ export type ASTNode = | InputValueDefinitionNode | InterfaceTypeDefinitionNode | UnionTypeDefinitionNode + | IntersectionTypeDefinitionNode | EnumTypeDefinitionNode | EnumValueDefinitionNode | InputObjectTypeDefinitionNode @@ -178,6 +179,7 @@ export type ASTNode = | ObjectTypeExtensionNode | InterfaceTypeExtensionNode | UnionTypeExtensionNode + | IntersectionTypeExtensionNode | EnumTypeExtensionNode | InputObjectTypeExtensionNode; @@ -263,6 +265,7 @@ export const QueryDocumentKeys: { 'fields', ], UnionTypeDefinition: ['description', 'name', 'directives', 'types'], + IntersectionTypeDefinition: ['description', 'name', 'directives', 'types'], EnumTypeDefinition: ['description', 'name', 'directives', 'values'], EnumValueDefinition: ['description', 'name', 'directives'], InputObjectTypeDefinition: ['description', 'name', 'directives', 'fields'], @@ -275,6 +278,7 @@ export const QueryDocumentKeys: { ObjectTypeExtension: ['name', 'interfaces', 'directives', 'fields'], InterfaceTypeExtension: ['name', 'interfaces', 'directives', 'fields'], UnionTypeExtension: ['name', 'directives', 'types'], + IntersectionTypeExtension: ['name', 'directives', 'types'], EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], }; @@ -568,6 +572,7 @@ export type TypeDefinitionNode = | ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode | UnionTypeDefinitionNode + | IntersectionTypeDefinitionNode | EnumTypeDefinitionNode | InputObjectTypeDefinitionNode; @@ -628,6 +633,15 @@ export interface UnionTypeDefinitionNode { readonly types?: ReadonlyArray; } +export interface IntersectionTypeDefinitionNode { + readonly kind: Kind.INTERSECTION_TYPE_DEFINITION; + readonly loc?: Location; + readonly description?: StringValueNode; + readonly name: NameNode; + readonly directives?: ReadonlyArray; + readonly types?: ReadonlyArray; +} + export interface EnumTypeDefinitionNode { readonly kind: Kind.ENUM_TYPE_DEFINITION; readonly loc?: Location; @@ -684,6 +698,7 @@ export type TypeExtensionNode = | ObjectTypeExtensionNode | InterfaceTypeExtensionNode | UnionTypeExtensionNode + | IntersectionTypeExtensionNode | EnumTypeExtensionNode | InputObjectTypeExtensionNode; @@ -720,6 +735,14 @@ export interface UnionTypeExtensionNode { readonly types?: ReadonlyArray; } +export interface IntersectionTypeExtensionNode { + readonly kind: Kind.INTERSECTION_TYPE_EXTENSION; + readonly loc?: Location; + readonly name: NameNode; + readonly directives?: ReadonlyArray; + readonly types?: ReadonlyArray; +} + export interface EnumTypeExtensionNode { readonly kind: Kind.ENUM_TYPE_EXTENSION; readonly loc?: Location; diff --git a/src/language/directiveLocation.ts b/src/language/directiveLocation.ts index e98ddf6d751..7aa2a1064d7 100644 --- a/src/language/directiveLocation.ts +++ b/src/language/directiveLocation.ts @@ -19,6 +19,7 @@ export enum DirectiveLocation { ARGUMENT_DEFINITION = 'ARGUMENT_DEFINITION', INTERFACE = 'INTERFACE', UNION = 'UNION', + INTERSECTION = 'INTERSECTION', ENUM = 'ENUM', ENUM_VALUE = 'ENUM_VALUE', INPUT_OBJECT = 'INPUT_OBJECT', diff --git a/src/language/kinds.ts b/src/language/kinds.ts index 39b2a8e675f..a25722b7fef 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -49,6 +49,7 @@ export enum Kind { INPUT_VALUE_DEFINITION = 'InputValueDefinition', INTERFACE_TYPE_DEFINITION = 'InterfaceTypeDefinition', UNION_TYPE_DEFINITION = 'UnionTypeDefinition', + INTERSECTION_TYPE_DEFINITION = 'IntersectionTypeDefinition', ENUM_TYPE_DEFINITION = 'EnumTypeDefinition', ENUM_VALUE_DEFINITION = 'EnumValueDefinition', INPUT_OBJECT_TYPE_DEFINITION = 'InputObjectTypeDefinition', @@ -64,6 +65,7 @@ export enum Kind { OBJECT_TYPE_EXTENSION = 'ObjectTypeExtension', INTERFACE_TYPE_EXTENSION = 'InterfaceTypeExtension', UNION_TYPE_EXTENSION = 'UnionTypeExtension', + INTERSECTION_TYPE_EXTENSION = 'IntersectionTypeExtension', ENUM_TYPE_EXTENSION = 'EnumTypeExtension', INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension', } diff --git a/src/language/parser.ts b/src/language/parser.ts index 282ee168596..3dd22cbfc44 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -31,6 +31,8 @@ import type { InputValueDefinitionNode, InterfaceTypeDefinitionNode, InterfaceTypeExtensionNode, + IntersectionTypeDefinitionNode, + IntersectionTypeExtensionNode, IntValueNode, ListTypeNode, ListValueNode, @@ -234,6 +236,7 @@ export class Parser { * - ObjectTypeDefinition * - InterfaceTypeDefinition * - UnionTypeDefinition + * - IntersectionTypeDefinition * - EnumTypeDefinition * - InputObjectTypeDefinition */ @@ -260,6 +263,8 @@ export class Parser { return this.parseInterfaceTypeDefinition(); case 'union': return this.parseUnionTypeDefinition(); + case 'intersection': + return this.parseIntersectionTypeDefinition(); case 'enum': return this.parseEnumTypeDefinition(); case 'input': @@ -992,6 +997,37 @@ export class Parser { : []; } + /** + * IntersectionTypeDefinition : + * - Description? intersection Name Directives[Const]? IntersectionMemberTypes? + */ + parseIntersectionTypeDefinition(): IntersectionTypeDefinitionNode { + const start = this._lexer.token; + const description = this.parseDescription(); + this.expectKeyword('intersection'); + const name = this.parseName(); + const directives = this.parseConstDirectives(); + const types = this.parseIntersectionMemberTypes(); + return this.node(start, { + kind: Kind.INTERSECTION_TYPE_DEFINITION, + description, + name, + directives, + types, + }); + } + + /** + * IntersectionMemberTypes : + * - = `|`? NamedType + * - IntersectionMemberTypes | NamedType + */ + parseIntersectionMemberTypes(): Array { + return this.expectOptionalToken(TokenKind.EQUALS) + ? this.delimitedMany(TokenKind.AMP, this.parseNamedType) + : []; + } + /** * EnumTypeDefinition : * - Description? enum Name Directives[Const]? EnumValuesDefinition? @@ -1104,6 +1140,7 @@ export class Parser { * - ObjectTypeExtension * - InterfaceTypeExtension * - UnionTypeExtension + * - IntersectionTypeExtension * - EnumTypeExtension * - InputObjectTypeDefinition */ @@ -1122,6 +1159,8 @@ export class Parser { return this.parseInterfaceTypeExtension(); case 'union': return this.parseUnionTypeExtension(); + case 'intersection': + return this.parseIntersectionTypeExtension(); case 'enum': return this.parseEnumTypeExtension(); case 'input': @@ -1262,6 +1301,29 @@ export class Parser { }); } + /** + * IntersectionTypeExtension : + * - extend intersection Name Directives[Const]? IntersectionMemberTypes + * - extend intersection Name Directives[Const] + */ + parseIntersectionTypeExtension(): IntersectionTypeExtensionNode { + const start = this._lexer.token; + this.expectKeyword('extend'); + this.expectKeyword('intersection'); + const name = this.parseName(); + const directives = this.parseConstDirectives(); + const types = this.parseIntersectionMemberTypes(); + if (directives.length === 0 && types.length === 0) { + throw this.unexpected(); + } + return this.node(start, { + kind: Kind.INTERSECTION_TYPE_EXTENSION, + name, + directives, + types, + }); + } + /** * EnumTypeExtension : * - extend enum Name Directives[Const]? EnumValuesDefinition diff --git a/src/language/predicates.ts b/src/language/predicates.ts index a390f4ee551..d705bc04825 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -89,6 +89,7 @@ export function isTypeDefinitionNode( node.kind === Kind.OBJECT_TYPE_DEFINITION || node.kind === Kind.INTERFACE_TYPE_DEFINITION || node.kind === Kind.UNION_TYPE_DEFINITION || + node.kind === Kind.INTERSECTION_TYPE_DEFINITION || node.kind === Kind.ENUM_TYPE_DEFINITION || node.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION ); @@ -106,6 +107,7 @@ export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode { node.kind === Kind.OBJECT_TYPE_EXTENSION || node.kind === Kind.INTERFACE_TYPE_EXTENSION || node.kind === Kind.UNION_TYPE_EXTENSION || + node.kind === Kind.INTERSECTION_TYPE_EXTENSION || node.kind === Kind.ENUM_TYPE_EXTENSION || node.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION ); diff --git a/src/language/printer.ts b/src/language/printer.ts index 38cb25444bf..e22fc35310f 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -210,6 +210,20 @@ const printDocASTReducer: ASTReducer = { ), }, + IntersectionTypeDefinition: { + leave: ({ description, name, directives, types }) => + wrap('', description, '\n') + + join( + [ + 'intersection', + name, + join(directives, ' '), + wrap('= ', join(types, ' & ')), + ], + ' ', + ), + }, + EnumTypeDefinition: { leave: ({ description, name, directives, values }) => wrap('', description, '\n') + @@ -294,6 +308,19 @@ const printDocASTReducer: ASTReducer = { ), }, + IntersectionTypeExtension: { + leave: ({ name, directives, types }) => + join( + [ + 'extend intersection', + name, + join(directives, ' '), + wrap('= ', join(types, ' & ')), + ], + ' ', + ), + }, + EnumTypeExtension: { leave: ({ name, directives, values }) => join(['extend enum', name, join(directives, ' '), block(values)], ' '), diff --git a/src/type/__tests__/definition-test.ts b/src/type/__tests__/definition-test.ts index 19d482915a7..ca872802f6c 100644 --- a/src/type/__tests__/definition-test.ts +++ b/src/type/__tests__/definition-test.ts @@ -11,6 +11,7 @@ import { GraphQLEnumType, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -25,6 +26,10 @@ const InterfaceType = new GraphQLInterfaceType({ fields: {}, }); const UnionType = new GraphQLUnionType({ name: 'Union', types: [ObjectType] }); +const IntersectionType = new GraphQLIntersectionType({ + name: 'Intersection', + types: [UnionType], +}); const EnumType = new GraphQLEnumType({ name: 'Enum', values: { foo: {} } }); const InputObjectType = new GraphQLInputObjectType({ name: 'InputObject', @@ -621,6 +626,74 @@ describe('Type System: Unions', () => { }); }); +describe('Type System: Intersections', () => { + it('accepts an Intersection type', () => { + expect( + () => + new GraphQLIntersectionType({ + name: 'SomeIntersection', + types: [UnionType], + }), + ).to.not.throw(); + }); + + it('accepts an Intersection type with array types', () => { + const intersectionType = new GraphQLIntersectionType({ + name: 'SomeIntersection', + types: [UnionType], + }); + expect(intersectionType.getTypes()).to.deep.equal([UnionType]); + }); + + it('accepts an Intersection type with function returning an array of types', () => { + const intersectionType = new GraphQLIntersectionType({ + name: 'SomeIntersection', + types: () => [UnionType], + }); + expect(intersectionType.getTypes()).to.deep.equal([UnionType]); + }); + + it('accepts an Intersection type without types', () => { + const intersectionType = new GraphQLIntersectionType({ + name: 'SomeIntersection', + types: [], + }); + expect(intersectionType.getTypes()).to.deep.equal([]); + }); + + it('rejects an Intersection type with invalid name', () => { + expect( + () => new GraphQLIntersectionType({ name: 'bad-name', types: [] }), + ).to.throw('Names must only contain [_a-zA-Z0-9] but "bad-name" does not.'); + }); + + it('rejects an Intersection type with an incorrect type for resolveType', () => { + expect( + () => + new GraphQLIntersectionType({ + name: 'SomeIntersection', + types: [], + // @ts-expect-error + resolveType: {}, + }), + ).to.throw( + 'SomeIntersection must provide "resolveType" as a function, but got: {}.', + ); + }); + + it('rejects an Intersection type with incorrectly typed types', () => { + const intersectionType = new GraphQLIntersectionType({ + name: 'SomeIntersection', + // @ts-expect-error + types: { UnionType }, + }); + + expect(() => intersectionType.getTypes()).to.throw( + 'Must provide Array of types or a function which returns such an array for Intersection SomeIntersection.', + ); + }); +}); + describe('Type System: Enums', () => { it('defines an enum type with deprecated value', () => { const EnumTypeWithDeprecatedValue = new GraphQLEnumType({ @@ -899,6 +972,7 @@ describe('Type System: List', () => { expectList(ScalarType).to.not.throw(); expectList(ObjectType).to.not.throw(); expectList(UnionType).to.not.throw(); + expectList(IntersectionType).to.not.throw(); expectList(InterfaceType).to.not.throw(); expectList(EnumType).to.not.throw(); expectList(InputObjectType).to.not.throw(); @@ -929,6 +1003,7 @@ describe('Type System: Non-Null', () => { expectNonNull(ScalarType).to.not.throw(); expectNonNull(ObjectType).to.not.throw(); expectNonNull(UnionType).to.not.throw(); + expectNonNull(IntersectionType).to.not.throw(); expectNonNull(InterfaceType).to.not.throw(); expectNonNull(EnumType).to.not.throw(); expectNonNull(InputObjectType).to.not.throw(); @@ -963,6 +1038,7 @@ describe('Type System: test utility methods', () => { expect(String(ObjectType)).to.equal('Object'); expect(String(InterfaceType)).to.equal('Interface'); expect(String(UnionType)).to.equal('Union'); + expect(String(IntersectionType)).to.equal('Intersection'); expect(String(EnumType)).to.equal('Enum'); expect(String(InputObjectType)).to.equal('InputObject'); @@ -978,6 +1054,7 @@ describe('Type System: test utility methods', () => { expect(JSON.stringify(ObjectType)).to.equal('"Object"'); expect(JSON.stringify(InterfaceType)).to.equal('"Interface"'); expect(JSON.stringify(UnionType)).to.equal('"Union"'); + expect(JSON.stringify(IntersectionType)).to.equal('"Intersection"'); expect(JSON.stringify(EnumType)).to.equal('"Enum"'); expect(JSON.stringify(InputObjectType)).to.equal('"InputObject"'); @@ -999,6 +1076,9 @@ describe('Type System: test utility methods', () => { expect(toString(ObjectType)).to.equal('[object GraphQLObjectType]'); expect(toString(InterfaceType)).to.equal('[object GraphQLInterfaceType]'); expect(toString(UnionType)).to.equal('[object GraphQLUnionType]'); + expect(toString(IntersectionType)).to.equal( + '[object GraphQLIntersectionType]', + ); expect(toString(EnumType)).to.equal('[object GraphQLEnumType]'); expect(toString(InputObjectType)).to.equal( '[object GraphQLInputObjectType]', diff --git a/src/type/__tests__/introspection-test.ts b/src/type/__tests__/introspection-test.ts index df431dafd30..0fa8c2b8c36 100644 --- a/src/type/__tests__/introspection-test.ts +++ b/src/type/__tests__/introspection-test.ts @@ -56,6 +56,7 @@ describe('Introspection', () => { inputFields: null, interfaces: [], enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -66,6 +67,7 @@ describe('Introspection', () => { inputFields: null, interfaces: null, enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -76,6 +78,7 @@ describe('Introspection', () => { inputFields: null, interfaces: null, enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -181,6 +184,7 @@ describe('Introspection', () => { inputFields: null, interfaces: [], enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -284,6 +288,25 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'memberTypes', + args: [], + type: { + kind: 'LIST', + name: null, + ofType: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'OBJECT', + name: '__Type', + ofType: null, + }, + }, + }, + isDeprecated: false, + deprecationReason: null, + }, { name: 'possibleTypes', args: [], @@ -376,6 +399,7 @@ describe('Introspection', () => { inputFields: null, interfaces: [], enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -406,6 +430,11 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'INTERSECTION', + isDeprecated: false, + deprecationReason: null, + }, { name: 'ENUM', isDeprecated: false, @@ -427,6 +456,7 @@ describe('Introspection', () => { deprecationReason: null, }, ], + memberTypes: null, possibleTypes: null, }, { @@ -538,6 +568,7 @@ describe('Introspection', () => { inputFields: null, interfaces: [], enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -627,6 +658,7 @@ describe('Introspection', () => { inputFields: null, interfaces: [], enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -690,6 +722,7 @@ describe('Introspection', () => { inputFields: null, interfaces: [], enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -798,6 +831,7 @@ describe('Introspection', () => { inputFields: null, interfaces: [], enumValues: null, + memberTypes: null, possibleTypes: null, }, { @@ -883,6 +917,11 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'INTERSECTION', + isDeprecated: false, + deprecationReason: null, + }, { name: 'ENUM', isDeprecated: false, @@ -904,6 +943,7 @@ describe('Introspection', () => { deprecationReason: null, }, ], + memberTypes: null, possibleTypes: null, }, ], diff --git a/src/type/__tests__/predicate-test.ts b/src/type/__tests__/predicate-test.ts index 81e721e7df3..d541172ead5 100644 --- a/src/type/__tests__/predicate-test.ts +++ b/src/type/__tests__/predicate-test.ts @@ -15,6 +15,7 @@ import { assertInputObjectType, assertInputType, assertInterfaceType, + assertIntersectionType, assertLeafType, assertListType, assertNamedType, @@ -31,6 +32,7 @@ import { GraphQLEnumType, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -42,6 +44,7 @@ import { isInputObjectType, isInputType, isInterfaceType, + isIntersectionType, isLeafType, isListType, isNamedType, @@ -80,6 +83,10 @@ const InterfaceType = new GraphQLInterfaceType({ fields: {}, }); const UnionType = new GraphQLUnionType({ name: 'Union', types: [ObjectType] }); +const IntersectionType = new GraphQLIntersectionType({ + name: 'Intersection', + types: [UnionType], +}); const EnumType = new GraphQLEnumType({ name: 'Enum', values: { foo: {} } }); const InputObjectType = new GraphQLInputObjectType({ name: 'InputObject', @@ -219,6 +226,27 @@ describe('Type predicates', () => { }); }); + describe('isIntersectionType', () => { + it('returns true for intersection type', () => { + expect(isIntersectionType(IntersectionType)).to.equal(true); + expect(() => assertIntersectionType(IntersectionType)).to.not.throw(); + }); + + it('returns false for wrapped union type', () => { + expect(isIntersectionType(new GraphQLList(IntersectionType))).to.equal( + false, + ); + expect(() => + assertIntersectionType(new GraphQLList(IntersectionType)), + ).to.throw(); + }); + + it('returns false for non-intersection type', () => { + expect(isIntersectionType(UnionType)).to.equal(false); + expect(() => assertIntersectionType(UnionType)).to.throw(); + }); + }); + describe('isEnumType', () => { it('returns true for enum type', () => { expect(isEnumType(EnumType)).to.equal(true); diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index 629e0b8c3ca..426d61fdfbc 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -24,12 +24,14 @@ import { assertEnumType, assertInputObjectType, assertInterfaceType, + assertIntersectionType, assertObjectType, assertScalarType, assertUnionType, GraphQLEnumType, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -49,6 +51,8 @@ const SomeSchema = buildSchema(` union SomeUnion = SomeObject + intersection SomeIntersection = SomeInterface & SomeUnion + enum SomeEnum { ONLY } input SomeInputObject { val: String = "hello" } @@ -62,6 +66,9 @@ const SomeInterfaceType = assertInterfaceType( ); const SomeObjectType = assertObjectType(SomeSchema.getType('SomeObject')); const SomeUnionType = assertUnionType(SomeSchema.getType('SomeUnion')); +const SomeIntersectionType = assertIntersectionType( + SomeSchema.getType('SomeIntersection'), +); const SomeEnumType = assertEnumType(SomeSchema.getType('SomeEnum')); const SomeInputObjectType = assertInputObjectType( SomeSchema.getType('SomeInputObject'), @@ -86,6 +93,7 @@ const outputTypes: ReadonlyArray = [ ...withModifiers(SomeEnumType), ...withModifiers(SomeObjectType), ...withModifiers(SomeUnionType), + ...withModifiers(SomeIntersectionType), ...withModifiers(SomeInterfaceType), ]; @@ -103,6 +111,7 @@ const inputTypes: ReadonlyArray = [ const notInputTypes: ReadonlyArray = [ ...withModifiers(SomeObjectType), ...withModifiers(SomeUnionType), + ...withModifiers(SomeIntersectionType), ...withModifiers(SomeInterfaceType), ]; @@ -401,7 +410,7 @@ describe('Type System: A Schema must have Object root types', () => { }, { message: 'Expected GraphQL named type but got: @SomeDirective.', - locations: [{ line: 14, column: 3 }], + locations: [{ line: 16, column: 3 }], }, ]); }); @@ -706,6 +715,554 @@ describe('Type System: Union types must be valid', () => { }); }); +describe('Type System: Intersection types must be valid', () => { + it('accepts an Intersection type with member types', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA implements SomeInterface { + someField: String + } + + type TypeB { + anotherField: String + } + + interface SomeInterface { + someField: String + } + + union SomeUnion = + | TypeA + | TypeB + + intersection GoodIntersection = + & SomeInterface + & SomeUnion + `); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + const SomeInterface = assertInterfaceType(schema.getType('SomeInterface')); + const SomeUnion = assertUnionType(schema.getType('SomeUnion')); + const TypeA = assertObjectType(schema.getType('TypeA')); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([TypeA]); + expect(schema.isSubType(SomeInterface, GoodIntersection)).to.equal(true); + expect(schema.isSubType(SomeUnion, GoodIntersection)).to.equal(true); + }); + + it('accepts an Intersection type including an intersection type', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA implements SomeInterface { + someField: String + } + + type TypeB { + anotherField: String + } + + interface SomeInterface { + someField: String + } + + union SomeUnion = + | TypeA + | TypeB + + intersection SomeIntersection = + & SomeInterface + & SomeUnion + + intersection GoodIntersection = SomeIntersection + `); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + const SomeInterface = assertInterfaceType(schema.getType('SomeInterface')); + const SomeUnion = assertUnionType(schema.getType('SomeUnion')); + const SomeIntersection = assertIntersectionType( + schema.getType('SomeIntersection'), + ); + const TypeA = assertObjectType(schema.getType('TypeA')); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([TypeA]); + expect(schema.isSubType(SomeInterface, GoodIntersection)).to.equal(true); + expect(schema.isSubType(SomeUnion, GoodIntersection)).to.equal(true); + expect(schema.isSubType(SomeIntersection, GoodIntersection)).to.equal(true); + }); + + it('accepts an Intersection type with conflicting unions', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA { + someField: String + } + + type TypeB { + someField: String + } + + union SomeUnion = + | TypeA + | TypeB + + type TypeC { + someField: String + } + + type TypeD { + someField: String + } + + union AnotherUnion = + | TypeC + | TypeD + + intersection GoodIntersection = + & SomeUnion + & AnotherUnion + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([]); + }); + + it('accepts an Intersection type with multiple conflicting unions', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA { + someField: String + } + + type TypeB { + someField: String + } + + union SomeUnion = + | TypeA + | TypeB + + type TypeC { + someField: String + } + + type TypeD { + someField: String + } + + union AnotherUnion = + | TypeC + | TypeD + + type TypeE { + someField: String + } + + type TypeF { + someField: String + } + + union FinalUnion = + | TypeE + | TypeF + + intersection GoodIntersection = + & SomeUnion + & AnotherUnion + & FinalUnion + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([]); + }); + + it('accepts an Intersection type with conflicting interfaces', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA implements SomeInterface { + someField: String + } + + type TypeB implements SomeInterface { + someField: String + } + + interface SomeInterface { + someField: String + } + + type TypeC implements AnotherInterface { + anotherField: String + } + + type TypeD implements AnotherInterface { + anotherField: String + } + + interface AnotherInterface { + anotherField: String + } + + intersection GoodIntersection = + & SomeInterface + & AnotherInterface + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([]); + }); + + it('accepts an Intersection type with multiple conflicting interfaces', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA implements SomeInterface { + someField: String + } + + type TypeB implements SomeInterface { + someField: String + } + + interface SomeInterface { + someField: String + } + + type TypeC implements AnotherInterface { + anotherField: String + } + + type TypeD implements AnotherInterface { + anotherField: String + } + + interface AnotherInterface { + anotherField: String + } + + type TypeE implements FinalInterface { + finalField: String + } + + type TypeF implements FinalInterface { + finalField: String + } + + interface FinalInterface { + finalField: String + } + + intersection GoodIntersection = + & SomeInterface + & AnotherInterface + & FinalInterface + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([]); + }); + + it('accepts an Intersection type with conflicting union and interface types', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA { + someField: String + } + + type TypeB { + anotherField: String + } + + type TypeC implements SomeInterface { + someField: String + } + + interface SomeInterface { + someField: String + } + + union SomeUnion = + | TypeA + | TypeB + + intersection GoodIntersection = + & SomeInterface + & SomeUnion + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([]); + }); + + it('accepts an Intersection type with a non-implemented interface', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA { + someField: String + } + + type TypeB { + anotherField: String + } + + interface SomeInterface { + someField: String + } + + union SomeUnion = + | TypeA + | TypeB + + intersection GoodIntersection = + & SomeInterface + & SomeUnion + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([]); + }); + + it('accepts an Intersection type with an empty union', () => { + const schema = buildSchema(` + type Query { + test: GoodIntersection + } + + type TypeA implements SomeInterface { + someField: String + } + + interface SomeInterface { + someField: String + } + + union SomeUnion + + intersection GoodIntersection = + & SomeInterface + & SomeUnion + `); + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: 'Union type SomeUnion must define one or more member types.', + locations: [{ line: 14, column: 7 }], + }, + ]); + + const GoodIntersection = assertIntersectionType( + schema.getType('GoodIntersection'), + ); + expect(schema.getPossibleTypes(GoodIntersection)).to.deep.equal([]); + }); + + it('rejects an Intersection type with empty types', () => { + let schema = buildSchema(` + type Query { + test: BadIntersection + } + + intersection BadIntersection + `); + + schema = extendSchema( + schema, + parse(` + directive @test on INTERSECTION + + extend intersection BadIntersection @test + `), + ); + + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Intersection type BadIntersection must define one or more member types.', + locations: [ + { line: 6, column: 7 }, + { line: 4, column: 9 }, + ], + }, + ]); + }); + + it('rejects an Intersection type with duplicated member type', () => { + let schema = buildSchema(` + type Query { + test: BadIntersection + } + + type TypeA implements SomeInterface { + someField: String + } + + type TypeB { + anotherField: String + } + + interface SomeInterface { + someField: String + } + + union SomeUnion = + | TypeA + | TypeB + + intersection BadIntersection = + & SomeInterface + & SomeUnion + & SomeInterface + `); + + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Intersection type BadIntersection can only include type SomeInterface once.', + locations: [ + { line: 23, column: 11 }, + { line: 25, column: 11 }, + ], + }, + ]); + + schema = extendSchema( + schema, + parse('extend intersection BadIntersection = SomeUnion'), + ); + + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Intersection type BadIntersection can only include type SomeInterface once.', + locations: [ + { line: 23, column: 11 }, + { line: 25, column: 11 }, + ], + }, + { + message: + 'Intersection type BadIntersection can only include type SomeUnion once.', + locations: [ + { line: 24, column: 11 }, + { line: 1, column: 39 }, + ], + }, + ]); + }); + + it('rejects an Intersection type with non-abstract member types', () => { + let schema = buildSchema(` + type Query { + test: BadIntersection + } + + type TypeA implements SomeInterface { + someField: String + } + + type TypeB { + anotherField: String + } + + interface SomeInterface { + someField: String + } + + union SomeUnion = + | TypeA + | TypeB + + intersection BadIntersection = + & SomeInterface + & String + & SomeUnion + `); + + schema = extendSchema( + schema, + parse('extend intersection BadIntersection = Int'), + ); + + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Intersection type BadIntersection can only include Interface, Union or Intersection types, it cannot include String.', + locations: [{ line: 24, column: 11 }], + }, + { + message: + 'Intersection type BadIntersection can only include Interface, Union or Intersection types, it cannot include Int.', + locations: [{ line: 1, column: 39 }], + }, + ]); + + const badIntersectionMemberTypes = [ + GraphQLString, + SomeObjectType, + new GraphQLNonNull(SomeObjectType), + new GraphQLList(SomeObjectType), + SomeEnumType, + SomeInputObjectType, + ]; + for (const memberType of badIntersectionMemberTypes) { + const badIntersection = new GraphQLIntersectionType({ + name: 'BadIntersection', + // @ts-expect-error + types: [memberType], + }); + const badSchema = schemaWithFieldType(badIntersection); + expectJSON(validateSchema(badSchema)).toDeepEqual([ + { + message: + 'Intersection type BadIntersection can only include Interface, Union or Intersection types, ' + + `it cannot include ${inspect(memberType)}.`, + }, + ]); + } + }); +}); + describe('Type System: Input Objects must have fields', () => { it('accepts an Input Object type with fields', () => { const schema = buildSchema(` @@ -1837,6 +2394,89 @@ describe('Objects must adhere to Interface they implement', () => { expectJSON(validateSchema(schema)).toDeepEqual([]); }); + it('accepts an Object with a subtyped Interface field (object subtyped from intersection)', () => { + const schema = buildSchema(` + type Query { + test: AnotherObject + } + + type SomeObject { + field: String + } + + union SomeUnionType = SomeObject + + intersection SomeIntersectionType = SomeUnionType + + interface AnotherInterface { + field: SomeIntersectionType + } + + type AnotherObject implements AnotherInterface { + field: SomeObject + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts an Object with a subtyped Interface field (intersection subtyped from interface)', () => { + const schema = buildSchema(` + type Query { + test: AnotherObject + } + + type SomeObject implements SomeInterface { + field: String + } + + interface SomeInterface { + field: String + } + + union SomeUnionType = SomeObject + + intersection SomeIntersectionType = SomeUnionType & SomeInterface + + interface AnotherInterface { + field: SomeInterface + } + + type AnotherObject implements AnotherInterface { + field: SomeIntersectionType + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts an Object with a subtyped Interface field (intersection subtyped from union)', () => { + const schema = buildSchema(` + type Query { + test: AnotherObject + } + + type SomeObject implements SomeInterface { + field: String + } + + interface SomeInterface { + field: String + } + + union SomeUnionType = SomeObject + + intersection SomeIntersectionType = SomeUnionType & SomeInterface + + interface AnotherInterface { + field: SomeUnionType + } + + type AnotherObject implements AnotherInterface { + field: SomeIntersectionType + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + it('rejects an Object missing an Interface argument', () => { const schema = buildSchema(` type Query { @@ -2271,6 +2911,89 @@ describe('Interfaces must adhere to Interface they implement', () => { expectJSON(validateSchema(schema)).toDeepEqual([]); }); + it('accepts an Interface with a subtyped Interface field (object subtyped from intersection)', () => { + const schema = buildSchema(` + type Query { + test: ChildInterface + } + + type SomeObject { + field: String + } + + union SomeUnionType = SomeObject + + intersection SomeIntersectionType = SomeUnionType + + interface ParentInterface { + field: SomeIntersectionType + } + + interface ChildInterface implements ParentInterface { + field: SomeObject + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts an Interface with a subtyped Interface field (intersection subtyped from interface)', () => { + const schema = buildSchema(` + type Query { + test: ChildInterface + } + + type SomeObject implements SomeInterface { + field: String + } + + union SomeUnionType = SomeObject + + interface SomeInterface { + field: String + } + + intersection SomeIntersectionType = SomeUnionType & SomeInterface + + interface ParentInterface { + field: SomeInterface + } + + interface ChildInterface implements ParentInterface { + field: SomeIntersectionType + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + + it('accepts an Interface with a subtyped Interface field (intersection subtyped from union)', () => { + const schema = buildSchema(` + type Query { + test: ChildInterface + } + + type SomeObject implements SomeInterface { + field: String + } + + union SomeUnionType = SomeObject + + interface SomeInterface { + field: String + } + + intersection SomeIntersectionType = SomeUnionType & SomeInterface + + interface ParentInterface { + field: SomeUnionType + } + + interface ChildInterface implements ParentInterface { + field: SomeIntersectionType + } + `); + expectJSON(validateSchema(schema)).toDeepEqual([]); + }); + it('rejects an Interface implementing a non-Interface type', () => { const schema = buildSchema(` type Query { diff --git a/src/type/definition.ts b/src/type/definition.ts index d9192c723ad..6a10f8ec7f9 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -28,6 +28,8 @@ import type { InputValueDefinitionNode, InterfaceTypeDefinitionNode, InterfaceTypeExtensionNode, + IntersectionTypeDefinitionNode, + IntersectionTypeExtensionNode, ObjectTypeDefinitionNode, ObjectTypeExtensionNode, OperationDefinitionNode, @@ -55,6 +57,7 @@ export type GraphQLType = | GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType + | GraphQLIntersectionType | GraphQLEnumType | GraphQLInputObjectType | GraphQLList @@ -63,6 +66,7 @@ export type GraphQLType = | GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType + | GraphQLIntersectionType | GraphQLEnumType | GraphQLInputObjectType | GraphQLList @@ -74,6 +78,7 @@ export function isType(type: unknown): type is GraphQLType { isObjectType(type) || isInterfaceType(type) || isUnionType(type) || + isIntersectionType(type) || isEnumType(type) || isInputObjectType(type) || isListType(type) || @@ -137,6 +142,21 @@ export function assertUnionType(type: unknown): GraphQLUnionType { return type; } +export function isIntersectionType( + type: unknown, +): type is GraphQLIntersectionType { + return instanceOf(type, GraphQLIntersectionType); +} + +export function assertIntersectionType(type: unknown): GraphQLIntersectionType { + if (!isIntersectionType(type)) { + throw new Error( + `Expected ${inspect(type)} to be a GraphQL Intersection type.`, + ); + } + return type; +} + export function isEnumType(type: unknown): type is GraphQLEnumType { return instanceOf(type, GraphQLEnumType); } @@ -242,6 +262,7 @@ export type GraphQLOutputType = | GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType + | GraphQLIntersectionType | GraphQLEnumType | GraphQLList | GraphQLNonNull< @@ -249,6 +270,7 @@ export type GraphQLOutputType = | GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType + | GraphQLIntersectionType | GraphQLEnumType | GraphQLList >; @@ -259,6 +281,7 @@ export function isOutputType(type: unknown): type is GraphQLOutputType { isObjectType(type) || isInterfaceType(type) || isUnionType(type) || + isIntersectionType(type) || isEnumType(type) || (isWrappingType(type) && isOutputType(type.ofType)) ); @@ -293,10 +316,16 @@ export function assertLeafType(type: unknown): GraphQLLeafType { export type GraphQLCompositeType = | GraphQLObjectType | GraphQLInterfaceType - | GraphQLUnionType; + | GraphQLUnionType + | GraphQLIntersectionType; export function isCompositeType(type: unknown): type is GraphQLCompositeType { - return isObjectType(type) || isInterfaceType(type) || isUnionType(type); + return ( + isObjectType(type) || + isInterfaceType(type) || + isUnionType(type) || + isIntersectionType(type) + ); } export function assertCompositeType(type: unknown): GraphQLCompositeType { @@ -311,10 +340,13 @@ export function assertCompositeType(type: unknown): GraphQLCompositeType { /** * These types may describe the parent context of a selection set. */ -export type GraphQLAbstractType = GraphQLInterfaceType | GraphQLUnionType; +export type GraphQLAbstractType = + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLIntersectionType; export function isAbstractType(type: unknown): type is GraphQLAbstractType { - return isInterfaceType(type) || isUnionType(type); + return isInterfaceType(type) || isUnionType(type) || isIntersectionType(type); } export function assertAbstractType(type: unknown): GraphQLAbstractType { @@ -441,6 +473,7 @@ export type GraphQLNullableType = | GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType + | GraphQLIntersectionType | GraphQLEnumType | GraphQLInputObjectType | GraphQLList; @@ -486,6 +519,7 @@ export type GraphQLNamedOutputType = | GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType + | GraphQLIntersectionType | GraphQLEnumType; export function isNamedType(type: unknown): type is GraphQLNamedType { @@ -494,6 +528,7 @@ export function isNamedType(type: unknown): type is GraphQLNamedType { isObjectType(type) || isInterfaceType(type) || isUnionType(type) || + isIntersectionType(type) || isEnumType(type) || isInputObjectType(type) ); @@ -1256,7 +1291,7 @@ export class GraphQLUnionType { this.astNode = config.astNode; this.extensionASTNodes = config.extensionASTNodes ?? []; - this._types = defineTypes.bind(undefined, config); + this._types = defineUnionMemberTypes.bind(undefined, config); devAssert( config.resolveType == null || typeof config.resolveType === 'function', `${this.name} must provide "resolveType" as a function, ` + @@ -1296,7 +1331,7 @@ export class GraphQLUnionType { } } -function defineTypes( +function defineUnionMemberTypes( config: Readonly>, ): ReadonlyArray { const types = resolveReadonlyArrayThunk(config.types); @@ -1329,6 +1364,135 @@ interface GraphQLUnionTypeNormalizedConfig extensionASTNodes: ReadonlyArray; } +/** + * Custom extensions + * + * @remarks + * Use a unique identifier name for your extension, for example the name of + * your library or project. Do not use a shortened identifier as this increases + * the risk of conflicts. We recommend you add at most one extension field, + * an object which can contain all the values you need. + */ +export interface GraphQLIntersectionTypeExtensions { + [attributeName: string]: unknown; +} + +/** + * Intersection Type Definition + * + * When a field can return one of a set of types constrained by multiple + * abstract types, an Intersection type is used to describe the constraints + * as well as providing a function to determine which type is actually used + * when the field is resolved. + * + * Example: + * + * ```ts + * const PetNodeType = new GraphQLIntersectionType({ + * name: 'Pet', + * types: [ PetType, NodeType ], + * resolveType(value) { + * if (value instanceof Dog) { + * return DogType; + * } + * if (value instanceof Cat) { + * return CatType; + * } + * } + * }); + * ``` + */ +export class GraphQLIntersectionType { + name: string; + description: Maybe; + resolveType: Maybe>; + extensions: Readonly; + astNode: Maybe; + extensionASTNodes: ReadonlyArray; + + private _types: ThunkReadonlyArray; + + constructor(config: Readonly>) { + this.name = assertName(config.name); + this.description = config.description; + this.resolveType = config.resolveType; + this.extensions = toObjMap(config.extensions); + this.astNode = config.astNode; + this.extensionASTNodes = config.extensionASTNodes ?? []; + + this._types = defineIntersectionMemberTypes.bind(undefined, config); + devAssert( + config.resolveType == null || typeof config.resolveType === 'function', + `${this.name} must provide "resolveType" as a function, ` + + `but got: ${inspect(config.resolveType)}.`, + ); + } + + get [Symbol.toStringTag]() { + return 'GraphQLIntersectionType'; + } + + getTypes(): ReadonlyArray { + if (typeof this._types === 'function') { + this._types = this._types(); + } + return this._types; + } + + toConfig(): GraphQLIntersectionTypeNormalizedConfig { + return { + name: this.name, + description: this.description, + types: this.getTypes(), + resolveType: this.resolveType, + extensions: this.extensions, + astNode: this.astNode, + extensionASTNodes: this.extensionASTNodes, + }; + } + + toString(): string { + return this.name; + } + + toJSON(): string { + return this.toString(); + } +} + +function defineIntersectionMemberTypes( + config: Readonly>, +): ReadonlyArray { + const types = resolveReadonlyArrayThunk(config.types); + devAssert( + Array.isArray(types), + `Must provide Array of types or a function which returns such an array for Intersection ${config.name}.`, + ); + return types; +} + +export interface GraphQLIntersectionTypeConfig { + name: string; + description?: Maybe; + types: ThunkReadonlyArray; + /** + * Optionally provide a custom type resolver function. If one is not provided, + * the default implementation will call `isTypeOf` on each implementing + * Object type. + */ + resolveType?: Maybe>; + extensions?: Maybe>; + astNode?: Maybe; + extensionASTNodes?: Maybe>; +} + +interface GraphQLIntersectionTypeNormalizedConfig + extends GraphQLIntersectionTypeConfig { + types: ReadonlyArray; + extensions: Readonly; + extensionASTNodes: ReadonlyArray; +} + /** * Custom extensions * diff --git a/src/type/index.ts b/src/type/index.ts index 270dd67d35d..407d83ba0ca 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -19,6 +19,7 @@ export { isObjectType, isInterfaceType, isUnionType, + isIntersectionType, isEnumType, isInputObjectType, isListType, @@ -39,6 +40,7 @@ export { assertObjectType, assertInterfaceType, assertUnionType, + assertIntersectionType, assertEnumType, assertInputObjectType, assertListType, @@ -59,6 +61,7 @@ export { GraphQLObjectType, GraphQLInterfaceType, GraphQLUnionType, + GraphQLIntersectionType, GraphQLEnumType, GraphQLInputObjectType, // Type Wrappers @@ -105,6 +108,8 @@ export type { GraphQLInputObjectTypeExtensions, GraphQLInterfaceTypeConfig, GraphQLInterfaceTypeExtensions, + GraphQLIntersectionTypeConfig, + GraphQLIntersectionTypeExtensions, GraphQLIsTypeOfFn, GraphQLObjectTypeConfig, GraphQLObjectTypeExtensions, diff --git a/src/type/introspection.ts b/src/type/introspection.ts index e5fce6f2418..683d90d47ba 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -23,6 +23,7 @@ import { isEnumType, isInputObjectType, isInterfaceType, + isIntersectionType, isListType, isNonNullType, isObjectType, @@ -185,6 +186,10 @@ export const __DirectiveLocation: GraphQLEnumType = new GraphQLEnumType({ value: DirectiveLocation.UNION, description: 'Location adjacent to a union definition.', }, + INTERSECTION: { + value: DirectiveLocation.INTERSECTION, + description: 'Location adjacent to an intersection definition.', + }, ENUM: { value: DirectiveLocation.ENUM, description: 'Location adjacent to an enum definition.', @@ -207,7 +212,7 @@ export const __DirectiveLocation: GraphQLEnumType = new GraphQLEnumType({ export const __Type: GraphQLObjectType = new GraphQLObjectType({ name: '__Type', description: - 'The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByURL`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.', + 'The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByURL`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union, Intersection and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.', fields: () => ({ kind: { @@ -225,6 +230,9 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ if (isUnionType(type)) { return TypeKind.UNION; } + if (isIntersectionType(type)) { + return TypeKind.INTERSECTION; + } if (isEnumType(type)) { return TypeKind.ENUM; } @@ -280,6 +288,14 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({ } }, }, + memberTypes: { + type: new GraphQLList(new GraphQLNonNull(__Type)), + resolve(type, _args, _context) { + if (isIntersectionType(type)) { + return type.getTypes(); + } + }, + }, possibleTypes: { type: new GraphQLList(new GraphQLNonNull(__Type)), resolve(type, _args, _context, { schema }) { @@ -440,6 +456,7 @@ export enum TypeKind { OBJECT = 'OBJECT', INTERFACE = 'INTERFACE', UNION = 'UNION', + INTERSECTION = 'INTERSECTION', ENUM = 'ENUM', INPUT_OBJECT = 'INPUT_OBJECT', LIST = 'LIST', @@ -469,6 +486,11 @@ export const __TypeKind: GraphQLEnumType = new GraphQLEnumType({ description: 'Indicates this type is a union. `possibleTypes` is a valid field.', }, + INTERSECTION: { + value: TypeKind.INTERSECTION, + description: + 'Indicates this type is an intersection. `memberTypes` and `possibleTypes` are valid fields.', + }, ENUM: { value: TypeKind.ENUM, description: diff --git a/src/type/schema.ts b/src/type/schema.ts index 97c27821459..7ab7320800c 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -17,14 +17,17 @@ import { OperationTypeNode } from '../language/ast'; import type { GraphQLAbstractType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLNamedType, GraphQLObjectType, GraphQLType, + GraphQLUnionType, } from './definition'; import { getNamedType, isInputObjectType, isInterfaceType, + isIntersectionType, isObjectType, isUnionType, } from './definition'; @@ -142,9 +145,17 @@ export class GraphQLSchema { private _directives: ReadonlyArray; private _typeMap: TypeMap; private _subTypeMap: ObjMap>; + private _intersectingTypesMap: ObjMap>; + private _constrainingTypesMap: ObjMap<{ + interfaces: Array; + unions: Array; + intersections: Array; + }>; + private _implementationsMap: ObjMap<{ objects: Array; interfaces: Array; + intersections: Array; }>; constructor(config: Readonly) { @@ -210,6 +221,10 @@ export class GraphQLSchema { // Storing the resulting map for reference by the schema. this._typeMap = Object.create(null); this._subTypeMap = Object.create(null); + // Keep track of possible types by intersection name. + this._intersectingTypesMap = Object.create(null); + // Keep track of constraining types by intersection name. + this._constrainingTypesMap = Object.create(null); // Keep track of all implementations by interface name. this._implementationsMap = Object.create(null); @@ -239,6 +254,7 @@ export class GraphQLSchema { implementations = this._implementationsMap[iface.name] = { objects: [], interfaces: [], + intersections: [], }; } @@ -254,12 +270,55 @@ export class GraphQLSchema { implementations = this._implementationsMap[iface.name] = { objects: [], interfaces: [], + intersections: [], }; } implementations.objects.push(namedType); } } + } else if (isIntersectionType(namedType)) { + // Store implementations by intersections. + const constrainingTypes = this.getConstrainingTypes(namedType); + + for (const iface of constrainingTypes.interfaces) { + let implementations = this._implementationsMap[iface.name]; + if (implementations === undefined) { + implementations = this._implementationsMap[iface.name] = { + objects: [], + interfaces: [], + intersections: [], + }; + } + + implementations.intersections.push(namedType); + } + + for (const union of constrainingTypes.unions) { + let implementations = this._implementationsMap[union.name]; + if (implementations === undefined) { + implementations = this._implementationsMap[union.name] = { + objects: [], + interfaces: [], + intersections: [], + }; + } + + implementations.intersections.push(namedType); + } + + for (const intersection of constrainingTypes.intersections) { + let implementations = this._implementationsMap[intersection.name]; + if (implementations === undefined) { + implementations = this._implementationsMap[intersection.name] = { + objects: [], + interfaces: [], + intersections: [], + }; + } + + implementations.intersections.push(namedType); + } } } } @@ -302,37 +361,129 @@ export class GraphQLSchema { getPossibleTypes( abstractType: GraphQLAbstractType, ): ReadonlyArray { - return isUnionType(abstractType) - ? abstractType.getTypes() - : this.getImplementations(abstractType).objects; + if (isUnionType(abstractType)) { + return abstractType.getTypes(); + } + if (isInterfaceType(abstractType)) { + return this.getImplementations(abstractType).objects; + } + + return this.getIntersectingTypes(abstractType); } - getImplementations(interfaceType: GraphQLInterfaceType): { + getIntersectingTypes( + intersectionType: GraphQLIntersectionType, + ): ReadonlyArray { + let intersectingTypes = this._intersectingTypesMap[intersectionType.name]; + if (intersectingTypes) { + return intersectingTypes; + } + + const intersectingTypeSet: Set = new Set(); + filterPossibleTypes( + intersectingTypeSet, + this.getConstrainingTypes(intersectionType), + this, + ); + + intersectingTypes = Array.from(intersectingTypeSet); + this._intersectingTypesMap[intersectionType.name] = intersectingTypes; + return intersectingTypes; + } + + getConstrainingTypes(intersectionType: GraphQLIntersectionType): { + interfaces: ReadonlyArray; + unions: ReadonlyArray; + intersections: ReadonlyArray; + } { + let constrainingTypes = this._constrainingTypesMap[intersectionType.name]; + if (constrainingTypes) { + return this._constrainingTypesMap[intersectionType.name]; + } + + constrainingTypes = this._constrainingTypesMap[intersectionType.name] = { + interfaces: [], + unions: [], + intersections: [], + }; + + for (const abstractType of intersectionType.getTypes()) { + if (isInterfaceType(abstractType)) { + constrainingTypes.interfaces.push(abstractType); + } else if (isUnionType(abstractType)) { + constrainingTypes.unions.push(abstractType); + } else if (isIntersectionType(abstractType)) { + constrainingTypes.intersections.push(abstractType); + const intersectionConstrainingTypes = + this.getConstrainingTypes(abstractType); + + constrainingTypes.interfaces = constrainingTypes.interfaces.concat( + intersectionConstrainingTypes.interfaces, + ); + constrainingTypes.unions = constrainingTypes.unions.concat( + intersectionConstrainingTypes.unions, + ); + constrainingTypes.intersections = + constrainingTypes.intersections.concat( + intersectionConstrainingTypes.intersections, + ); + } + } + + return constrainingTypes; + } + + getImplementations(abstractType: GraphQLAbstractType): { objects: ReadonlyArray; interfaces: ReadonlyArray; + intersections: ReadonlyArray; } { - const implementations = this._implementationsMap[interfaceType.name]; - return implementations ?? { objects: [], interfaces: [] }; + const implementations = this._implementationsMap[abstractType.name]; + return ( + implementations ?? { + objects: [], + interfaces: [], + intersections: [], + } + ); } isSubType( abstractType: GraphQLAbstractType, - maybeSubType: GraphQLObjectType | GraphQLInterfaceType, + maybeSubType: + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLIntersectionType, ): boolean { let map = this._subTypeMap[abstractType.name]; if (map === undefined) { map = Object.create(null); - if (isUnionType(abstractType)) { + if (isInterfaceType(abstractType)) { + const implementations = this.getImplementations(abstractType); + for (const type of implementations.objects) { + map[type.name] = true; + } + for (const type of implementations.interfaces) { + map[type.name] = true; + } + for (const type of implementations.intersections) { + map[type.name] = true; + } + } else if (isUnionType(abstractType)) { + const implementations = this.getImplementations(abstractType); + for (const type of implementations.intersections) { + map[type.name] = true; + } for (const type of abstractType.getTypes()) { map[type.name] = true; } - } else { + } else if (isIntersectionType(abstractType)) { const implementations = this.getImplementations(abstractType); - for (const type of implementations.objects) { + for (const type of implementations.intersections) { map[type.name] = true; } - for (const type of implementations.interfaces) { + for (const type of this.getIntersectingTypes(abstractType)) { map[type.name] = true; } } @@ -366,6 +517,84 @@ export class GraphQLSchema { } } +function isNonEmpty( + types: ReadonlyArray, +): types is Readonly< + [ + GraphQLInterfaceType | GraphQLUnionType, + ...Array, + ] +> { + return types.length > 0; +} + +function filterPossibleTypes( + possibleTypeSet: Set, + constrainingTypes: { + interfaces: ReadonlyArray; + unions: ReadonlyArray; + intersections: ReadonlyArray; + }, + schema: GraphQLSchema, +): void { + if (isNonEmpty(constrainingTypes.interfaces)) { + for (const possibleType of schema.getPossibleTypes( + constrainingTypes.interfaces[0], + )) { + possibleTypeSet.add(possibleType); + } + + _filterPossibleTypes( + constrainingTypes.interfaces, + constrainingTypes.unions, + possibleTypeSet, + schema, + ); + } else if (isNonEmpty(constrainingTypes.unions)) { + for (const possibleType of schema.getPossibleTypes( + constrainingTypes.unions[0], + )) { + possibleTypeSet.add(possibleType); + } + + _filterPossibleTypes(constrainingTypes.unions, [], possibleTypeSet, schema); + } +} + +function _filterPossibleTypes( + nonEmptyGroup: Readonly< + [ + GraphQLInterfaceType | GraphQLUnionType, + ...Array, + ] + >, + secondaryGroup: ReadonlyArray, + possibleTypeSet: Set, + schema: GraphQLSchema, +): void { + for (let i = 1; i < nonEmptyGroup.length; i++) { + for (const possibleType of possibleTypeSet) { + if (!schema.isSubType(nonEmptyGroup[i], possibleType)) { + possibleTypeSet.delete(possibleType); + if (!possibleTypeSet.size) { + return; + } + } + } + } + + for (const abstractType of secondaryGroup) { + for (const possibleType of possibleTypeSet) { + if (!schema.isSubType(abstractType, possibleType)) { + possibleTypeSet.delete(possibleType); + if (!possibleTypeSet.size) { + return; + } + } + } + } +} + type TypeMap = ObjMap; export interface GraphQLSchemaValidationOptions { @@ -415,6 +644,10 @@ function collectReferencedTypes( for (const memberType of namedType.getTypes()) { collectReferencedTypes(memberType, typeSet); } + } else if (isIntersectionType(namedType)) { + for (const memberType of namedType.getTypes()) { + collectReferencedTypes(memberType, typeSet); + } } else if (isObjectType(namedType) || isInterfaceType(namedType)) { for (const interfaceType of namedType.getInterfaces()) { collectReferencedTypes(interfaceType, typeSet); diff --git a/src/type/validate.ts b/src/type/validate.ts index 126e97d9805..56597ae8b82 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -8,6 +8,8 @@ import type { DirectiveNode, InterfaceTypeDefinitionNode, InterfaceTypeExtensionNode, + IntersectionTypeDefinitionNode, + IntersectionTypeExtensionNode, NamedTypeNode, ObjectTypeDefinitionNode, ObjectTypeExtensionNode, @@ -23,6 +25,7 @@ import type { GraphQLInputField, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLObjectType, GraphQLUnionType, } from './definition'; @@ -31,6 +34,7 @@ import { isInputObjectType, isInputType, isInterfaceType, + isIntersectionType, isNamedType, isNonNullType, isObjectType, @@ -245,6 +249,9 @@ function validateTypes(context: SchemaValidationContext): void { } else if (isUnionType(type)) { // Ensure Unions include valid member types. validateUnionMembers(context, type); + } else if (isIntersectionType(type)) { + // Ensure Intersections include valid member types. + validateIntersectionMembers(context, type); } else if (isEnumType(type)) { // Ensure Enums have valid values. validateEnumValues(context, type); @@ -479,6 +486,43 @@ function validateUnionMembers( } } +function validateIntersectionMembers( + context: SchemaValidationContext, + intersection: GraphQLIntersectionType, +): void { + const memberTypes = intersection.getTypes(); + + if (memberTypes.length === 0) { + context.reportError( + `Intersection type ${intersection.name} must define one or more member types.`, + [intersection.astNode, ...intersection.extensionASTNodes], + ); + } + + const includedTypeNames = Object.create(null); + for (const memberType of memberTypes) { + if (includedTypeNames[memberType.name]) { + context.reportError( + `Intersection type ${intersection.name} can only include type ${memberType.name} once.`, + getIntersectionMemberTypeNodes(intersection, memberType.name), + ); + continue; + } + includedTypeNames[memberType.name] = true; + if ( + !isInterfaceType(memberType) && + !isUnionType(memberType) && + !isIntersectionType(memberType) + ) { + context.reportError( + `Intersection type ${intersection.name} can only include Interface, Union or Intersection types, ` + + `it cannot include ${inspect(memberType)}.`, + getIntersectionMemberTypeNodes(intersection, String(memberType)), + ); + } + } +} + function validateEnumValues( context: SchemaValidationContext, enumType: GraphQLEnumType, @@ -618,6 +662,23 @@ function getUnionMemberTypeNodes( .filter((typeNode) => typeNode.name.value === typeName); } +function getIntersectionMemberTypeNodes( + intersection: GraphQLIntersectionType, + typeName: string, +): Maybe> { + const { astNode, extensionASTNodes } = intersection; + const nodes: ReadonlyArray< + IntersectionTypeDefinitionNode | IntersectionTypeExtensionNode + > = astNode != null ? [astNode, ...extensionASTNodes] : extensionASTNodes; + + // FIXME: https://github.com/graphql/graphql-js/issues/2203 + return nodes + .flatMap( + (intersectionNode) => /* c8 ignore next */ intersectionNode.types ?? [], + ) + .filter((typeNode) => typeNode.name.value === typeName); +} + function getDeprecatedDirectiveNode( definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, ): Maybe { diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 7427ebb5073..f2b63d7f478 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -15,6 +15,7 @@ import { assertEnumType, assertInputObjectType, assertInterfaceType, + assertIntersectionType, assertObjectType, assertScalarType, assertUnionType, @@ -201,6 +202,9 @@ describe('Schema Builder', () => { """There is nothing inside!""" union BlackHole + """There is nothing intersecting!""" + intersection Ether + """With an enum""" enum Color { RED @@ -473,6 +477,67 @@ describe('Schema Builder', () => { expect(errors).to.have.lengthOf.above(0); }); + it('Empty intersection', () => { + const sdl = dedent` + intersection EmptyIntersection + `; + expect(cycleSDL(sdl)).to.equal(sdl); + }); + + it('Simple Intersection', () => { + const sdl = dedent` + intersection Hello = World + + type Query { + hello: Hello + } + + union World = SomeType + + type SomeType { + str: String + } + `; + expect(cycleSDL(sdl)).to.equal(sdl); + }); + + it('Multiple Intersection', () => { + const sdl = dedent` + intersection Hello = WorldOne & WorldTwo + + type Query { + hello: Hello + } + + type SomeType { + str: String + } + + union WorldOne = SomeType + + type AnotherType implements WorldTwo { + str: String + } + + interface WorldTwo { + str: String + } + `; + expect(cycleSDL(sdl)).to.equal(sdl); + }); + + it('Can build recursive Intersection', () => { + const schema = buildSchema(` + intersection Hello = Hello + + type Query { + hello: Hello + } + `); + const errors = validateSchema(schema); + expect(errors).to.have.lengthOf(0); + }); + it('Custom Scalar', () => { const sdl = dedent` scalar CustomScalar @@ -841,6 +906,38 @@ describe('Schema Builder', () => { `); }); + it('Correctly extend intersection type', () => { + const schema = buildSchema(` + intersection SomeIntersection = FirstUnion + extend intersection SomeIntersection = SecondUnion + extend intersection SomeIntersection = ThirdUnion + + union FirstUnion = FirstType + union SecondUnion = SecondType + union ThirdUnion = ThirdType + + type FirstType + type SecondType + type ThirdType + `); + + const someUnion = assertIntersectionType( + schema.getType('SomeIntersection'), + ); + expect(printType(someUnion)).to.equal(dedent` + intersection SomeIntersection = FirstUnion & SecondUnion & ThirdUnion + `); + + expectASTNode(someUnion).to.equal( + 'intersection SomeIntersection = FirstUnion', + ); + expectExtensionASTNodes(someUnion).to.equal(dedent` + extend intersection SomeIntersection = SecondUnion + + extend intersection SomeIntersection = ThirdUnion + `); + }); + it('Correctly extend enum type', () => { const schema = buildSchema(dedent` enum SomeEnum { diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index b50cdf66c37..b225227b92b 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -283,6 +283,34 @@ describe('Type System: build schema from introspection', () => { expect(cycleIntrospection(sdl)).to.equal(sdl); }); + it('builds a schema with an intersection', () => { + const sdl = dedent` + interface Named { + name: String + } + + union Friendly = Dog | Human + + intersection NamedFriendly = Friendly & Named + + type Dog implements Named { + name: String + bestFriend: Friendly + } + + type Human implements Named { + name: String + bestFriend: Friendly + } + + type Query { + namedFriendly: NamedFriendly + } + `; + + expect(cycleIntrospection(sdl)).to.equal(sdl); + }); + it('builds a schema with complex field values', () => { const sdl = dedent` type Query { @@ -619,6 +647,8 @@ describe('Type System: build schema from introspection', () => { union SomeUnion = Query + intersection SomeIntersection = SomeUnion + enum SomeEnum { FOO } input SomeInputObject { @@ -798,6 +828,21 @@ describe('Type System: build schema from introspection', () => { ); }); + it('throws when missing memberTypes', () => { + const introspection = introspectionFromSchema(dummySchema); + const someIntersectionIntrospection = introspection.__schema.types.find( + ({ name }) => name === 'SomeIntersection', + ); + + invariant(someIntersectionIntrospection?.kind === 'INTERSECTION'); + // @ts-expect-error + delete someIntersectionIntrospection.memberTypes; + + expect(() => buildClientSchema(introspection)).to.throw( + /Introspection result missing memberTypes: { kind: "INTERSECTION", name: "SomeIntersection",.* }\./, + ); + }); + it('throws when missing possibleTypes', () => { const introspection = introspectionFromSchema(dummySchema); const someUnionIntrospection = introspection.__schema.types.find( diff --git a/src/utilities/__tests__/extendSchema-test.ts b/src/utilities/__tests__/extendSchema-test.ts index 86baf0e6998..f9a4b41594a 100644 --- a/src/utilities/__tests__/extendSchema-test.ts +++ b/src/utilities/__tests__/extendSchema-test.ts @@ -14,6 +14,7 @@ import { assertEnumType, assertInputObjectType, assertInterfaceType, + assertIntersectionType, assertObjectType, assertScalarType, assertUnionType, @@ -239,6 +240,48 @@ describe('extendSchema', () => { `); }); + it('extends intersections by adding new types', () => { + const schema = buildSchema(` + type Query { + someIntersection: SomeIntersection + } + + intersection SomeIntersection = FooUnion & BizUnion + + union FooUnion = Foo + union BizUnion = Biz + union BarUnion = Bar + + type Foo { foo: String } + type Biz { biz: String } + type Bar { bar: String } + `); + const extendAST = parse(` + extend intersection SomeIntersection = BarUnion + `); + const extendedSchema = extendSchema(schema, extendAST); + + expect(validateSchema(extendedSchema)).to.deep.equal([]); + expectSchemaChanges(schema, extendedSchema).to.equal(dedent` + intersection SomeIntersection = FooUnion & BizUnion & BarUnion + `); + }); + + it('allows extension of union by adding itself', () => { + const schema = buildSchema(` + intersection SomeIntersection + `); + const extendAST = parse(` + extend intersection SomeIntersection = SomeIntersection + `); + const extendedSchema = extendSchema(schema, extendAST); + + expect(validateSchema(extendedSchema)).to.have.lengthOf.above(0); + expectSchemaChanges(schema, extendedSchema).to.equal(dedent` + intersection SomeIntersection = SomeIntersection + `); + }); + it('extends inputs by adding new fields', () => { const schema = buildSchema(` type Query { @@ -327,6 +370,7 @@ describe('extendSchema', () => { scalar SomeScalar enum SomeEnum union SomeUnion + intersection SomeIntersection input SomeInput type SomeObject interface SomeInterface @@ -346,6 +390,8 @@ describe('extendSchema', () => { extend union SomeUnion = SomeObject + extend intersection SomeIntersection = SomeUnion + extend input SomeInput { newField: String } @@ -377,6 +423,8 @@ describe('extendSchema', () => { extend union SomeUnion = TestType + extend intersection SomeIntersection = TestUnion + extend input SomeInput { oneMoreNewField: String } @@ -387,6 +435,8 @@ describe('extendSchema', () => { union TestUnion = TestType + intersection TestIntersection = TestUnion + interface TestInterface { interfaceField: String } @@ -413,6 +463,9 @@ describe('extendSchema', () => { const query = assertObjectType(extendedTwiceSchema.getType('Query')); const someEnum = assertEnumType(extendedTwiceSchema.getType('SomeEnum')); const someUnion = assertUnionType(extendedTwiceSchema.getType('SomeUnion')); + const someIntersection = assertIntersectionType( + extendedTwiceSchema.getType('SomeIntersection'), + ); const someScalar = assertScalarType( extendedTwiceSchema.getType('SomeScalar'), ); @@ -428,6 +481,9 @@ describe('extendSchema', () => { ); const testEnum = assertEnumType(extendedTwiceSchema.getType('TestEnum')); const testUnion = assertUnionType(extendedTwiceSchema.getType('TestUnion')); + const testIntersection = assertIntersectionType( + extendedTwiceSchema.getType('TestIntersection'), + ); const testType = assertObjectType(extendedTwiceSchema.getType('TestType')); const testInterface = assertInterfaceType( extendedTwiceSchema.getType('TestInterface'), @@ -439,6 +495,7 @@ describe('extendSchema', () => { expect(testType.extensionASTNodes).to.deep.equal([]); expect(testEnum.extensionASTNodes).to.deep.equal([]); expect(testUnion.extensionASTNodes).to.deep.equal([]); + expect(testIntersection.extensionASTNodes).to.deep.equal([]); expect(testInput.extensionASTNodes).to.deep.equal([]); expect(testInterface.extensionASTNodes).to.deep.equal([]); @@ -446,6 +503,7 @@ describe('extendSchema', () => { testInput.astNode, testEnum.astNode, testUnion.astNode, + testIntersection.astNode, testInterface.astNode, testType.astNode, testDirective.astNode, @@ -453,6 +511,7 @@ describe('extendSchema', () => { ...someScalar.extensionASTNodes, ...someEnum.extensionASTNodes, ...someUnion.extensionASTNodes, + ...someIntersection.extensionASTNodes, ...someInput.extensionASTNodes, ...someInterface.extensionASTNodes, ]).to.have.members([ @@ -563,6 +622,8 @@ describe('extendSchema', () => { someField: String } + union DummyIntersectionMember = DummyUnionMember + enum UnusedEnum { SOME_VALUE } @@ -580,6 +641,8 @@ describe('extendSchema', () => { } union UnusedUnion = DummyUnionMember + + intersection UnusedIntersection = DummyIntersectionMember `; const extendedSchema = extendSchema(schema, parse(extensionSDL)); @@ -698,13 +761,16 @@ describe('extendSchema', () => { scalar NewScalar - union NewUnion = NewObject`; + union NewUnion = NewObject + + intersection NewIntersection = NewUnion`; const extendAST = parse(` ${newTypesSDL} extend type SomeObject { newObject: NewObject newInterface: NewInterface newUnion: NewUnion + newIntersection: NewIntersection newScalar: NewScalar newEnum: NewEnum newTree: [SomeObject]! @@ -719,6 +785,7 @@ describe('extendSchema', () => { newObject: NewObject newInterface: NewInterface newUnion: NewUnion + newIntersection: NewIntersection newScalar: NewScalar newEnum: NewEnum newTree: [SomeObject]! @@ -774,6 +841,7 @@ describe('extendSchema', () => { someInterface: SomeInterface someEnum: SomeEnum someUnion: SomeUnion + someIntersection: SomeIntersection } scalar SomeScalar @@ -792,6 +860,8 @@ describe('extendSchema', () => { union SomeUnion = SomeObject + intersection SomeIntersection = SomeUnion + input SomeInput { oldField: String } @@ -809,6 +879,10 @@ describe('extendSchema', () => { foo: String } + union NewUnion = NewObject + + union AnotherNewUnion = AnotherNewObject + interface NewInterface { newField: String } @@ -843,6 +917,10 @@ describe('extendSchema', () => { extend union SomeUnion = AnotherNewObject + extend intersection SomeIntersection = NewUnion + + extend intersection SomeIntersection = AnotherNewUnion + extend input SomeInput { newField: String } @@ -871,6 +949,8 @@ describe('extendSchema', () => { union SomeUnion = SomeObject | NewObject | AnotherNewObject + intersection SomeIntersection = SomeUnion & NewUnion & AnotherNewUnion + input SomeInput { oldField: String newField: String diff --git a/src/utilities/__tests__/findBreakingChanges-test.ts b/src/utilities/__tests__/findBreakingChanges-test.ts index 6c26e24ad0b..d366f0d0146 100644 --- a/src/utilities/__tests__/findBreakingChanges-test.ts +++ b/src/utilities/__tests__/findBreakingChanges-test.ts @@ -67,12 +67,14 @@ describe('findBreakingChanges', () => { scalar TypeWasScalarBecomesEnum interface TypeWasInterfaceBecomesUnion type TypeWasObjectBecomesInputObject + intersection TypeWasIntersectionBecomesScalar `); const newSchema = buildSchema(` enum TypeWasScalarBecomesEnum union TypeWasInterfaceBecomesUnion input TypeWasObjectBecomesInputObject + scalar TypeWasIntersectionBecomesScalar `); expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ { @@ -90,6 +92,11 @@ describe('findBreakingChanges', () => { description: 'TypeWasObjectBecomesInputObject changed from an Object type to an Input type.', }, + { + type: BreakingChangeType.TYPE_CHANGED_KIND, + description: + 'TypeWasIntersectionBecomesScalar changed from an Intersection type to a Scalar type.', + }, ]); }); @@ -339,6 +346,35 @@ describe('findBreakingChanges', () => { ]); }); + it('should detect if a type was added to an intersection type', () => { + const oldSchema = buildSchema(` + type Type1 + type Type2 + + union UnionType1 = Type1 + union UnionType2 = Type2 + + intersection IntersectionType1 = UnionType1 + `); + const newSchema = buildSchema(` + type Type1 + type Type2 + + union UnionType1 = Type1 + union UnionType2 = Type2 + + intersection IntersectionType1 = UnionType1 & UnionType2 + `); + + expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ + { + type: BreakingChangeType.TYPE_ADDED_TO_INTERSECTION, + description: + 'UnionType2 was added to intersection type IntersectionType1.', + }, + ]); + }); + it('should detect if a value was removed from an enum type', () => { const oldSchema = buildSchema(` enum EnumType1 { @@ -665,6 +701,10 @@ describe('findBreakingChanges', () => { type TypeInUnion2 union UnionTypeThatLosesAType = TypeInUnion1 | TypeInUnion2 + union UnionInIntersection1 = TypeInUnion1 + union UnionInIntersection2 = TypeInUnion2 + intersection IntersectionTypeThatGainsAUnion = UnionInIntersection1 + type TypeThatChangesType type TypeThatGetsRemoved @@ -700,6 +740,10 @@ describe('findBreakingChanges', () => { type TypeInUnion2 union UnionTypeThatLosesAType = TypeInUnion1 + union UnionInIntersection1 = TypeInUnion1 + union UnionInIntersection2 = TypeInUnion2 + intersection IntersectionTypeThatGainsAUnion = UnionInIntersection1 & UnionInIntersection2 + interface TypeThatChangesType interface TypeThatHasBreakingFieldChanges { @@ -737,6 +781,11 @@ describe('findBreakingChanges', () => { description: 'TypeInUnion2 was removed from union type UnionTypeThatLosesAType.', }, + { + type: BreakingChangeType.TYPE_ADDED_TO_INTERSECTION, + description: + 'UnionInIntersection2 was added to intersection type IntersectionTypeThatGainsAUnion.', + }, { type: BreakingChangeType.TYPE_CHANGED_KIND, description: @@ -1124,6 +1173,40 @@ describe('findDangerousChanges', () => { ]); }); + it('should detect if a type was removed from an intersection type', () => { + const oldSchema = buildSchema(` + type Type1 + type Type2 + type Type3 + + union UnionType1 = Type1 + union UnionType2 = Type2 + union UnionType3 = Type3 + + intersection IntersectionType1 = UnionType1 & UnionType2 + `); + + const newSchema = buildSchema(` + type Type1 + type Type2 + type Type3 + + union UnionType1 = Type1 + union UnionType2 = Type2 + union UnionType3 = Type3 + + intersection IntersectionType1 = UnionType1 & UnionType3 + `); + + expect(findDangerousChanges(oldSchema, newSchema)).to.deep.equal([ + { + type: DangerousChangeType.TYPE_REMOVED_FROM_INTERSECTION, + description: + 'UnionType2 was removed from intersection type IntersectionType1.', + }, + ]); + }); + it('should detect if an optional field was added to an input', () => { const oldSchema = buildSchema(` input InputType1 { @@ -1162,7 +1245,12 @@ describe('findDangerousChanges', () => { type TypeThatGainsInterface1 type TypeInUnion1 + type TypeInUnion2 union UnionTypeThatGainsAType = TypeInUnion1 + + union UnionInIntersection1 = TypeInUnion1 + union UnionInIntersection2 = TypeInUnion2 + intersection IntersectionTypeThatLosesAUnion = UnionInIntersection1 & UnionInIntersection2 `); const newSchema = buildSchema(` @@ -1182,6 +1270,10 @@ describe('findDangerousChanges', () => { type TypeInUnion1 type TypeInUnion2 union UnionTypeThatGainsAType = TypeInUnion1 | TypeInUnion2 + + union UnionInIntersection1 = TypeInUnion1 + union UnionInIntersection2 = TypeInUnion2 + intersection IntersectionTypeThatLosesAUnion = UnionInIntersection1 `); expect(findDangerousChanges(oldSchema, newSchema)).to.deep.equal([ @@ -1204,6 +1296,11 @@ describe('findDangerousChanges', () => { description: 'TypeInUnion2 was added to union type UnionTypeThatGainsAType.', }, + { + type: DangerousChangeType.TYPE_REMOVED_FROM_INTERSECTION, + description: + 'UnionInIntersection2 was removed from intersection type IntersectionTypeThatLosesAUnion.', + }, ]); }); diff --git a/src/utilities/__tests__/lexicographicSortSchema-test.ts b/src/utilities/__tests__/lexicographicSortSchema-test.ts index bce12e3ac5f..c6296df83b9 100644 --- a/src/utilities/__tests__/lexicographicSortSchema-test.ts +++ b/src/utilities/__tests__/lexicographicSortSchema-test.ts @@ -143,6 +143,60 @@ describe('lexicographicSortSchema', () => { `); }); + it('sort types in intersection', () => { + const sorted = sortSDL(` + type FooA { + dummy: String + } + + union FooAUnion = FooA + + type FooB { + dummy: String + } + + union FooBUnion = FooB + + type FooC { + dummy: String + } + + union FooCUnion = FooC + + intersection FooIntersection = FooBUnion & FooAUnion & FooCUnion + + type Query { + dummy: FooIntersection + } + `); + + expect(sorted).to.equal(dedent` + type FooA { + dummy: String + } + + union FooAUnion = FooA + + type FooB { + dummy: String + } + + union FooBUnion = FooB + + type FooC { + dummy: String + } + + union FooCUnion = FooC + + intersection FooIntersection = FooAUnion & FooBUnion & FooCUnion + + type Query { + dummy: FooIntersection + } + `); + }); + it('sort enum values', () => { const sorted = sortSDL(` enum Foo { diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts index d09153a2e68..07920e1c195 100644 --- a/src/utilities/__tests__/printSchema-test.ts +++ b/src/utilities/__tests__/printSchema-test.ts @@ -10,6 +10,7 @@ import { GraphQLEnumType, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -487,6 +488,63 @@ describe('Type System Printer', () => { `); }); + it('Print Intersections', () => { + const FooType = new GraphQLObjectType({ + name: 'Foo', + fields: { + bool: { type: GraphQLBoolean }, + }, + }); + + const FooUnion = new GraphQLUnionType({ + name: 'FooUnion', + types: [FooType], + }); + + const BarType = new GraphQLObjectType({ + name: 'Bar', + fields: { + str: { type: GraphQLString }, + }, + }); + + const BarUnion = new GraphQLUnionType({ + name: 'BarUnion', + types: [BarType], + }); + + const SingleIntersection = new GraphQLIntersectionType({ + name: 'SingleIntersection', + types: [FooUnion], + }); + + const MultipleIntersection = new GraphQLIntersectionType({ + name: 'MultipleIntersection', + types: [FooUnion, BarUnion], + }); + + const schema = new GraphQLSchema({ + types: [SingleIntersection, MultipleIntersection], + }); + expectPrintedSchema(schema).to.equal(dedent` + intersection SingleIntersection = FooUnion + + union FooUnion = Foo + + type Foo { + bool: Boolean + } + + intersection MultipleIntersection = FooUnion & BarUnion + + union BarUnion = Bar + + type Bar { + str: String + } + `); + }); + it('Print Input Type', () => { const InputType = new GraphQLInputObjectType({ name: 'InputType', @@ -701,7 +759,7 @@ describe('Type System Printer', () => { """ The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the \`__TypeKind\` enum. - Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional \`specifiedByURL\`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. + Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional \`specifiedByURL\`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union, Intersection and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. """ type __Type { kind: __TypeKind! @@ -710,6 +768,7 @@ describe('Type System Printer', () => { specifiedByURL: String fields(includeDeprecated: Boolean = false): [__Field!] interfaces: [__Type!] + memberTypes: [__Type!] possibleTypes: [__Type!] enumValues(includeDeprecated: Boolean = false): [__EnumValue!] inputFields(includeDeprecated: Boolean = false): [__InputValue!] @@ -734,6 +793,11 @@ describe('Type System Printer', () => { """Indicates this type is a union. \`possibleTypes\` is a valid field.""" UNION + """ + Indicates this type is an intersection. \`memberTypes\` and \`possibleTypes\` are valid fields. + """ + INTERSECTION + """Indicates this type is an enum. \`enumValues\` is a valid field.""" ENUM @@ -849,6 +913,9 @@ describe('Type System Printer', () => { """Location adjacent to a union definition.""" UNION + """Location adjacent to an intersection definition.""" + INTERSECTION + """Location adjacent to an enum definition.""" ENUM diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 18e6110b2bb..eda73ee0810 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -6,18 +6,21 @@ import { keyValMap } from '../jsutils/keyValMap'; import { parseValue } from '../language/parser'; import type { + GraphQLAbstractType, GraphQLFieldConfig, GraphQLFieldConfigMap, GraphQLNamedType, GraphQLType, } from '../type/definition'; import { + assertAbstractType, assertInterfaceType, assertNullableType, assertObjectType, GraphQLEnumType, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -39,6 +42,7 @@ import type { IntrospectionInputObjectType, IntrospectionInputValue, IntrospectionInterfaceType, + IntrospectionIntersectionType, IntrospectionNamedTypeRef, IntrospectionObjectType, IntrospectionQuery, @@ -168,6 +172,16 @@ export function buildClientSchema( return assertInterfaceType(getNamedType(typeRef)); } + function getAbstractType( + typeRef: IntrospectionNamedTypeRef< + | IntrospectionInterfaceType + | IntrospectionUnionType + | IntrospectionIntersectionType + >, + ): GraphQLAbstractType { + return assertAbstractType(getNamedType(typeRef)); + } + // Given a type's introspection result, construct the correct // GraphQLType instance. function buildType(type: IntrospectionType): GraphQLNamedType { @@ -184,6 +198,8 @@ export function buildClientSchema( return buildInterfaceDef(type); case TypeKind.UNION: return buildUnionDef(type); + case TypeKind.INTERSECTION: + return buildIntersectionDef(type); case TypeKind.ENUM: return buildEnumDef(type); case TypeKind.INPUT_OBJECT: @@ -268,6 +284,22 @@ export function buildClientSchema( }); } + function buildIntersectionDef( + intersectionIntrospection: IntrospectionIntersectionType, + ): GraphQLIntersectionType { + if (!intersectionIntrospection.memberTypes) { + const intersectionIntrospectionStr = inspect(intersectionIntrospection); + throw new Error( + `Introspection result missing memberTypes: ${intersectionIntrospectionStr}.`, + ); + } + return new GraphQLIntersectionType({ + name: intersectionIntrospection.name, + description: intersectionIntrospection.description, + types: () => intersectionIntrospection.memberTypes.map(getAbstractType), + }); + } + function buildEnumDef( enumIntrospection: IntrospectionEnumType, ): GraphQLEnumType { diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 998847e9f16..2e4f8f858c4 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -17,6 +17,8 @@ import type { InputValueDefinitionNode, InterfaceTypeDefinitionNode, InterfaceTypeExtensionNode, + IntersectionTypeDefinitionNode, + IntersectionTypeExtensionNode, NamedTypeNode, ObjectTypeDefinitionNode, ObjectTypeExtensionNode, @@ -36,6 +38,7 @@ import { } from '../language/predicates'; import type { + GraphQLAbstractType, GraphQLArgumentConfig, GraphQLEnumValueConfigMap, GraphQLFieldConfig, @@ -49,6 +52,7 @@ import { GraphQLEnumType, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -57,6 +61,7 @@ import { isEnumType, isInputObjectType, isInterfaceType, + isIntersectionType, isListType, isNonNullType, isObjectType, @@ -260,6 +265,9 @@ export function extendSchemaImpl( if (isUnionType(type)) { return extendUnionType(type); } + if (isIntersectionType(type)) { + return extendIntersectionType(type); + } if (isEnumType(type)) { return extendEnumType(type); } @@ -372,6 +380,22 @@ export function extendSchemaImpl( }); } + function extendIntersectionType( + type: GraphQLIntersectionType, + ): GraphQLIntersectionType { + const config = type.toConfig(); + const extensions = typeExtensionsMap[config.name] ?? []; + + return new GraphQLIntersectionType({ + ...config, + types: () => [ + ...type.getTypes().map(replaceNamedType), + ...buildIntersectionTypes(extensions), + ], + extensionASTNodes: config.extensionASTNodes.concat(extensions), + }); + } + function extendField( field: GraphQLFieldConfig, ): GraphQLFieldConfig { @@ -577,6 +601,21 @@ export function extendSchemaImpl( ); } + function buildIntersectionTypes( + nodes: ReadonlyArray< + IntersectionTypeDefinitionNode | IntersectionTypeExtensionNode + >, + ): Array { + // Note: While this could make assertions to get the correctly typed + // values below, that would throw immediately while type system + // validation with validateSchema() will produce more actionable results. + // @ts-expect-error + return nodes.flatMap( + // FIXME: https://github.com/graphql/graphql-js/issues/2203 + (node) => /* c8 ignore next */ node.types?.map(getNamedType) ?? [], + ); + } + function buildType(astNode: TypeDefinitionNode): GraphQLNamedType { const name = astNode.name.value; const extensionASTNodes = typeExtensionsMap[name] ?? []; @@ -628,6 +667,17 @@ export function extendSchemaImpl( extensionASTNodes, }); } + case Kind.INTERSECTION_TYPE_DEFINITION: { + const allNodes = [astNode, ...extensionASTNodes]; + + return new GraphQLIntersectionType({ + name, + description: astNode.description?.value, + types: () => buildIntersectionTypes(allNodes), + astNode, + extensionASTNodes, + }); + } case Kind.SCALAR_TYPE_DEFINITION: { return new GraphQLScalarType({ name, diff --git a/src/utilities/findBreakingChanges.ts b/src/utilities/findBreakingChanges.ts index 0bf0d453b46..fbf6f75d28f 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -10,6 +10,7 @@ import type { GraphQLInputObjectType, GraphQLInputType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLNamedType, GraphQLObjectType, GraphQLType, @@ -19,6 +20,7 @@ import { isEnumType, isInputObjectType, isInterfaceType, + isIntersectionType, isListType, isNamedType, isNonNullType, @@ -38,6 +40,7 @@ export enum BreakingChangeType { TYPE_REMOVED = 'TYPE_REMOVED', TYPE_CHANGED_KIND = 'TYPE_CHANGED_KIND', TYPE_REMOVED_FROM_UNION = 'TYPE_REMOVED_FROM_UNION', + TYPE_ADDED_TO_INTERSECTION = 'TYPE_ADDED_TO_INTERSECTION', VALUE_REMOVED_FROM_ENUM = 'VALUE_REMOVED_FROM_ENUM', REQUIRED_INPUT_FIELD_ADDED = 'REQUIRED_INPUT_FIELD_ADDED', IMPLEMENTED_INTERFACE_REMOVED = 'IMPLEMENTED_INTERFACE_REMOVED', @@ -56,6 +59,7 @@ export enum BreakingChangeType { export enum DangerousChangeType { VALUE_ADDED_TO_ENUM = 'VALUE_ADDED_TO_ENUM', TYPE_ADDED_TO_UNION = 'TYPE_ADDED_TO_UNION', + TYPE_REMOVED_FROM_INTERSECTION = 'TYPE_REMOVED_FROM_INTERSECTION', OPTIONAL_INPUT_FIELD_ADDED = 'OPTIONAL_INPUT_FIELD_ADDED', OPTIONAL_ARG_ADDED = 'OPTIONAL_ARG_ADDED', IMPLEMENTED_INTERFACE_ADDED = 'IMPLEMENTED_INTERFACE_ADDED', @@ -192,6 +196,8 @@ function findTypeChanges( schemaChanges.push(...findEnumTypeChanges(oldType, newType)); } else if (isUnionType(oldType) && isUnionType(newType)) { schemaChanges.push(...findUnionTypeChanges(oldType, newType)); + } else if (isIntersectionType(oldType) && isIntersectionType(newType)) { + schemaChanges.push(...findIntersectionTypeChanges(oldType, newType)); } else if (isInputObjectType(oldType) && isInputObjectType(newType)) { schemaChanges.push(...findInputObjectTypeChanges(oldType, newType)); } else if (isObjectType(oldType) && isObjectType(newType)) { @@ -290,6 +296,30 @@ function findUnionTypeChanges( return schemaChanges; } +function findIntersectionTypeChanges( + oldType: GraphQLIntersectionType, + newType: GraphQLIntersectionType, +): Array { + const schemaChanges = []; + const possibleTypesDiff = diff(oldType.getTypes(), newType.getTypes()); + + for (const newPossibleType of possibleTypesDiff.added) { + schemaChanges.push({ + type: BreakingChangeType.TYPE_ADDED_TO_INTERSECTION, + description: `${newPossibleType.name} was added to intersection type ${oldType.name}.`, + }); + } + + for (const oldPossibleType of possibleTypesDiff.removed) { + schemaChanges.push({ + type: DangerousChangeType.TYPE_REMOVED_FROM_INTERSECTION, + description: `${oldPossibleType.name} was removed from intersection type ${oldType.name}.`, + }); + } + + return schemaChanges; +} + function findEnumTypeChanges( oldType: GraphQLEnumType, newType: GraphQLEnumType, @@ -521,6 +551,9 @@ function typeKindName(type: GraphQLNamedType): string { if (isUnionType(type)) { return 'a Union type'; } + if (isIntersectionType(type)) { + return 'an Intersection type'; + } if (isEnumType(type)) { return 'an Enum type'; } diff --git a/src/utilities/getIntrospectionQuery.ts b/src/utilities/getIntrospectionQuery.ts index c21fe9a1bba..5d554229e67 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -114,6 +114,9 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { isDeprecated deprecationReason } + memberTypes { + ...TypeRef + } possibleTypes { ...TypeRef } @@ -185,6 +188,7 @@ export type IntrospectionType = | IntrospectionObjectType | IntrospectionInterfaceType | IntrospectionUnionType + | IntrospectionIntersectionType | IntrospectionEnumType | IntrospectionInputObjectType; @@ -193,6 +197,7 @@ export type IntrospectionOutputType = | IntrospectionObjectType | IntrospectionInterfaceType | IntrospectionUnionType + | IntrospectionIntersectionType | IntrospectionEnumType; export type IntrospectionInputType = @@ -234,6 +239,25 @@ export interface IntrospectionUnionType { readonly kind: 'UNION'; readonly name: string; readonly description?: Maybe; + readonly memberTypes: ReadonlyArray< + IntrospectionNamedTypeRef + >; + readonly possibleTypes: ReadonlyArray< + IntrospectionNamedTypeRef + >; +} + +export interface IntrospectionIntersectionType { + readonly kind: 'INTERSECTION'; + readonly name: string; + readonly description?: Maybe; + readonly memberTypes: ReadonlyArray< + IntrospectionNamedTypeRef< + | IntrospectionInterfaceType + | IntrospectionUnionType + | IntrospectionIntersectionType + > + >; readonly possibleTypes: ReadonlyArray< IntrospectionNamedTypeRef >; diff --git a/src/utilities/lexicographicSortSchema.ts b/src/utilities/lexicographicSortSchema.ts index 26b6908c9ff..152451d06e5 100644 --- a/src/utilities/lexicographicSortSchema.ts +++ b/src/utilities/lexicographicSortSchema.ts @@ -16,6 +16,7 @@ import { GraphQLEnumType, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLList, GraphQLNonNull, GraphQLObjectType, @@ -23,6 +24,7 @@ import { isEnumType, isInputObjectType, isInterfaceType, + isIntersectionType, isListType, isNonNullType, isObjectType, @@ -141,6 +143,13 @@ export function lexicographicSortSchema(schema: GraphQLSchema): GraphQLSchema { types: () => sortTypes(config.types), }); } + if (isIntersectionType(type)) { + const config = type.toConfig(); + return new GraphQLIntersectionType({ + ...config, + types: () => sortTypes(config.types), + }); + } if (isEnumType(type)) { const config = type.toConfig(); return new GraphQLEnumType({ diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index 83859feee8a..87abbe6ebd5 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -12,6 +12,7 @@ import type { GraphQLInputField, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLIntersectionType, GraphQLNamedType, GraphQLObjectType, GraphQLScalarType, @@ -21,6 +22,7 @@ import { isEnumType, isInputObjectType, isInterfaceType, + isIntersectionType, isObjectType, isScalarType, isUnionType, @@ -141,6 +143,9 @@ export function printType(type: GraphQLNamedType): string { if (isUnionType(type)) { return printUnion(type); } + if (isIntersectionType(type)) { + return printIntersection(type); + } if (isEnumType(type)) { return printEnum(type); } @@ -191,6 +196,12 @@ function printUnion(type: GraphQLUnionType): string { return printDescription(type) + 'union ' + type.name + possibleTypes; } +function printIntersection(type: GraphQLIntersectionType): string { + const types = type.getTypes(); + const possibleTypes = types.length ? ' = ' + types.join(' & ') : ''; + return printDescription(type) + 'intersection ' + type.name + possibleTypes; +} + function printEnum(type: GraphQLEnumType): string { const values = type .getValues() diff --git a/src/utilities/typeComparators.ts b/src/utilities/typeComparators.ts index 287be40bfe9..cc97f286a7b 100644 --- a/src/utilities/typeComparators.ts +++ b/src/utilities/typeComparators.ts @@ -2,6 +2,7 @@ import type { GraphQLCompositeType, GraphQLType } from '../type/definition'; import { isAbstractType, isInterfaceType, + isIntersectionType, isListType, isNonNullType, isObjectType, @@ -73,7 +74,9 @@ export function isTypeSubTypeOf( // Otherwise, the child type is not a valid subtype of the parent type. return ( isAbstractType(superType) && - (isInterfaceType(maybeSubType) || isObjectType(maybeSubType)) && + (isInterfaceType(maybeSubType) || + isObjectType(maybeSubType) || + isIntersectionType(maybeSubType)) && schema.isSubType(superType, maybeSubType) ); } diff --git a/src/validation/__tests__/KnownDirectivesRule-test.ts b/src/validation/__tests__/KnownDirectivesRule-test.ts index 4cb6e225c1c..8f6696a2bdf 100644 --- a/src/validation/__tests__/KnownDirectivesRule-test.ts +++ b/src/validation/__tests__/KnownDirectivesRule-test.ts @@ -54,6 +54,7 @@ const schemaWithSDLDirectives = buildSchema(` directive @onArgumentDefinition on ARGUMENT_DEFINITION directive @onInterface on INTERFACE directive @onUnion on UNION + directive @onIntersection on INTERSECTION directive @onEnum on ENUM directive @onEnumValue on ENUM_VALUE directive @onInputObject on INPUT_OBJECT @@ -332,6 +333,10 @@ describe('Validate: Known directives', () => { extend union MyUnion @onUnion + intersection MyIntersection @onIntersection = MyUnion + + extend intersection MyIntersection @onIntersection + enum MyEnum @onEnum { MY_VALUE @onEnumValue } @@ -369,11 +374,13 @@ describe('Validate: Known directives', () => { union MyUnion @onEnumValue = MyObj | Other + intersection MyIntersection @onUnion = MyUnion + enum MyEnum @onScalar { MY_VALUE @onUnion } - input MyInput @onEnum { + input MyInput @onIntersection { myField: Int @onArgumentDefinition } @@ -421,30 +428,35 @@ describe('Validate: Known directives', () => { message: 'Directive "@onEnumValue" may not be used on UNION.', locations: [{ line: 12, column: 25 }], }, + { + message: 'Directive "@onUnion" may not be used on INTERSECTION.', + locations: [{ line: 14, column: 39 }], + }, { message: 'Directive "@onScalar" may not be used on ENUM.', - locations: [{ line: 14, column: 23 }], + locations: [{ line: 16, column: 23 }], }, { message: 'Directive "@onUnion" may not be used on ENUM_VALUE.', - locations: [{ line: 15, column: 22 }], + locations: [{ line: 17, column: 22 }], }, { - message: 'Directive "@onEnum" may not be used on INPUT_OBJECT.', - locations: [{ line: 18, column: 25 }], + message: + 'Directive "@onIntersection" may not be used on INPUT_OBJECT.', + locations: [{ line: 20, column: 25 }], }, { message: 'Directive "@onArgumentDefinition" may not be used on INPUT_FIELD_DEFINITION.', - locations: [{ line: 19, column: 26 }], + locations: [{ line: 21, column: 26 }], }, { message: 'Directive "@onObject" may not be used on SCHEMA.', - locations: [{ line: 22, column: 18 }], + locations: [{ line: 24, column: 18 }], }, { message: 'Directive "@onObject" may not be used on SCHEMA.', - locations: [{ line: 26, column: 25 }], + locations: [{ line: 28, column: 25 }], }, ]); }); diff --git a/src/validation/__tests__/PossibleFragmentSpreadsRule-test.ts b/src/validation/__tests__/PossibleFragmentSpreadsRule-test.ts index 3e52f234b59..7c134cd2a52 100644 --- a/src/validation/__tests__/PossibleFragmentSpreadsRule-test.ts +++ b/src/validation/__tests__/PossibleFragmentSpreadsRule-test.ts @@ -37,8 +37,19 @@ const testSchema = buildSchema(` meowVolume: Int } + type Bird implements Being & Pet { + name: String + chirpVolume: Int + } + union CatOrDog = Cat | Dog + union CatOrBird = Cat | Bird + + intersection CatOrDogPet = CatOrDog & Being & Pet + + intersection CatOrBirdPet = CatOrBird & Being & Pet + interface Intelligent { iq: Int } @@ -58,6 +69,8 @@ const testSchema = buildSchema(` union HumanOrAlien = Human | Alien + intersection HumanOrAlienIntelligent = HumanOrAlien & Intelligent + type Query { catOrDog: CatOrDog dogOrHuman: DogOrHuman @@ -93,6 +106,13 @@ describe('Validate: Possible fragment spreads', () => { `); }); + it('object into containing intersection', () => { + expectValid(` + fragment objectWithinIntersection on CatOrDogPet { ...dogFragment } + fragment dogFragment on Dog { barkVolume } + `); + }); + it('union into contained object', () => { expectValid(` fragment unionWithinObject on Dog { ...catOrDogFragment } @@ -114,6 +134,34 @@ describe('Validate: Possible fragment spreads', () => { `); }); + it('intersection into contained object', () => { + expectValid(` + fragment intersectionWithinObject on Dog { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { __typename } + `); + }); + + it('intersection into overlapping interface', () => { + expectValid(` + fragment intersectionWithinInterface on Pet { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { __typename } + `); + }); + + it('intersection into overlapping union', () => { + expectValid(` + fragment intersectionWithinUnion on DogOrHuman { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { __typename } + `); + }); + + it('intersection into overlapping intersection', () => { + expectValid(` + fragment intersectionWithinIntersection on CatOrBirdPet { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { __typename } + `); + }); + it('interface into implemented object', () => { expectValid(` fragment interfaceWithinObject on Dog { ...petFragment } @@ -141,6 +189,13 @@ describe('Validate: Possible fragment spreads', () => { `); }); + it('interface into overlapping intersection', () => { + expectValid(` + fragment interfaceWithinIntersection on CatOrDogPet { ...petFragment } + fragment petFragment on Pet { name } + `); + }); + it('ignores incorrect type (caught by FragmentsOnCompositeTypesRule)', () => { expectValid(` fragment petFragment on Pet { ...badInADifferentWay } @@ -207,6 +262,19 @@ describe('Validate: Possible fragment spreads', () => { ]); }); + it('object into not containing intersection', () => { + expectErrors(` + fragment invalidObjectWithinIntersection on CatOrDogPet { ...humanFragment } + fragment humanFragment on Human { pets { name } } + `).toDeepEqual([ + { + message: + 'Fragment "humanFragment" cannot be spread here as objects of type "CatOrDogPet" can never be of type "Human".', + locations: [{ line: 2, column: 65 }], + }, + ]); + }); + it('union into not contained object', () => { expectErrors(` fragment invalidUnionWithinObject on Human { ...catOrDogFragment } @@ -246,6 +314,58 @@ describe('Validate: Possible fragment spreads', () => { ]); }); + it('intersection into not contained object', () => { + expectErrors(` + fragment invalidIntersectionWithinObject on Human { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { __typename } + `).toDeepEqual([ + { + message: + 'Fragment "catOrDogPetFragment" cannot be spread here as objects of type "Human" can never be of type "CatOrDogPet".', + locations: [{ line: 2, column: 59 }], + }, + ]); + }); + + it('intersection into non overlapping interface', () => { + expectErrors(` + fragment invalidIntersectionWithinInterface on Intelligent { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { __typename } + `).toDeepEqual([ + { + message: + 'Fragment "catOrDogPetFragment" cannot be spread here as objects of type "Intelligent" can never be of type "CatOrDogPet".', + locations: [{ line: 2, column: 68 }], + }, + ]); + }); + + it('intersection into non overlapping union', () => { + expectErrors(` + fragment invalidIntersectionWithinUnion on HumanOrAlien { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { __typename } + `).toDeepEqual([ + { + message: + 'Fragment "catOrDogPetFragment" cannot be spread here as objects of type "HumanOrAlien" can never be of type "CatOrDogPet".', + locations: [{ line: 2, column: 65 }], + }, + ]); + }); + + it('intersection into non overlapping intersection', () => { + expectErrors(` + fragment invalidIntersectionWithinIntersection on HumanOrAlienIntelligent { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { __typename } + `).toDeepEqual([ + { + message: + 'Fragment "catOrDogPetFragment" cannot be spread here as objects of type "HumanOrAlienIntelligent" can never be of type "CatOrDogPet".', + locations: [{ line: 2, column: 83 }], + }, + ]); + }); + it('interface into non implementing object', () => { expectErrors(` fragment invalidInterfaceWithinObject on Cat { ...intelligentFragment } @@ -300,4 +420,17 @@ describe('Validate: Possible fragment spreads', () => { }, ]); }); + + it('interface into non overlapping intersection', () => { + expectErrors(` + fragment invalidInterfaceWithinIntersection on HumanOrAlien { ...catOrDogPetFragment } + fragment catOrDogPetFragment on CatOrDogPet { name } + `).toDeepEqual([ + { + message: + 'Fragment "catOrDogPetFragment" cannot be spread here as objects of type "HumanOrAlien" can never be of type "CatOrDogPet".', + locations: [{ line: 2, column: 69 }], + }, + ]); + }); }); diff --git a/src/validation/__tests__/PossibleTypeExtensionsRule-test.ts b/src/validation/__tests__/PossibleTypeExtensionsRule-test.ts index e29c097bdbf..c50933f3b02 100644 --- a/src/validation/__tests__/PossibleTypeExtensionsRule-test.ts +++ b/src/validation/__tests__/PossibleTypeExtensionsRule-test.ts @@ -23,6 +23,7 @@ describe('Validate: Possible type extensions', () => { type FooObject interface FooInterface union FooUnion + intersection FooIntersection enum FooEnum input FooInputObject `); @@ -34,6 +35,7 @@ describe('Validate: Possible type extensions', () => { type FooObject interface FooInterface union FooUnion + intersection FooIntersection enum FooEnum input FooInputObject @@ -41,6 +43,7 @@ describe('Validate: Possible type extensions', () => { extend type FooObject @dummy extend interface FooInterface @dummy extend union FooUnion @dummy + extend intersection FooIntersection @dummy extend enum FooEnum @dummy extend input FooInputObject @dummy `); @@ -52,6 +55,7 @@ describe('Validate: Possible type extensions', () => { type FooObject interface FooInterface union FooUnion + intersection FooIntersection enum FooEnum input FooInputObject @@ -59,6 +63,7 @@ describe('Validate: Possible type extensions', () => { extend type FooObject @dummy extend interface FooInterface @dummy extend union FooUnion @dummy + extend intersection FooIntersection @dummy extend enum FooEnum @dummy extend input FooInputObject @dummy @@ -66,6 +71,7 @@ describe('Validate: Possible type extensions', () => { extend type FooObject @dummy extend interface FooInterface @dummy extend union FooUnion @dummy + extend intersection FooIntersection @dummy extend enum FooEnum @dummy extend input FooInputObject @dummy `); @@ -82,6 +88,7 @@ describe('Validate: Possible type extensions', () => { extend type Unknown @dummy extend interface Unknown @dummy extend union Unknown @dummy + extend intersection Unknown @dummy extend enum Unknown @dummy extend input Unknown @dummy `).toDeepEqual([ @@ -89,8 +96,9 @@ describe('Validate: Possible type extensions', () => { { message, locations: [{ line: 5, column: 19 }] }, { message, locations: [{ line: 6, column: 24 }] }, { message, locations: [{ line: 7, column: 20 }] }, - { message, locations: [{ line: 8, column: 19 }] }, - { message, locations: [{ line: 9, column: 20 }] }, + { message, locations: [{ line: 8, column: 27 }] }, + { message, locations: [{ line: 9, column: 19 }] }, + { message, locations: [{ line: 10, column: 20 }] }, ]); }); @@ -106,6 +114,7 @@ describe('Validate: Possible type extensions', () => { extend type Foo @dummy extend interface Foo @dummy extend union Foo @dummy + extend intersection Foo @dummy extend enum Foo @dummy extend input Foo @dummy `).toDeepEqual([ @@ -113,8 +122,9 @@ describe('Validate: Possible type extensions', () => { { message, locations: [{ line: 7, column: 19 }] }, { message, locations: [{ line: 8, column: 24 }] }, { message, locations: [{ line: 9, column: 20 }] }, - { message, locations: [{ line: 10, column: 19 }] }, - { message, locations: [{ line: 11, column: 20 }] }, + { message, locations: [{ line: 10, column: 27 }] }, + { message, locations: [{ line: 11, column: 19 }] }, + { message, locations: [{ line: 12, column: 20 }] }, ]); }); @@ -124,13 +134,15 @@ describe('Validate: Possible type extensions', () => { type FooObject interface FooInterface union FooUnion + intersection FooIntersection enum FooEnum input FooInputObject extend type FooScalar @dummy extend interface FooObject @dummy extend union FooInterface @dummy - extend enum FooUnion @dummy + extend intersection FooUnion @dummy + extend enum FooIntersection @dummy extend input FooEnum @dummy extend scalar FooInputObject @dummy `).toDeepEqual([ @@ -138,42 +150,49 @@ describe('Validate: Possible type extensions', () => { message: 'Cannot extend non-object type "FooScalar".', locations: [ { line: 2, column: 7 }, - { line: 9, column: 7 }, + { line: 10, column: 7 }, ], }, { message: 'Cannot extend non-interface type "FooObject".', locations: [ { line: 3, column: 7 }, - { line: 10, column: 7 }, + { line: 11, column: 7 }, ], }, { message: 'Cannot extend non-union type "FooInterface".', locations: [ { line: 4, column: 7 }, - { line: 11, column: 7 }, + { line: 12, column: 7 }, ], }, { - message: 'Cannot extend non-enum type "FooUnion".', + message: 'Cannot extend non-intersection type "FooUnion".', locations: [ { line: 5, column: 7 }, - { line: 12, column: 7 }, + { line: 13, column: 7 }, ], }, { - message: 'Cannot extend non-input object type "FooEnum".', + message: 'Cannot extend non-enum type "FooIntersection".', locations: [ { line: 6, column: 7 }, - { line: 13, column: 7 }, + { line: 14, column: 7 }, ], }, { - message: 'Cannot extend non-scalar type "FooInputObject".', + message: 'Cannot extend non-input object type "FooEnum".', locations: [ { line: 7, column: 7 }, - { line: 14, column: 7 }, + { line: 15, column: 7 }, + ], + }, + { + message: 'Cannot extend non-scalar type "FooInputObject".', + locations: [ + { line: 8, column: 7 }, + { line: 16, column: 7 }, ], }, ]); @@ -185,6 +204,7 @@ describe('Validate: Possible type extensions', () => { type FooObject interface FooInterface union FooUnion + intersection FooIntersection enum FooEnum input FooInputObject `); @@ -193,6 +213,7 @@ describe('Validate: Possible type extensions', () => { extend type FooObject @dummy extend interface FooInterface @dummy extend union FooUnion @dummy + extend intersection FooIntersection @dummy extend enum FooEnum @dummy extend input FooInputObject @dummy `; @@ -207,6 +228,7 @@ describe('Validate: Possible type extensions', () => { extend type Unknown @dummy extend interface Unknown @dummy extend union Unknown @dummy + extend intersection Unknown @dummy extend enum Unknown @dummy extend input Unknown @dummy `; @@ -218,8 +240,9 @@ describe('Validate: Possible type extensions', () => { { message, locations: [{ line: 3, column: 19 }] }, { message, locations: [{ line: 4, column: 24 }] }, { message, locations: [{ line: 5, column: 20 }] }, - { message, locations: [{ line: 6, column: 19 }] }, - { message, locations: [{ line: 7, column: 20 }] }, + { message, locations: [{ line: 6, column: 27 }] }, + { message, locations: [{ line: 7, column: 19 }] }, + { message, locations: [{ line: 8, column: 20 }] }, ]); }); @@ -229,6 +252,7 @@ describe('Validate: Possible type extensions', () => { type FooObject interface FooInterface union FooUnion + intersection FooIntersection enum FooEnum input FooInputObject `); @@ -236,7 +260,8 @@ describe('Validate: Possible type extensions', () => { extend type FooScalar @dummy extend interface FooObject @dummy extend union FooInterface @dummy - extend enum FooUnion @dummy + extend intersection FooUnion @dummy + extend enum FooIntersection @dummy extend input FooEnum @dummy extend scalar FooInputObject @dummy `; @@ -255,17 +280,21 @@ describe('Validate: Possible type extensions', () => { locations: [{ line: 4, column: 7 }], }, { - message: 'Cannot extend non-enum type "FooUnion".', + message: 'Cannot extend non-intersection type "FooUnion".', locations: [{ line: 5, column: 7 }], }, { - message: 'Cannot extend non-input object type "FooEnum".', + message: 'Cannot extend non-enum type "FooIntersection".', locations: [{ line: 6, column: 7 }], }, { - message: 'Cannot extend non-scalar type "FooInputObject".', + message: 'Cannot extend non-input object type "FooEnum".', locations: [{ line: 7, column: 7 }], }, + { + message: 'Cannot extend non-scalar type "FooInputObject".', + locations: [{ line: 8, column: 7 }], + }, ]); }); }); diff --git a/src/validation/__tests__/harness.ts b/src/validation/__tests__/harness.ts index 661256c56dd..24e967abbab 100644 --- a/src/validation/__tests__/harness.ts +++ b/src/validation/__tests__/harness.ts @@ -55,6 +55,8 @@ export const testSchema: GraphQLSchema = buildSchema(` union CatOrDog = Cat | Dog + intersection CatOrDogPet = CatOrDog & Pet + type Human { name(surname: Boolean): String pets: [Pet] diff --git a/src/validation/rules/KnownDirectivesRule.ts b/src/validation/rules/KnownDirectivesRule.ts index f24dbe7d281..5b00eef594d 100644 --- a/src/validation/rules/KnownDirectivesRule.ts +++ b/src/validation/rules/KnownDirectivesRule.ts @@ -105,6 +105,9 @@ function getDirectiveLocationForASTPath( case Kind.UNION_TYPE_DEFINITION: case Kind.UNION_TYPE_EXTENSION: return DirectiveLocation.UNION; + case Kind.INTERSECTION_TYPE_DEFINITION: + case Kind.INTERSECTION_TYPE_EXTENSION: + return DirectiveLocation.INTERSECTION; case Kind.ENUM_TYPE_DEFINITION: case Kind.ENUM_TYPE_EXTENSION: return DirectiveLocation.ENUM; diff --git a/src/validation/rules/PossibleTypeExtensionsRule.ts b/src/validation/rules/PossibleTypeExtensionsRule.ts index 57d16b473fe..207763886da 100644 --- a/src/validation/rules/PossibleTypeExtensionsRule.ts +++ b/src/validation/rules/PossibleTypeExtensionsRule.ts @@ -16,6 +16,7 @@ import { isEnumType, isInputObjectType, isInterfaceType, + isIntersectionType, isObjectType, isScalarType, isUnionType, @@ -45,6 +46,7 @@ export function PossibleTypeExtensionsRule( ObjectTypeExtension: checkExtension, InterfaceTypeExtension: checkExtension, UnionTypeExtension: checkExtension, + IntersectionTypeExtension: checkExtension, EnumTypeExtension: checkExtension, InputObjectTypeExtension: checkExtension, }; @@ -93,6 +95,7 @@ const defKindToExtKind: ObjMap = { [Kind.OBJECT_TYPE_DEFINITION]: Kind.OBJECT_TYPE_EXTENSION, [Kind.INTERFACE_TYPE_DEFINITION]: Kind.INTERFACE_TYPE_EXTENSION, [Kind.UNION_TYPE_DEFINITION]: Kind.UNION_TYPE_EXTENSION, + [Kind.INTERSECTION_TYPE_DEFINITION]: Kind.INTERSECTION_TYPE_EXTENSION, [Kind.ENUM_TYPE_DEFINITION]: Kind.ENUM_TYPE_EXTENSION, [Kind.INPUT_OBJECT_TYPE_DEFINITION]: Kind.INPUT_OBJECT_TYPE_EXTENSION, }; @@ -110,6 +113,9 @@ function typeToExtKind(type: GraphQLNamedType): Kind { if (isUnionType(type)) { return Kind.UNION_TYPE_EXTENSION; } + if (isIntersectionType(type)) { + return Kind.INTERSECTION_TYPE_EXTENSION; + } if (isEnumType(type)) { return Kind.ENUM_TYPE_EXTENSION; } @@ -131,6 +137,8 @@ function extensionKindToTypeName(kind: Kind): string { return 'interface'; case Kind.UNION_TYPE_EXTENSION: return 'union'; + case Kind.INTERSECTION_TYPE_EXTENSION: + return 'intersection'; case Kind.ENUM_TYPE_EXTENSION: return 'enum'; case Kind.INPUT_OBJECT_TYPE_EXTENSION: