From 3448955331ae24f3b08c1d8b459b13e0ae96c79f Mon Sep 17 00:00:00 2001 From: doug-martin Date: Tue, 21 Jul 2020 23:48:07 -0500 Subject: [PATCH] feat(graphql,hooks): Add before hooks to graphql mutations --- .../query-graphql/src/decorators/constants.ts | 8 +++ .../src/decorators/decorator.utils.ts | 41 ++++++++++++ .../src/decorators/hook.decorator.ts | 63 +++++++++++++++++++ .../query-graphql/src/decorators/index.ts | 3 + .../src/decorators/mutation-args.decorator.ts | 24 +++++++ .../src/decorators/skip-if.decorator.ts | 28 +-------- packages/query-graphql/src/index.ts | 12 ++++ .../src/resolvers/create.resolver.ts | 39 +++++++++--- .../src/resolvers/delete.resolver.ts | 14 ++++- .../query-graphql/src/resolvers/helpers.ts | 1 + .../src/resolvers/update.resolver.ts | 30 ++++++++- 11 files changed, 223 insertions(+), 40 deletions(-) create mode 100644 packages/query-graphql/src/decorators/constants.ts create mode 100644 packages/query-graphql/src/decorators/decorator.utils.ts create mode 100644 packages/query-graphql/src/decorators/hook.decorator.ts create mode 100644 packages/query-graphql/src/decorators/mutation-args.decorator.ts diff --git a/packages/query-graphql/src/decorators/constants.ts b/packages/query-graphql/src/decorators/constants.ts new file mode 100644 index 000000000..b0719f746 --- /dev/null +++ b/packages/query-graphql/src/decorators/constants.ts @@ -0,0 +1,8 @@ +export const BEFORE_CREATE_ONE = 'nestjs-query:before-create-one'; +export const BEFORE_CREATE_MANY = 'nestjs-query:before-create-many'; + +export const BEFORE_UPDATE_ONE = 'nestjs-query:before-update-one'; +export const BEFORE_UPDATE_MANY = 'nestjs-query:before-update-many'; + +export const BEFORE_DELETE_ONE = 'nestjs-query:before-delete-one'; +export const BEFORE_DELETE_MANY = 'nestjs-query:before-delete-many'; diff --git a/packages/query-graphql/src/decorators/decorator.utils.ts b/packages/query-graphql/src/decorators/decorator.utils.ts new file mode 100644 index 000000000..8c912c1ba --- /dev/null +++ b/packages/query-graphql/src/decorators/decorator.utils.ts @@ -0,0 +1,41 @@ +import { Class } from '@nestjs-query/core'; + +export type ComposableDecorator = MethodDecorator | PropertyDecorator | ClassDecorator | ParameterDecorator; +export type ComposedDecorator = MethodDecorator & PropertyDecorator & ClassDecorator & ParameterDecorator; + +export function composeDecorators(...decorators: ComposableDecorator[]): ComposedDecorator { + // eslint-disable-next-line @typescript-eslint/ban-types + return ( + // eslint-disable-next-line @typescript-eslint/ban-types + target: TFunction | object, + propertyKey?: string | symbol, + descriptorOrIndex?: TypedPropertyDescriptor | number, + ) => { + decorators.forEach((decorator) => { + if (target instanceof Function && !descriptorOrIndex) { + return (decorator as ClassDecorator)(target); + } + if (typeof descriptorOrIndex === 'number') { + return (decorator as ParameterDecorator)(target, propertyKey as string | symbol, descriptorOrIndex); + } + return (decorator as MethodDecorator | PropertyDecorator)( + target, + propertyKey as string | symbol, + descriptorOrIndex as TypedPropertyDescriptor, + ); + }); + }; +} + +type ClassDecoratorDataFunc = (data: Data) => ClassDecorator; +export const classMetadataDecorator = (key: string): ClassDecoratorDataFunc => { + // eslint-disable-next-line @typescript-eslint/ban-types + return (data: Data) => (target: Function): void => { + Reflect.defineMetadata(key, data, target); + }; +}; + +export type MetaValue = MetaType | undefined; +export function getClassMetadata(DTOClass: Class, key: string): MetaValue { + return Reflect.getMetadata(key, DTOClass) as MetaValue; +} diff --git a/packages/query-graphql/src/decorators/hook.decorator.ts b/packages/query-graphql/src/decorators/hook.decorator.ts new file mode 100644 index 000000000..8cd8e0bd9 --- /dev/null +++ b/packages/query-graphql/src/decorators/hook.decorator.ts @@ -0,0 +1,63 @@ +import { Class, DeepPartial } from '@nestjs-query/core'; +import { + CreateManyInputType, + CreateOneInputType, + DeleteManyInputType, + DeleteOneInputType, + UpdateManyInputType, + UpdateOneInputType, +} from '../types'; +import { + BEFORE_CREATE_MANY, + BEFORE_CREATE_ONE, + BEFORE_DELETE_MANY, + BEFORE_DELETE_ONE, + BEFORE_UPDATE_MANY, + BEFORE_UPDATE_ONE, +} from './constants'; +import { getClassMetadata, classMetadataDecorator, MetaValue } from './decorator.utils'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type HookFunc = (instance: T, context: Context) => T | Promise; +export type CreateOneHook = HookFunc>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const BeforeCreateOne = classMetadataDecorator>(BEFORE_CREATE_ONE); +export function getCreateOneHook(DTOClass: Class): MetaValue> { + return getClassMetadata(DTOClass, BEFORE_CREATE_ONE); +} + +export type CreateManyHook = HookFunc>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const BeforeCreateMany = classMetadataDecorator>(BEFORE_CREATE_MANY); +export function getCreateManyHook(DTOClass: Class): MetaValue> { + return getClassMetadata(DTOClass, BEFORE_CREATE_MANY); +} + +export type UpdateOneHook = HookFunc>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const BeforeUpdateOne = classMetadataDecorator>(BEFORE_UPDATE_ONE); +export function getUpdateOneHook>(DTOClass: Class): MetaValue> { + return getClassMetadata(DTOClass, BEFORE_UPDATE_ONE); +} + +export type UpdateManyHook> = HookFunc>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const BeforeUpdateMany = classMetadataDecorator>(BEFORE_UPDATE_MANY); +export function getUpdateManyHook>( + DTOClass: Class, +): MetaValue> { + return getClassMetadata(DTOClass, BEFORE_UPDATE_MANY); +} + +export type DeleteOneHook = HookFunc; +export const BeforeDeleteOne = classMetadataDecorator(BEFORE_DELETE_ONE); +export function getDeleteOneHook(DTOClass: Class): MetaValue { + return getClassMetadata(DTOClass, BEFORE_DELETE_ONE); +} + +export type DeleteManyHook = HookFunc>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const BeforeDeleteMany = classMetadataDecorator>(BEFORE_DELETE_MANY); +export function getDeleteManyHook(DTOClass: Class): MetaValue> { + return getClassMetadata(DTOClass, BEFORE_DELETE_MANY); +} diff --git a/packages/query-graphql/src/decorators/index.ts b/packages/query-graphql/src/decorators/index.ts index 001bb2ff1..750b26f1b 100644 --- a/packages/query-graphql/src/decorators/index.ts +++ b/packages/query-graphql/src/decorators/index.ts @@ -16,3 +16,6 @@ export { ResolverSubscription, SubscriptionResolverMethodOpts } from './resolver export { InjectPubSub } from './inject-pub-sub.decorator'; export * from './skip-if.decorator'; export * from './aggregate-query-param.decorator'; +export * from './hook.decorator'; +export * from './mutation-args.decorator'; +export * from './decorator.utils'; diff --git a/packages/query-graphql/src/decorators/mutation-args.decorator.ts b/packages/query-graphql/src/decorators/mutation-args.decorator.ts new file mode 100644 index 000000000..2c966b70f --- /dev/null +++ b/packages/query-graphql/src/decorators/mutation-args.decorator.ts @@ -0,0 +1,24 @@ +import { Class } from '@nestjs-query/core'; +import { Args, GqlExecutionContext } from '@nestjs/graphql'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { composeDecorators } from './decorator.utils'; +import { transformAndValidate } from '../resolvers/helpers'; +import { MutationArgsType } from '../types'; +import { HookFunc } from './hook.decorator'; + +export const MutationArgs = ( + HookArgsClass: Class>, + hook?: HookFunc, +): ParameterDecorator => { + return composeDecorators( + Args(), + createParamDecorator(async (data: unknown, ctx: ExecutionContext) => { + const gqlContext = GqlExecutionContext.create(ctx); + const args = await transformAndValidate(HookArgsClass, gqlContext.getArgs()); + if (hook) { + return Object.assign(args, { input: hook(args.input, gqlContext.getContext()) }); + } + return args; + })(), + ); +}; diff --git a/packages/query-graphql/src/decorators/skip-if.decorator.ts b/packages/query-graphql/src/decorators/skip-if.decorator.ts index 5984733c9..46ebb92e4 100644 --- a/packages/query-graphql/src/decorators/skip-if.decorator.ts +++ b/packages/query-graphql/src/decorators/skip-if.decorator.ts @@ -1,35 +1,13 @@ +import { composeDecorators, ComposableDecorator, ComposedDecorator } from './decorator.utils'; /** * @internal * Wraps Args to allow skipping decorating * @param check - checker to run. * @param decorators - The decorators to apply */ -export function SkipIf( - check: () => boolean, - ...decorators: (MethodDecorator | PropertyDecorator | ClassDecorator | ParameterDecorator)[] -): MethodDecorator & PropertyDecorator & ClassDecorator & ParameterDecorator { +export function SkipIf(check: () => boolean, ...decorators: ComposableDecorator[]): ComposedDecorator { if (check()) { return (): void => {}; } - // eslint-disable-next-line @typescript-eslint/ban-types - return ( - // eslint-disable-next-line @typescript-eslint/ban-types - target: TFunction | object, - propertyKey?: string | symbol, - descriptorOrIndex?: TypedPropertyDescriptor | number, - ) => { - decorators.forEach((decorator) => { - if (target instanceof Function && !descriptorOrIndex) { - return (decorator as ClassDecorator)(target); - } - if (typeof descriptorOrIndex === 'number') { - return (decorator as ParameterDecorator)(target, propertyKey as string | symbol, descriptorOrIndex); - } - return (decorator as MethodDecorator | PropertyDecorator)( - target, - propertyKey as string | symbol, - descriptorOrIndex as TypedPropertyDescriptor, - ); - }); - }; + return composeDecorators(...decorators); } diff --git a/packages/query-graphql/src/index.ts b/packages/query-graphql/src/index.ts index 58cb5eca1..7e06c40a5 100644 --- a/packages/query-graphql/src/index.ts +++ b/packages/query-graphql/src/index.ts @@ -12,6 +12,18 @@ export { ReferenceTypeFunc, ReferenceDecoratorOpts, InjectPubSub, + BeforeCreateOne, + CreateOneHook, + BeforeCreateMany, + CreateManyHook, + BeforeUpdateOne, + UpdateOneHook, + BeforeUpdateMany, + UpdateManyHook, + BeforeDeleteOne, + DeleteOneHook, + BeforeDeleteMany, + DeleteManyHook, } from './decorators'; export * from './resolvers'; export * from './federation'; diff --git a/packages/query-graphql/src/resolvers/create.resolver.ts b/packages/query-graphql/src/resolvers/create.resolver.ts index 4bfcc2f6e..e76caadb4 100644 --- a/packages/query-graphql/src/resolvers/create.resolver.ts +++ b/packages/query-graphql/src/resolvers/create.resolver.ts @@ -7,7 +7,15 @@ import { Class, DeepPartial } from '@nestjs-query/core'; import { Args, ArgsType, InputType, PartialType, Resolver } from '@nestjs/graphql'; import omit from 'lodash.omit'; import { DTONames, getDTONames } from '../common'; -import { ResolverMutation, ResolverSubscription } from '../decorators'; +import { + ResolverMutation, + ResolverSubscription, + getCreateOneHook, + getCreateManyHook, + MutationArgs, + CreateOneHook, + CreateManyHook, +} from '../decorators'; import { EventType, getDTOEventName } from '../subscription'; import { CreateManyInputType, @@ -16,7 +24,7 @@ import { SubscriptionArgsType, SubscriptionFilterInputType, } from '../types'; -import { createSubscriptionFilter, transformAndValidate } from './helpers'; +import { createSubscriptionFilter } from './helpers'; import { BaseServiceResolver, ResolverClass, ServiceResolver, SubscriptionResolverOpts } from './resolver.interface'; export type CreatedEvent = { [eventName: string]: DTO }; @@ -71,6 +79,20 @@ const defaultCreateManyInput = (dtoNames: DTONames, InputDTO: Class): Clas return CM; }; +const lookupCreateOneHook = >( + DTOClass: Class, + CreateDTOClass: Class, +): CreateOneHook | undefined => { + return (getCreateOneHook(CreateDTOClass) ?? getCreateOneHook(DTOClass)) as CreateOneHook | undefined; +}; + +const lookupCreateManyHook = >( + DTOClass: Class, + CreateDTOClass: Class, +): CreateManyHook | undefined => { + return (getCreateManyHook(CreateDTOClass) ?? getCreateManyHook(DTOClass)) as CreateManyHook | undefined; +}; + /** * @internal * Mixin to add `create` graphql endpoints. @@ -91,7 +113,8 @@ export const Creatable = >(DTOClass: Class, CreateOneInput = defaultCreateOneInput(dtoNames, CreateDTOClass), CreateManyInput = defaultCreateManyInput(dtoNames, CreateDTOClass), } = opts; - + const createOneHook = lookupCreateOneHook(DTOClass, CreateDTOClass); + const createManyHook = lookupCreateManyHook(DTOClass, CreateDTOClass); const commonResolverOpts = omit( opts, 'dtoName', @@ -120,9 +143,8 @@ export const Creatable = >(DTOClass: Class, @Resolver(() => DTOClass, { isAbstract: true }) class CreateResolverBase extends BaseClass { @ResolverMutation(() => DTOClass, { name: `createOne${baseName}` }, commonResolverOpts, opts.one ?? {}) - async createOne(@Args() input: CO): Promise { - const createOne = await transformAndValidate(CO, input); - const created = await this.service.createOne(createOne.input.input); + async createOne(@MutationArgs(CO, createOneHook) input: CO): Promise { + const created = await this.service.createOne(input.input.input); if (enableOneSubscriptions) { await this.publishCreatedEvent(created); } @@ -130,9 +152,8 @@ export const Creatable = >(DTOClass: Class, } @ResolverMutation(() => [DTOClass], { name: `createMany${pluralBaseName}` }, commonResolverOpts, opts.many ?? {}) - async createMany(@Args() input: CM): Promise { - const createMany = await transformAndValidate(CM, input); - const created = await this.service.createMany(createMany.input.input); + async createMany(@MutationArgs(CM, createManyHook) input: CM): Promise { + const created = await this.service.createMany(input.input.input); if (enableManySubscriptions) { await Promise.all(created.map((c) => this.publishCreatedEvent(c))); } diff --git a/packages/query-graphql/src/resolvers/delete.resolver.ts b/packages/query-graphql/src/resolvers/delete.resolver.ts index 499e33227..a15fd440c 100644 --- a/packages/query-graphql/src/resolvers/delete.resolver.ts +++ b/packages/query-graphql/src/resolvers/delete.resolver.ts @@ -13,7 +13,13 @@ import { SubscriptionArgsType, SubscriptionFilterInputType, } from '../types'; -import { ResolverMutation, ResolverSubscription } from '../decorators'; +import { + getDeleteManyHook, + getDeleteOneHook, + MutationArgs, + ResolverMutation, + ResolverSubscription, +} from '../decorators'; import { createSubscriptionFilter, transformAndValidate } from './helpers'; export type DeletedEvent = { [eventName: string]: DTO }; @@ -63,6 +69,8 @@ export const Deletable = (DTOClass: Class, opts: DeleteResolverOpts
(DTOClass: Class, opts: DeleteResolverOpts
DTOClass, { isAbstract: true }) class DeleteResolverBase extends BaseClass { @ResolverMutation(() => DeleteOneResponse, { name: `deleteOne${baseName}` }, commonResolverOpts, opts.one ?? {}) - async deleteOne(@Args() input: DO): Promise> { + async deleteOne(@MutationArgs(DO, deleteOneHook) input: DO): Promise> { const deleteOne = await transformAndValidate(DO, input); const deletedResponse = await this.service.deleteOne(deleteOne.input.id); if (enableOneSubscriptions) { @@ -100,7 +108,7 @@ export const Deletable = (DTOClass: Class, opts: DeleteResolverOpts
DMR, { name: `deleteMany${pluralBaseName}` }, commonResolverOpts, opts.many ?? {}) - async deleteMany(@Args() input: DM): Promise { + async deleteMany(@MutationArgs(DM, deleteManyHook) input: DM): Promise { const deleteMany = await transformAndValidate(DM, input); const deleteManyResponse = await this.service.deleteMany(deleteMany.input.filter); if (enableManySubscriptions) { diff --git a/packages/query-graphql/src/resolvers/helpers.ts b/packages/query-graphql/src/resolvers/helpers.ts index 29ad91c53..d676d04cb 100644 --- a/packages/query-graphql/src/resolvers/helpers.ts +++ b/packages/query-graphql/src/resolvers/helpers.ts @@ -22,6 +22,7 @@ export const createSubscriptionFilter = , context: any) => boolean | Promise) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any return async (payload: any, variables: SubscriptionArgsType): Promise => { const { input } = variables; if (input) { diff --git a/packages/query-graphql/src/resolvers/update.resolver.ts b/packages/query-graphql/src/resolvers/update.resolver.ts index 5c7543c5c..ef1937f16 100644 --- a/packages/query-graphql/src/resolvers/update.resolver.ts +++ b/packages/query-graphql/src/resolvers/update.resolver.ts @@ -13,7 +13,15 @@ import { UpdateOneInputType, } from '../types'; import { BaseServiceResolver, ResolverClass, ServiceResolver, SubscriptionResolverOpts } from './resolver.interface'; -import { ResolverMutation, ResolverSubscription } from '../decorators'; +import { + ResolverMutation, + ResolverSubscription, + getUpdateOneHook, + getUpdateManyHook, + MutationArgs, + UpdateOneHook, + UpdateManyHook, +} from '../decorators'; import { createSubscriptionFilter, transformAndValidate } from './helpers'; export type UpdatedEvent = { [eventName: string]: DTO }; @@ -68,6 +76,20 @@ const defaultUpdateManyInput = >( return UM; }; +const lookupUpdateOneHook = >( + DTOClass: Class, + UpdateDTOClass: Class, +): UpdateOneHook | undefined => { + return (getUpdateOneHook(UpdateDTOClass) ?? getUpdateOneHook(DTOClass)) as UpdateOneHook | undefined; +}; + +const lookupUpdateManyHook = >( + DTOClass: Class, + UpdateDTOClass: Class, +): UpdateManyHook | undefined => { + return (getUpdateManyHook(UpdateDTOClass) ?? getUpdateManyHook(DTOClass)) as UpdateManyHook | undefined; +}; + /** * @internal * Mixin to add `update` graphql endpoints. @@ -90,6 +112,8 @@ export const Updateable = >(DTOClass: Class UpdateOneInput = defaultUpdateOneInput(dtoNames, UpdateDTOClass), UpdateManyInput = defaultUpdateManyInput(dtoNames, DTOClass, UpdateDTOClass), } = opts; + const updateOneHook = lookupUpdateOneHook(DTOClass, UpdateDTOClass); + const updateManyHook = lookupUpdateManyHook(DTOClass, UpdateDTOClass); const commonResolverOpts = omit( opts, @@ -118,7 +142,7 @@ export const Updateable = >(DTOClass: Class @Resolver(() => DTOClass, { isAbstract: true }) class UpdateResolverBase extends BaseClass { @ResolverMutation(() => DTOClass, { name: `updateOne${baseName}` }, commonResolverOpts, opts.one ?? {}) - async updateOne(@Args() input: UO): Promise { + async updateOne(@MutationArgs(UO, updateOneHook) input: UO): Promise { const updateOne = await transformAndValidate(UO, input); const { id, update } = updateOne.input; const updateResult = await this.service.updateOne(id, update); @@ -129,7 +153,7 @@ export const Updateable = >(DTOClass: Class } @ResolverMutation(() => UMR, { name: `updateMany${pluralBaseName}` }, commonResolverOpts, opts.many ?? {}) - async updateMany(@Args() input: UM): Promise { + async updateMany(@MutationArgs(UM, updateManyHook) input: UM): Promise { const updateMany = await transformAndValidate(UM, input); const { update, filter } = updateMany.input; const updateManyResponse = await this.service.updateMany(update, filter);