Skip to content

Commit

Permalink
feat: allow interfaces to implement other interfaces (#496)
Browse files Browse the repository at this point in the history
closes #389
  • Loading branch information
santialbo committed Oct 26, 2020
1 parent 10c5f8b commit 9bfdf2c
Show file tree
Hide file tree
Showing 6 changed files with 231 additions and 39 deletions.
101 changes: 82 additions & 19 deletions src/builder.ts
Expand Up @@ -52,16 +52,12 @@ import { NexusExtendInputTypeConfig, NexusExtendInputTypeDef } from './definitio
import { NexusExtendTypeConfig, NexusExtendTypeDef } from './definitions/extendType'
import { NexusInputObjectTypeConfig } from './definitions/inputObjectType'
import {
Implemented,
InterfaceDefinitionBlock,
NexusInterfaceTypeConfig,
NexusInterfaceTypeDef,
} from './definitions/interfaceType'
import {
Implemented,
NexusObjectTypeConfig,
NexusObjectTypeDef,
ObjectDefinitionBlock,
} from './definitions/objectType'
import { NexusObjectTypeConfig, NexusObjectTypeDef, ObjectDefinitionBlock } from './definitions/objectType'
import { NexusScalarExtensions, NexusScalarTypeConfig } from './definitions/scalarType'
import { NexusUnionTypeConfig, UnionDefinitionBlock, UnionMembers } from './definitions/unionType'
import {
Expand Down Expand Up @@ -701,6 +697,51 @@ export class SchemaBuilder {
})
}

checkForInterfaceCircularDependencies() {
const interfaces: Record<string, NexusInterfaceTypeConfig<any>> = {}
Object.keys(this.pendingTypeMap)
.map((key) => this.pendingTypeMap[key])
.filter(isNexusInterfaceTypeDef)
.forEach((type) => {
interfaces[type.name] = type.value
})
const alreadyChecked: Record<string, boolean> = {}
function walkType(obj: NexusInterfaceTypeConfig<any>, path: string[], visited: Record<string, boolean>) {
if (alreadyChecked[obj.name]) {
return
}
if (visited[obj.name]) {
if (obj.name === path[path.length - 1]) {
throw new Error(`GraphQL Nexus: Interface ${obj.name} can't implement itself`)
} else {
throw new Error(
`GraphQL Nexus: Interface circular dependency detected ${[
...path.slice(path.lastIndexOf(obj.name)),
obj.name,
].join(' -> ')}`
)
}
}
const definitionBlock = new InterfaceDefinitionBlock({
typeName: obj.name,
addInterfaces: (i) =>
i.forEach((config) => {
const name = typeof config === 'string' ? config : config.value.name
walkType(interfaces[name], [...path, obj.name], { ...visited, [obj.name]: true })
}),
setResolveType: () => {},
addField: () => {},
addDynamicOutputMembers: () => {},
warn: () => {},
})
obj.definition(definitionBlock)
alreadyChecked[obj.name] = true
}
Object.keys(interfaces).forEach((name) => {
walkType(interfaces[name], [], {})
})
}

