From 85e8658c6acd495233cabb576c3458afcb8fff12 Mon Sep 17 00:00:00 2001 From: doug-martin Date: Wed, 13 May 2020 20:10:50 -0500 Subject: [PATCH] feat(graphql,core): Add support for custom services and assemblers --- ...-assembler-query-service.decorator.spec.ts | 28 +++++ packages/core/src/decorators/helpers.ts | 7 ++ packages/core/src/decorators/index.ts | 1 + ...nject-assembler-query-service.decorator.ts | 8 ++ .../inject-query-service.decorator.ts | 3 +- packages/core/src/index.ts | 11 +- packages/core/src/module.ts | 22 ++++ packages/core/src/providers.ts | 28 +++++ packages/core/src/services/index.ts | 1 + .../query-graphql/__tests__/module.spec.ts | 10 +- .../query-graphql/__tests__/providers.spec.ts | 34 +++--- packages/query-graphql/src/module.ts | 14 ++- packages/query-graphql/src/providers.ts | 106 +++++++++++++++--- 13 files changed, 230 insertions(+), 43 deletions(-) create mode 100644 packages/core/__tests__/decorators/inject-assembler-query-service.decorator.spec.ts create mode 100644 packages/core/src/decorators/inject-assembler-query-service.decorator.ts create mode 100644 packages/core/src/module.ts create mode 100644 packages/core/src/providers.ts diff --git a/packages/core/__tests__/decorators/inject-assembler-query-service.decorator.spec.ts b/packages/core/__tests__/decorators/inject-assembler-query-service.decorator.spec.ts new file mode 100644 index 000000000..08f2860ae --- /dev/null +++ b/packages/core/__tests__/decorators/inject-assembler-query-service.decorator.spec.ts @@ -0,0 +1,28 @@ +import * as nestjsCommon from '@nestjs/common'; +import { QueryService, InjectAssemblerQueryService, DefaultAssembler } from '../../src'; +import { getAssemblerQueryServiceToken } from '../../src/decorators/helpers'; + +describe('@InjectAssemblerQueryService', () => { + const injectSpy = jest.spyOn(nestjsCommon, 'Inject'); + + class Foo { + str!: string; + } + + class Bar { + num!: string; + } + + // eslint-disable-next-line @typescript-eslint/ban-types + class TestAssembler extends DefaultAssembler {} + + it('call inject with the correct key', () => { + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class Test { + constructor(@InjectAssemblerQueryService(TestAssembler) readonly service: QueryService) {} + } + expect(injectSpy).toBeCalledTimes(1); + expect(injectSpy).toBeCalledWith(getAssemblerQueryServiceToken(TestAssembler)); + }); +}); diff --git a/packages/core/src/decorators/helpers.ts b/packages/core/src/decorators/helpers.ts index 98cf06ebd..b81d520b2 100644 --- a/packages/core/src/decorators/helpers.ts +++ b/packages/core/src/decorators/helpers.ts @@ -1,5 +1,12 @@ +import { Assembler } from '../assemblers'; import { Class } from '../common'; export function getQueryServiceToken(DTOClass: Class): string { return `${DTOClass.name}QueryService`; } + +export function getAssemblerQueryServiceToken( + AssemblerClass: Class>, +): string { + return `${AssemblerClass.name}QueryService`; +} diff --git a/packages/core/src/decorators/index.ts b/packages/core/src/decorators/index.ts index defc3b235..8c1abbde8 100644 --- a/packages/core/src/decorators/index.ts +++ b/packages/core/src/decorators/index.ts @@ -1,2 +1,3 @@ export { getQueryServiceToken } from './helpers'; export { InjectQueryService } from './inject-query-service.decorator'; +export { InjectAssemblerQueryService } from './inject-assembler-query-service.decorator'; diff --git a/packages/core/src/decorators/inject-assembler-query-service.decorator.ts b/packages/core/src/decorators/inject-assembler-query-service.decorator.ts new file mode 100644 index 000000000..1d598bafb --- /dev/null +++ b/packages/core/src/decorators/inject-assembler-query-service.decorator.ts @@ -0,0 +1,8 @@ +import { Inject } from '@nestjs/common'; +import { Assembler } from '../assemblers'; +import { Class } from '../common'; +import { getAssemblerQueryServiceToken } from './helpers'; + +export const InjectAssemblerQueryService = ( + AssemblerClass: Class>, +): ParameterDecorator => Inject(getAssemblerQueryServiceToken(AssemblerClass)); diff --git a/packages/core/src/decorators/inject-query-service.decorator.ts b/packages/core/src/decorators/inject-query-service.decorator.ts index da371b705..7259ccf28 100644 --- a/packages/core/src/decorators/inject-query-service.decorator.ts +++ b/packages/core/src/decorators/inject-query-service.decorator.ts @@ -2,4 +2,5 @@ import { Inject } from '@nestjs/common'; import { Class } from '../common'; import { getQueryServiceToken } from './helpers'; -export const InjectQueryService = (entity: Class): ParameterDecorator => Inject(getQueryServiceToken(entity)); +export const InjectQueryService = (DTOClass: Class): ParameterDecorator => + Inject(getQueryServiceToken(DTOClass)); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d3f7b0f4..ff4e6f8be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,8 +1,14 @@ /* eslint-disable import/export */ export * from './interfaces'; export * from './common'; -export { InjectQueryService, getQueryServiceToken } from './decorators'; -export { QueryService, AssemblerQueryService, RelationQueryService, QueryServiceRelation } from './services'; +export { InjectAssemblerQueryService, InjectQueryService, getQueryServiceToken } from './decorators'; +export { + QueryService, + AssemblerQueryService, + RelationQueryService, + NoOpQueryService, + QueryServiceRelation, +} from './services'; export { transformFilter, transformQuery, transformSort, QueryFieldMap } from './helpers'; export { ClassTransformerAssembler, @@ -13,3 +19,4 @@ export { AssemblerDeserializer, AssemblerFactory, } from './assemblers'; +export { NestjsQueryCoreModule, NestjsQueryCoreModuleOpts } from './module'; diff --git a/packages/core/src/module.ts b/packages/core/src/module.ts new file mode 100644 index 000000000..788ba9de9 --- /dev/null +++ b/packages/core/src/module.ts @@ -0,0 +1,22 @@ +import { DynamicModule, ForwardReference } from '@nestjs/common'; +import { Assembler } from './assemblers'; +import { Class } from './common'; +import { createServices } from './providers'; + +export interface NestjsQueryCoreModuleOpts { + imports?: Array | DynamicModule | Promise | ForwardReference>; + assemblers?: Class>[]; +} + +export class NestjsQueryCoreModule { + static forFeature(opts: NestjsQueryCoreModuleOpts): DynamicModule { + const { imports = [], assemblers = [] } = opts; + const assemblerServiceProviders = createServices(assemblers); + return { + module: NestjsQueryCoreModule, + imports: [...imports], + providers: [...assemblers, ...assemblerServiceProviders], + exports: [...imports, ...assemblers, ...assemblerServiceProviders], + }; + } +} diff --git a/packages/core/src/providers.ts b/packages/core/src/providers.ts new file mode 100644 index 000000000..07de30286 --- /dev/null +++ b/packages/core/src/providers.ts @@ -0,0 +1,28 @@ +import { Provider } from '@nestjs/common'; +import { Assembler } from './assemblers'; +import { Class } from './common'; +import { getQueryServiceToken } from './decorators'; +import { getAssemblerQueryServiceToken } from './decorators/helpers'; +import { getCoreMetadataStorage } from './metadata'; +import { AssemblerQueryService, QueryService } from './services'; + +function createServiceProvider(AssemblerClass: Class>): Provider { + const classes = getCoreMetadataStorage().getAssemblerClasses(AssemblerClass); + if (!classes) { + throw new Error( + `unable to determine DTO and Entity classes for ${AssemblerClass.name}. Did you decorate your class with @Assembler`, + ); + } + const { EntityClass } = classes; + return { + provide: getAssemblerQueryServiceToken(AssemblerClass), + useFactory(assembler: Assembler, entityService: QueryService) { + return new AssemblerQueryService(assembler, entityService); + }, + inject: [AssemblerClass, getQueryServiceToken(EntityClass)], + }; +} + +export const createServices = (opts: Class>[]): Provider[] => { + return opts.map((opt) => createServiceProvider(opt)); +}; diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index b448b8a2b..44166cd3d 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,3 +1,4 @@ export { QueryService } from './query.service'; export { AssemblerQueryService } from './assembler-query.service'; export { RelationQueryService, QueryServiceRelation } from './relation-query.service'; +export { NoOpQueryService } from './noop-query.service'; diff --git a/packages/query-graphql/__tests__/module.spec.ts b/packages/query-graphql/__tests__/module.spec.ts index 190a80ef6..a86d11520 100644 --- a/packages/query-graphql/__tests__/module.spec.ts +++ b/packages/query-graphql/__tests__/module.spec.ts @@ -10,7 +10,7 @@ describe('NestjsQueryTypeOrmModule', () => { } it('should create a module', () => { - const typeOrmModule = NestjsQueryGraphQLModule.forFeature({ + const graphqlModule = NestjsQueryGraphQLModule.forFeature({ imports: [], resolvers: [ { @@ -19,9 +19,9 @@ describe('NestjsQueryTypeOrmModule', () => { }, ], }); - expect(typeOrmModule.imports).toHaveLength(0); - expect(typeOrmModule.module).toBe(NestjsQueryGraphQLModule); - expect(typeOrmModule.providers).toHaveLength(1); - expect(typeOrmModule.exports).toHaveLength(1); + expect(graphqlModule.imports).toHaveLength(1); + expect(graphqlModule.module).toBe(NestjsQueryGraphQLModule); + expect(graphqlModule.providers).toHaveLength(1); + expect(graphqlModule.exports).toHaveLength(2); }); }); diff --git a/packages/query-graphql/__tests__/providers.spec.ts b/packages/query-graphql/__tests__/providers.spec.ts index d1f244ca2..7b6e409ef 100644 --- a/packages/query-graphql/__tests__/providers.spec.ts +++ b/packages/query-graphql/__tests__/providers.spec.ts @@ -1,5 +1,4 @@ -import { Class } from '@nestjs-query/core'; -import { NoOpQueryService } from '@nestjs-query/core/src/services/noop-query.service'; +import { Class, NoOpQueryService } from '@nestjs-query/core'; import { ObjectType } from '@nestjs/graphql'; import { FilterableField } from '../src/decorators'; import { createResolvers } from '../src/providers'; @@ -13,20 +12,23 @@ describe('createTypeOrmQueryServiceProviders', () => { name!: string; } - it('should create a provider for the entity', () => { - const providers = createResolvers([{ DTOClass: TestDTO, EntityClass: TestDTO }]); - expect(providers).toHaveLength(1); - const Provider = providers[0] as Class>; - expect(Provider.name).toBe('TestDTOAutoResolver'); - expect(new Provider(NoOpQueryService.getInstance())).toBeInstanceOf(Provider); - }); + describe('entity crud resolver', () => { + it('should create a provider for the entity', () => { + const providers = createResolvers([{ DTOClass: TestDTO, EntityClass: TestDTO }]); + expect(providers).toHaveLength(1); + const Provider = providers[0] as Class>; + expect(Provider.name).toBe('TestDTOAutoResolver'); + expect(new Provider(NoOpQueryService.getInstance())).toBeInstanceOf(Provider); + }); + + it('should create a federated provider for the entity', () => { + class Service extends NoOpQueryService {} - it('should create a federated provider for the entity', () => { - class Service extends NoOpQueryService {} - const providers = createResolvers([{ type: 'federated', DTOClass: TestDTO, Service }]); - expect(providers).toHaveLength(1); - const Provider = providers[0] as Class>; - expect(Provider.name).toBe('TestDTOFederatedAutoResolver'); - expect(new Provider(NoOpQueryService.getInstance())).toBeInstanceOf(Provider); + const providers = createResolvers([{ type: 'federated', DTOClass: TestDTO, Service }]); + expect(providers).toHaveLength(1); + const Provider = providers[0] as Class>; + expect(Provider.name).toBe('TestDTOFederatedAutoResolver'); + expect(new Provider(NoOpQueryService.getInstance())).toBeInstanceOf(Provider); + }); }); }); diff --git a/packages/query-graphql/src/module.ts b/packages/query-graphql/src/module.ts index 87e666528..15d5cb2d4 100644 --- a/packages/query-graphql/src/module.ts +++ b/packages/query-graphql/src/module.ts @@ -1,22 +1,28 @@ -import { Class } from '@nestjs-query/core'; +import { Assembler, NestjsQueryCoreModule, Class } from '@nestjs-query/core'; import { DynamicModule, ForwardReference, Provider } from '@nestjs/common'; import { AutoResolverOpts, createResolvers } from './providers'; export interface NestjsQueryGraphqlModuleOpts { imports: Array | DynamicModule | Promise | ForwardReference>; services?: Provider[]; + assemblers?: Class>[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any resolvers: AutoResolverOpts[]; } export class NestjsQueryGraphQLModule { static forFeature(opts: NestjsQueryGraphqlModuleOpts): DynamicModule { + const coreModule = NestjsQueryCoreModule.forFeature({ + assemblers: opts.assemblers, + imports: opts.imports, + }); + const services = opts.services || []; const resolverProviders = createResolvers(opts.resolvers); return { module: NestjsQueryGraphQLModule, - imports: [...opts.imports], - providers: [...(opts.services || []), ...resolverProviders], - exports: [...resolverProviders, ...opts.imports], + imports: [...opts.imports, coreModule], + providers: [...services, ...resolverProviders], + exports: [...resolverProviders, ...services, ...opts.imports, coreModule], }; } } diff --git a/packages/query-graphql/src/providers.ts b/packages/query-graphql/src/providers.ts index c17ef6d83..2ae8a52f7 100644 --- a/packages/query-graphql/src/providers.ts +++ b/packages/query-graphql/src/providers.ts @@ -1,29 +1,62 @@ -import { Class, QueryService, getQueryServiceToken, AssemblerQueryService, AssemblerFactory } from '@nestjs-query/core'; +import { + Class, + QueryService, + InjectAssemblerQueryService, + InjectQueryService, + AssemblerFactory, + AssemblerQueryService, +} from '@nestjs-query/core'; +import { Assembler } from '@nestjs-query/core/src'; import { Provider, Inject } from '@nestjs/common'; import { Resolver } from '@nestjs/graphql'; import { CRUDResolver, CRUDResolverOpts, FederationResolver, RelationsOpts } from './resolvers'; -export type CRUDAutoResolverOpts = CRUDResolverOpts & { +type CRUDAutoResolverOpts = CRUDResolverOpts & { DTOClass: Class; +}; + +export type EntityCRUDAutoResolverOpts = CRUDAutoResolverOpts & { EntityClass: Class; }; +export type AssemblerCRUDAutoResolverOpts = CRUDAutoResolverOpts & { + AssemblerClass: Class; +}; + +export type ServiceCRUDAutoResolverOpts = CRUDAutoResolverOpts & { + ServiceClass: Class; +}; + export type FederatedAutoResolverOpts = RelationsOpts & { type: 'federated'; DTOClass: Class; Service: Class; }; -export type AutoResolverOpts = - | CRUDAutoResolverOpts - | FederatedAutoResolverOpts; +export type AutoResolverOpts = + | EntityCRUDAutoResolverOpts + | AssemblerCRUDAutoResolverOpts + | ServiceCRUDAutoResolverOpts + | FederatedAutoResolverOpts; -const isFederatedResolverOpts = ( - opts: AutoResolverOpts, -): opts is FederatedAutoResolverOpts => { +export const isFederatedResolverOpts = ( + opts: AutoResolverOpts, +): opts is FederatedAutoResolverOpts => { return 'type' in opts && opts.type === 'federated'; }; +export const isAssemblerCRUDAutoResolverOpts = ( + opts: AutoResolverOpts, +): opts is AssemblerCRUDAutoResolverOpts => { + return 'DTOClass' in opts && 'AssemblerClass' in opts; +}; + +export const isServiceCRUDAutoResolverOpts = ( + opts: AutoResolverOpts, +): opts is ServiceCRUDAutoResolverOpts => { + return 'DTOClass' in opts && 'ServiceClass' in opts; +}; + const getResolverToken = (DTOClass: Class): string => `${DTOClass.name}AutoResolver`; const getFederatedResolverToken = (DTOClass: Class): string => `${DTOClass.name}FederatedAutoResolver`; @@ -42,32 +75,75 @@ function createFederatedResolver(resolverOpts: FederatedAutoResolv return AutoResolver; } -function createResolver(resolverOpts: AutoResolverOpts): Provider { - if (isFederatedResolverOpts(resolverOpts)) { - return createFederatedResolver(resolverOpts); - } +function createEntityAutoResolver( + resolverOpts: EntityCRUDAutoResolverOpts, +): Provider { const { DTOClass, EntityClass } = resolverOpts; - class Service extends AssemblerQueryService { constructor(service: QueryService) { const assembler = AssemblerFactory.getAssembler(DTOClass, EntityClass); super(assembler, service); } } - @Resolver(() => DTOClass) class AutoResolver extends CRUDResolver(DTOClass, resolverOpts) { - constructor(@Inject(getQueryServiceToken(EntityClass)) service: QueryService) { + constructor(@InjectQueryService(EntityClass) service: QueryService) { super(new Service(service)); } } + // need to set class name so DI works properly + Object.defineProperty(AutoResolver, 'name', { value: getResolverToken(DTOClass), writable: false }); + return AutoResolver; +} +function createAssemblerAutoResolver( + resolverOpts: AssemblerCRUDAutoResolverOpts, +): Provider { + const { DTOClass, AssemblerClass } = resolverOpts; + @Resolver(() => DTOClass) + class AutoResolver extends CRUDResolver(DTOClass, resolverOpts) { + constructor( + @InjectAssemblerQueryService((AssemblerClass as unknown) as Class>) + service: QueryService, + ) { + super(service); + } + } // need to set class name so DI works properly Object.defineProperty(AutoResolver, 'name', { value: getResolverToken(DTOClass), writable: false }); + return AutoResolver; +} +function createServiceAutoResolver( + resolverOpts: ServiceCRUDAutoResolverOpts, +): Provider { + const { DTOClass, ServiceClass } = resolverOpts; + @Resolver(() => DTOClass) + class AutoResolver extends CRUDResolver(DTOClass, resolverOpts) { + constructor(@Inject(ServiceClass) service: QueryService) { + super(service); + } + } + // need to set class name so DI works properly + Object.defineProperty(AutoResolver, 'name', { value: getResolverToken(DTOClass), writable: false }); return AutoResolver; } +function createResolver( + resolverOpts: AutoResolverOpts, +): Provider { + if (isFederatedResolverOpts(resolverOpts)) { + return createFederatedResolver(resolverOpts); + } + if (isAssemblerCRUDAutoResolverOpts(resolverOpts)) { + return createAssemblerAutoResolver(resolverOpts); + } + if (isServiceCRUDAutoResolverOpts(resolverOpts)) { + return createServiceAutoResolver(resolverOpts); + } + return createEntityAutoResolver(resolverOpts); +} + export const createResolvers = (opts: AutoResolverOpts[]): Provider[] => { return opts.map((opt) => createResolver(opt)); };