diff --git a/docs/explore/object-type-and-field.md b/docs/explore/object-type-and-field.md index 7ad274a..19f72ec 100644 --- a/docs/explore/object-type-and-field.md +++ b/docs/explore/object-type-and-field.md @@ -88,3 +88,22 @@ class Person { } } ``` + +## Interfaces + +```ts +import { InterfaceType, ObjectType, Field } from 'typegql'; + +@InterfaceType() +class Vehicle { + @Field() id: number; +} + +@ObjectType() +class Car extends Vehicle { + @Field({ type: () => Person }) + owner() { + return db.findPersonByCarId(this.id); + } +} +``` diff --git a/docs/reference/index.md b/docs/reference/index.md index 5271404..82d2841 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -11,6 +11,17 @@ interface ObjectTypeOptions { @ObjectType(options?: ObjectTypeOptions) ``` +### @InterfaceType + +```typescript +interface InterfaceTypeOptions { + name?: string; // infered from class name + description?: string; +} + +@InterfaceType(options?: InterfaceTypeOptions) +``` + ### @Field ```typescript @@ -180,6 +191,12 @@ compileSchema(schemaTarget: Function): GraphQLSchema compileObjectType(schemaTarget: Function): GraphQLObjectType ``` +### compileInterfaceType, + +```typescript +compileInterfaceType(schemaTarget: Function): GraphQLInterfaceType +``` + ### compileInputObjectType, ```typescript diff --git a/src/domains/index.ts b/src/domains/index.ts index 1c59bd0..776e271 100644 --- a/src/domains/index.ts +++ b/src/domains/index.ts @@ -3,6 +3,11 @@ export { compileObjectType, objectTypeRegistry, } from './objectType'; +export { + InterfaceType, + compileInterfaceType, + interfaceTypeRegistry, +} from './interfaceType'; export { InputObjectType, compileInputObjectType, diff --git a/src/domains/interfaceType/compiler/index.ts b/src/domains/interfaceType/compiler/index.ts new file mode 100644 index 0000000..50100b5 --- /dev/null +++ b/src/domains/interfaceType/compiler/index.ts @@ -0,0 +1,4 @@ +export { + compileInterfaceType, + compileInterfaceTypeWithConfig, +} from './interfaceType'; diff --git a/src/domains/interfaceType/compiler/interfaceType.ts b/src/domains/interfaceType/compiler/interfaceType.ts new file mode 100644 index 0000000..9a7432b --- /dev/null +++ b/src/domains/interfaceType/compiler/interfaceType.ts @@ -0,0 +1,62 @@ +import { GraphQLInterfaceType } from 'graphql'; +import { InterfaceTypeError, interfaceTypeRegistry } from '../index'; + +import { compileAllFields, fieldsRegistry } from '~/domains/field'; +import { + createCachedThunk, + getClassWithAllParentClasses, +} from '~/services/utils'; + +const compileOutputTypeCache = new WeakMap(); + +export interface TypeOptions { + name: string; + description?: string; +} + +function createTypeFieldsGetter(target: Function) { + const targetWithParents = getClassWithAllParentClasses(target); + const hasFields = targetWithParents.some(ancestor => { + return !fieldsRegistry.isEmpty(ancestor); + }); + + if (!hasFields) { + throw new InterfaceTypeError( + target, + `There are no fields inside this type.`, + ); + } + + return createCachedThunk(() => { + return compileAllFields(target); + }); +} + +export function compileInterfaceTypeWithConfig( + target: Function, + config: TypeOptions, +): GraphQLInterfaceType { + if (compileOutputTypeCache.has(target)) { + return compileOutputTypeCache.get(target); + } + + const compiled = new GraphQLInterfaceType({ + ...config, + fields: createTypeFieldsGetter(target), + }); + + compileOutputTypeCache.set(target, compiled); + return compiled; +} + +export function compileInterfaceType(target: Function) { + if (!interfaceTypeRegistry.has(target)) { + throw new InterfaceTypeError( + target, + `Class is not registered. Make sure it's decorated with @ObjectType decorator`, + ); + } + + const compiler = interfaceTypeRegistry.get(target); + return compiler(); +} diff --git a/src/domains/interfaceType/error.ts b/src/domains/interfaceType/error.ts new file mode 100644 index 0000000..24051b3 --- /dev/null +++ b/src/domains/interfaceType/error.ts @@ -0,0 +1,9 @@ +import { BaseError } from '~/services/error'; + +export class InterfaceTypeError extends BaseError { + constructor(target: Function, msg: string) { + const fullMsg = `@InterfaceType '${target.name}': ${msg}`; + super(fullMsg); + this.message = fullMsg; + } +} diff --git a/src/domains/interfaceType/index.ts b/src/domains/interfaceType/index.ts new file mode 100644 index 0000000..a215257 --- /dev/null +++ b/src/domains/interfaceType/index.ts @@ -0,0 +1,20 @@ +import { compileInterfaceTypeWithConfig } from './compiler'; +import { interfaceTypeRegistry } from './registry'; + +export { compileInterfaceType } from './compiler'; +export { InterfaceTypeError } from './error'; +export { interfaceTypeRegistry } from './registry'; + +export interface InterfaceTypeOptions { + name?: string; + description?: string; +} + +export function InterfaceType(options?: InterfaceTypeOptions): ClassDecorator { + return (target: Function) => { + const config = { name: target.name, ...options }; + const outputTypeCompiler = () => + compileInterfaceTypeWithConfig(target, config); + interfaceTypeRegistry.set(target, outputTypeCompiler); + }; +} diff --git a/src/domains/interfaceType/registry.ts b/src/domains/interfaceType/registry.ts new file mode 100644 index 0000000..bd7a08a --- /dev/null +++ b/src/domains/interfaceType/registry.ts @@ -0,0 +1,14 @@ +import { GraphQLInterfaceType, GraphQLTypeResolver } from 'graphql'; + +type Getter = () => Result; + +export const interfaceTypeRegistry = new WeakMap< + Function, + Getter +>(); + +export interface TypeConfig { + name: string; + description: string; + resolveType: GraphQLTypeResolver | void; +} diff --git a/src/domains/objectType/compiler/objectType.ts b/src/domains/objectType/compiler/objectType.ts index 32a3e25..b6c9359 100644 --- a/src/domains/objectType/compiler/objectType.ts +++ b/src/domains/objectType/compiler/objectType.ts @@ -1,5 +1,9 @@ import { GraphQLObjectType } from 'graphql'; import { ObjectTypeError, objectTypeRegistry } from '../index'; +import { + compileInterfaceType, + interfaceTypeRegistry, +} from '~/domains/interfaceType'; import { compileAllFields, fieldsRegistry } from '~/domains/field'; import { @@ -14,6 +18,18 @@ export interface TypeOptions { description?: string; } +function getInterfaces(target: Function) { + const targetWithParents = getClassWithAllParentClasses(target); + const interfaces = targetWithParents.filter(ancestor => { + return interfaceTypeRegistry.has(ancestor); + }); + + if (interfaces) { + const result = interfaces.map(fn => compileInterfaceType(fn)); + return result; + } +} + function createTypeFieldsGetter(target: Function) { const targetWithParents = getClassWithAllParentClasses(target); const hasFields = targetWithParents.some(ancestor => { @@ -41,6 +57,7 @@ export function compileObjectTypeWithConfig( ...config, isTypeOf: value => value instanceof target, fields: createTypeFieldsGetter(target), + interfaces: getInterfaces(target), }); compileOutputTypeCache.set(target, compiled); diff --git a/src/domains/objectType/index.ts b/src/domains/objectType/index.ts index da43c2c..b1905f9 100644 --- a/src/domains/objectType/index.ts +++ b/src/domains/objectType/index.ts @@ -3,7 +3,7 @@ import { objectTypeRegistry } from './registry'; export { compileObjectType } from './compiler'; export { ObjectTypeError } from './error'; -export { objectTypeRegistry, inputTypeRegistry } from './registry'; +export { objectTypeRegistry } from './registry'; export interface ObjectTypeOptions { name?: string; diff --git a/src/domains/objectType/registry.ts b/src/domains/objectType/registry.ts index 1e8e376..1abc8c4 100644 --- a/src/domains/objectType/registry.ts +++ b/src/domains/objectType/registry.ts @@ -1,4 +1,4 @@ -import { GraphQLInputType, GraphQLObjectType } from 'graphql'; +import { GraphQLObjectType } from 'graphql'; type Getter = () => Result; @@ -6,10 +6,6 @@ export const objectTypeRegistry = new WeakMap< Function, Getter >(); -export const inputTypeRegistry = new WeakMap< - Function, - Getter ->(); export interface TypeConfig { name: string; diff --git a/src/index.ts b/src/index.ts index 6feafd6..30e01c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { SchemaRoot, Context, ObjectType, + InterfaceType, Query, Mutation, InputField, diff --git a/src/test/interfaceType/__snapshots__/index.spec.ts.snap b/src/test/interfaceType/__snapshots__/index.spec.ts.snap new file mode 100644 index 0000000..831d94a --- /dev/null +++ b/src/test/interfaceType/__snapshots__/index.spec.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Type Throws when @InterfaceType has no fields 1`] = `"@InterfaceType 'NoFields': There are no fields inside this type."`; + +exports[`Type Throws when @InterfaceType has no fields 2`] = `"@InterfaceType 'NoDeclaredFields': There are no fields inside this type."`; + +exports[`Type Throws when trying to compile type without @InterfaceType decorator 1`] = `"@InterfaceType 'Bar': Class is not registered. Make sure it's decorated with @ObjectType decorator"`; diff --git a/src/test/interfaceType/index.spec.ts b/src/test/interfaceType/index.spec.ts new file mode 100644 index 0000000..1ae6936 --- /dev/null +++ b/src/test/interfaceType/index.spec.ts @@ -0,0 +1,75 @@ +import { GraphQLInterfaceType } from 'graphql'; +import { InterfaceType, compileInterfaceType } from '~/domains'; +import { Field } from '~/domains/field'; + +describe('Type', () => { + it('Throws when trying to compile type without @InterfaceType decorator', () => { + expect(() => + compileInterfaceType(class Bar {}), + ).toThrowErrorMatchingSnapshot(); + }); + + it('Throws when @InterfaceType has no fields', () => { + @InterfaceType() + class NoFields {} + + @InterfaceType() + class NoDeclaredFields { + foo: string; + } + expect(() => compileInterfaceType(NoFields)).toThrowErrorMatchingSnapshot(); + expect(() => + compileInterfaceType(NoDeclaredFields), + ).toThrowErrorMatchingSnapshot(); + }); + + it('Compiles basic type with field', () => { + @InterfaceType() + class Foo { + @Field() bar: string; + } + + const compiled = compileInterfaceType(Foo); + + const fields = compiled.getFields(); + const barField = fields.bar; + + expect(compiled).toBeInstanceOf(GraphQLInterfaceType); + + expect(barField).toBeTruthy(); + expect(barField.name).toEqual('bar'); + }); + + it('Sets proper options', () => { + @InterfaceType({ description: 'Baz' }) + class Foo { + @Field() bar: string; + } + + const compiled = compileInterfaceType(Foo); + + expect(compiled.description).toEqual('Baz'); + expect(compiled.name).toEqual('Foo'); + + @InterfaceType({ name: 'Baz' }) + class FooCustomName { + @Field() bar: string; + } + + const compiledCustomName = compileInterfaceType(FooCustomName); + + expect(compiledCustomName.name).toEqual('Baz'); + }); + + it('Final type is compiled only once per class', () => { + @InterfaceType() + class Foo { + @Field() bar: string; + } + + const compiledA = compileInterfaceType(Foo); + const compiledB = compileInterfaceType(Foo); + + expect(compiledA).toBe(compiledB); + }); +}); diff --git a/src/test/interfaceType/inheritance.spec.ts b/src/test/interfaceType/inheritance.spec.ts new file mode 100644 index 0000000..d0fdaa0 --- /dev/null +++ b/src/test/interfaceType/inheritance.spec.ts @@ -0,0 +1,61 @@ +import { GraphQLString, GraphQLNonNull } from 'graphql'; +import { InterfaceType, compileInterfaceType, Field } from '~/domains'; +import { getClassWithAllParentClasses } from '~/services/utils'; + +describe('Type inheritance', () => { + it('Will pass fields from parent class', () => { + class Base { + @Field() baseField: string; + } + + @InterfaceType() + class Foo extends Base {} + + const { baseField } = compileInterfaceType(Foo).getFields(); + + expect(baseField.type).toEqual(GraphQLString); + }); + + it('Will overwrite fields in child class', () => { + class Base { + @Field() foo: string; + @Field() bar: string; + } + + @InterfaceType() + class Foo extends Base { + @Field({ isNullable: false }) + foo: string; + } + + const { foo, bar } = compileInterfaceType(Foo).getFields(); + + expect(bar.type).toEqual(GraphQLString); + expect(foo.type).toEqual(new GraphQLNonNull(GraphQLString)); + }); + + it('picks up all the properties even for long chain of extended classes', async () => { + @InterfaceType() + class Vehicle { + @Field() passengers: string; + } + + @InterfaceType() + class Car extends Vehicle { + @Field() doorCount: number; + } + + @InterfaceType() + class Lamborghini extends Car { + @Field() speed: string; + } + const compiled = compileInterfaceType(Lamborghini); + + const fields = compiled.getFields(); + + expect(fields).toHaveProperty('passengers'); + expect(fields).toHaveProperty('doorCount'); + expect(fields).toHaveProperty('speed'); + expect(getClassWithAllParentClasses(Lamborghini).length).toBe(3); + }); +}); diff --git a/src/test/objectType/__snapshots__/inheritance.spec.ts.snap b/src/test/objectType/__snapshots__/inheritance.spec.ts.snap new file mode 100644 index 0000000..53361ae --- /dev/null +++ b/src/test/objectType/__snapshots__/inheritance.spec.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Type inheritance can use multiple interfaces from InterfaceType ancestors 1`] = ` +"type Car implements Entity & Vehicle { + name: String + passengers: String + doorCount: Float +}" +`; + +exports[`Type inheritance implements an interface when extending by an InterfaceType 1`] = ` +"type Car implements Vehicle { + passengers: String + doorCount: Float +}" +`; diff --git a/src/test/objectType/inheritance.spec.ts b/src/test/objectType/inheritance.spec.ts index 5bff4dc..bd9d4d3 100644 --- a/src/test/objectType/inheritance.spec.ts +++ b/src/test/objectType/inheritance.spec.ts @@ -1,5 +1,5 @@ -import { GraphQLString, GraphQLNonNull } from 'graphql'; -import { ObjectType, compileObjectType, Field } from '~/domains'; +import { GraphQLString, GraphQLNonNull, printType } from 'graphql'; +import { InterfaceType, ObjectType, compileObjectType, Field } from '~/domains'; import { getClassWithAllParentClasses } from '~/services/utils'; describe('Type inheritance', () => { @@ -56,6 +56,57 @@ describe('Type inheritance', () => { expect(fields).toHaveProperty('passengers'); expect(fields).toHaveProperty('doorCount'); expect(fields).toHaveProperty('speed'); - expect(getClassWithAllParentClasses(Lamborghini).length).toBe(3); + expect(getClassWithAllParentClasses(Lamborghini)).toHaveLength(3); + }); + + it('implements an interface when extending by an InterfaceType', async () => { + @InterfaceType() + class Vehicle { + @Field() passengers: string; + } + + @ObjectType() + class Car extends Vehicle { + @Field() doorCount: number; + } + + const compiled = compileObjectType(Car); + + const fields = compiled.getFields(); + + expect(fields).toHaveProperty('passengers'); + expect(fields).toHaveProperty('doorCount'); + expect(getClassWithAllParentClasses(Car)).toHaveLength(2); + + expect(compiled.getInterfaces()).toHaveLength(1); + expect(printType(compiled)).toMatchSnapshot(); + }); + + it('can use multiple interfaces from InterfaceType ancestors', async () => { + @InterfaceType() + class Entity { + @Field() name: string; + } + + @InterfaceType() + class Vehicle extends Entity { + @Field() passengers: string; + } + + @ObjectType() + class Car extends Vehicle { + @Field() doorCount: number; + } + + const compiled = compileObjectType(Car); + + const fields = compiled.getFields(); + expect(fields).toHaveProperty('name'); + expect(fields).toHaveProperty('passengers'); + expect(fields).toHaveProperty('doorCount'); + expect(getClassWithAllParentClasses(Car)).toHaveLength(3); + + expect(compiled.getInterfaces()).toHaveLength(2); + expect(printType(compiled)).toMatchSnapshot(); }); });