buildNexusTypes() {
// If Query isn't defined, set it to null so it falls through to "missingType"
if (!this.pendingTypeMap.Query) {
Expand Down Expand Up @@ -758,6 +799,7 @@ export class SchemaBuilder {
this.createSchemaExtension()
this.walkTypes()
this.beforeBuildTypes()
this.checkForInterfaceCircularDependencies()
this.buildNexusTypes()
return {
finalConfig: this.config,
Expand Down Expand Up @@ -824,19 +866,9 @@ export class SchemaBuilder {
}
const objectTypeConfig: NexusGraphQLObjectTypeConfig = {
name: config.name,
interfaces: () => interfaces.map((i) => this.getInterface(i)),
interfaces: () => this.buildInterfaceList(interfaces),
description: config.description,
fields: () => {
const allInterfaces = interfaces.map((i) => this.getInterface(i))
const interfaceConfigs = allInterfaces.map((i) => i.toConfig())
const interfaceFieldsMap: GraphQLFieldConfigMap<any, any> = {}
interfaceConfigs.forEach((i) => {
Object.keys(i.fields).forEach((iFieldName) => {
interfaceFieldsMap[iFieldName] = i.fields[iFieldName]
})
})
return this.buildOutputFields(fields, objectTypeConfig, interfaceFieldsMap)
},
fields: () => this.buildOutputFields(fields, objectTypeConfig, this.buildInterfaceFields(interfaces)),
extensions: {
nexus: new NexusObjectTypeExtension(config),
},
Expand All @@ -848,9 +880,11 @@ export class SchemaBuilder {
const { name, description } = config
let resolveType: AbstractTypeResolver<string> | undefined
const fields: NexusOutputFieldDef[] = []
const interfaces: Implemented[] = []
const definitionBlock = new InterfaceDefinitionBlock({
typeName: config.name,
addField: (field) => fields.push(field),
addInterfaces: (interfaceDefs) => interfaces.push(...interfaceDefs),
setResolveType: (fn) => (resolveType = fn),
addDynamicOutputMembers: (block, isList) => this.addDynamicOutputMembers(block, isList, 'build'),
warn: consoleWarn,
Expand All @@ -870,9 +904,11 @@ export class SchemaBuilder {
}
const interfaceTypeConfig: NexusGraphQLInterfaceTypeConfig = {
name,
interfaces: () => this.buildInterfaceList(interfaces),
resolveType,
description,
fields: () => this.buildOutputFields(fields, interfaceTypeConfig, {}),
fields: () =>
this.buildOutputFields(fields, interfaceTypeConfig, this.buildInterfaceFields(interfaces)),
extensions: {
nexus: new NexusInterfaceTypeExtension(config),
},
Expand Down Expand Up @@ -1013,6 +1049,26 @@ export class SchemaBuilder {
return unionMembers
}

protected buildInterfaceList(interfaces: (string | NexusInterfaceTypeDef<any>)[]) {
const list: GraphQLInterfaceType[] = []
interfaces.forEach((i) => {
const type = this.getInterface(i)
list.push(type, ...type.getInterfaces())
})
return list
}

protected buildInterfaceFields(interfaces: (string | NexusInterfaceTypeDef<any>)[]) {
const interfaceFieldsMap: GraphQLFieldConfigMap<any, any> = {}
interfaces.forEach((i) => {
const config = this.getInterface(i).toConfig()
Object.keys(config.fields).forEach((field) => {
interfaceFieldsMap[field] = config.fields[field]
})
})
return interfaceFieldsMap
}

protected buildOutputFields(
fields: NexusOutputFieldDef[],
typeConfig: NexusGraphQLInterfaceTypeConfig | NexusGraphQLObjectTypeConfig,
Expand Down Expand Up @@ -1380,6 +1436,13 @@ export class SchemaBuilder {
protected walkInterfaceType(obj: NexusInterfaceTypeConfig<any>) {
const definitionBlock = new InterfaceDefinitionBlock({
typeName: obj.name,
addInterfaces: (i) => {
i.forEach((j) => {
if (typeof j !== 'string') {
this.addType(j)
}
})
},
setResolveType: () => {},
addField: (f) => this.maybeTraverseOutputFieldType(f),
addDynamicOutputMembers: (block, isList) => this.addDynamicOutputMembers(block, isList, 'walk'),
Expand Down
14 changes: 1 addition & 13 deletions src/definitions/definitionBlocks.ts
@@ -1,13 +1,5 @@
import { GraphQLFieldResolver } from 'graphql'
import {
AbstractTypeResolver,
AllInputTypes,
FieldResolver,
GetGen,
GetGen3,
HasGen3,
NeedsResolver,
} from '../typegenTypeHelpers'
import { AllInputTypes, FieldResolver, GetGen, GetGen3, HasGen3, NeedsResolver } from '../typegenTypeHelpers'
import { ArgsRecord } from './args'
import { AllNexusInputTypeDefs, AllNexusOutputTypeDefs } from './wrapping'
import { BaseScalars } from './_types'
Expand Down Expand Up @@ -285,7 +277,3 @@ export class InputDefinitionBlock<TypeName extends string> {
return config
}
}

export interface AbstractOutputDefinitionBuilder<TypeName extends string> extends OutputDefinitionBuilder {
setResolveType(fn: AbstractTypeResolver<TypeName>): void
}
19 changes: 16 additions & 3 deletions src/definitions/interfaceType.ts
@@ -1,8 +1,10 @@
import { assertValidName } from 'graphql'
import { AbstractTypeResolver } from '../typegenTypeHelpers'
import { AbstractOutputDefinitionBuilder, OutputDefinitionBlock } from './definitionBlocks'
import { AbstractTypeResolver, GetGen } from '../typegenTypeHelpers'
import { OutputDefinitionBlock, OutputDefinitionBuilder } from './definitionBlocks'
import { NexusTypes, NonNullConfig, RootTypingDef, withNexusSymbol } from './_types'

export type Implemented = GetGen<'interfaceNames'> | NexusInterfaceTypeDef<any>

export type NexusInterfaceTypeConfig<TypeName extends string> = {
name: TypeName

Expand Down Expand Up @@ -31,8 +33,13 @@ export type NexusInterfaceTypeConfig<TypeName extends string> = {
rootTyping?: RootTypingDef
}

export interface InterfaceDefinitionBuilder<TypeName extends string> extends OutputDefinitionBuilder {
setResolveType(fn: AbstractTypeResolver<TypeName>): void
addInterfaces(toAdd: Implemented[]): void
}

export class InterfaceDefinitionBlock<TypeName extends string> extends OutputDefinitionBlock<TypeName> {
constructor(protected typeBuilder: AbstractOutputDefinitionBuilder<TypeName>) {
constructor(protected typeBuilder: InterfaceDefinitionBuilder<TypeName>) {
super(typeBuilder)
}
/**
Expand All @@ -41,6 +48,12 @@ export class InterfaceDefinitionBlock<TypeName extends string> extends OutputDef
resolveType(fn: AbstractTypeResolver<TypeName>) {
this.typeBuilder.setResolveType(fn)
}
/**
* @param interfaceName
*/
implements(...interfaceName: Array<Implemented>) {
this.typeBuilder.addInterfaces(interfaceName)
}
}

export class NexusInterfaceTypeDef<TypeName extends string> {
Expand Down
6 changes: 2 additions & 4 deletions src/definitions/objectType.ts
@@ -1,11 +1,9 @@
import { assertValidName } from 'graphql'
import { FieldResolver, GetGen } from '../typegenTypeHelpers'
import { FieldResolver } from '../typegenTypeHelpers'
import { OutputDefinitionBlock, OutputDefinitionBuilder } from './definitionBlocks'
import { NexusInterfaceTypeDef } from './interfaceType'
import { Implemented } from './interfaceType'
import { NexusTypes, NonNullConfig, Omit, RootTypingDef, withNexusSymbol } from './_types'

export type Implemented = GetGen<'interfaceNames'> | NexusInterfaceTypeDef<any>

export interface FieldModification<TypeName extends string, FieldName extends string> {
/**
* The description to annotate the GraphQL SDL
Expand Down
17 changes: 17 additions & 0 deletions tests/__snapshots__/interfaceType.spec.ts.snap
Expand Up @@ -11,6 +11,23 @@ Object {
}
`;

exports[`interfaceType can extend other interfaces 1`] = `
Object {
"data": Object {
"dog": Object {
"breed": "Puli",
"classification": "Canis familiaris",
"owner": "Mark",
"type": "Animal",
},
},
}
`;

exports[`interfaceType can not implement itself 1`] = `"GraphQL Nexus: Interface Node can't implement itself"`;

exports[`interfaceType detects circular dependencies 1`] = `"GraphQL Nexus: Interface circular dependency detected NodeA -> NodeC -> NodeB -> NodeA"`;

exports[`interfaceType logs error when resolveType is not provided for an interface 1`] = `
Array [
[Error: Missing resolveType for the Node interface. Be sure to add one in the definition block for the type, or t.resolveType(() => null) if you don't want or need to implement.],
Expand Down
113 changes: 113 additions & 0 deletions tests/interfaceType.spec.ts
Expand Up @@ -45,6 +45,119 @@ describe('interfaceType', () => {
)
).toMatchSnapshot()
})
it('can extend other interfaces', async () => {
const schema = makeSchema({
types: [
interfaceType({
name: 'LivingOrganism',
definition(t) {
t.string('type')
t.resolveType(() => null)
},
}),
interfaceType({
name: 'Animal',
definition(t) {
t.implements('LivingOrganism')
t.string('classification')
t.resolveType(() => null)
},
}),
interfaceType({
name: 'Pet',
definition(t) {
t.implements('Animal')
t.string('owner')
t.resolveType(() => null)
},
}),
objectType({
name: 'Dog',
definition(t) {
t.implements('Pet')
t.string('breed')
},
}),
queryField('dog', {
type: 'Dog',
resolve: () => ({
type: 'Animal',
classification: 'Canis familiaris',
owner: 'Mark',
breed: 'Puli',
}),
}),
],
outputs: {
schema: path.join(__dirname, 'interfaceTypeTest.graphql'),
typegen: false,
},
shouldGenerateArtifacts: false,
})
expect(
await graphql(
schema,
`
{
dog {
type
classification
owner
breed
}
}
`
)
).toMatchSnapshot()
})
it('can not implement itself', async () => {
expect(() =>
makeSchema({
types: [
interfaceType({
name: 'Node',
definition(t) {
t.id('id')
t.implements('Node')
},
}),
],
outputs: false,
shouldGenerateArtifacts: false,
})
).toThrowErrorMatchingSnapshot()
})
it('detects circular dependencies', async () => {
expect(() =>
makeSchema({
types: [
interfaceType({
name: 'NodeA',
definition(t) {
t.id('a')
t.implements('NodeC')
},
}),
interfaceType({
name: 'NodeB',
definition(t) {
t.id('b')
t.implements('NodeA')
},
}),
interfaceType({
name: 'NodeC',
definition(t) {
t.id('c')
t.implements('NodeB')
},
}),
],
outputs: false,
shouldGenerateArtifacts: false,
})
).toThrowErrorMatchingSnapshot()
})
it('logs error when resolveType is not provided for an interface', async () => {
const spy = jest.spyOn(console, 'error').mockImplementation()
makeSchema({
Expand Down

0 comments on commit 9bfdf2c

Please sign in to comment.