diff --git a/documentation/docs/graphql/hooks.mdx b/documentation/docs/graphql/hooks.mdx index a9030749e..96020dad8 100644 --- a/documentation/docs/graphql/hooks.mdx +++ b/documentation/docs/graphql/hooks.mdx @@ -413,46 +413,59 @@ import { TodoItemUpdateDTO } from './dto/todo-item-update.dto'; // create the base input type @InputType() -export class UpdateManyTodoItemsInput extends UpdateManyInputType(TodoItemDTO, TodoItemUpdateDTO) {} +export class MarkTodoItemsAsCompletedInput extends UpdateManyInputType(TodoItemDTO, TodoItemUpdateDTO) {} // Wrap the input in the MutationArgsType to provide a uniform format for all mutations // The `MutationArgsType` is a thin wrapper that names the args as input @ArgsType() -export class UpdateManyTodoItemsArgs extends MutationArgsType(UpdateManyTodoItemsInput) {} +export class MarkTodoItemsAsCompletedArgs extends MutationArgsType(UpdateManyTodoItemsInput) {} ``` Now we can use our new types in the resolver. -```ts title="todo-item/todo-item.resolver.ts" {14,15} +```ts title="todo-item/todo-item.resolver.ts" {16,17} import { InjectQueryService, mergeFilter, QueryService, UpdateManyResponse } from '@nestjs-query/core'; import { HookTypes, HookInterceptor, MutationHookArgs, UpdateManyResponseType } from '@nestjs-query/query-graphql'; import { UseInterceptors } from '@nestjs/common'; import { Mutation, Resolver } from '@nestjs/graphql'; import { TodoItemDTO } from './dto/todo-item.dto'; import { TodoItemEntity } from './todo-item.entity'; -import { UpdateManyTodoItemsArgs } from './types'; +import { MarkTodoItemsAsCompletedArgs } from './types'; +import { TodoItemUpdateDTO } from './dto/todo-item-update.dto'; @Resolver(() => TodoItemDTO) export class TodoItemResolver { constructor(@InjectQueryService(TodoItemEntity) readonly service: QueryService) {} + // Set the return type to the TodoItemConnection @Mutation(() => UpdateManyResponseType()) - @UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO)) - markTodoItemsAsCompleted(@MutationHookArgs() { input }: UpdateManyTodoItemsArgs): Promise { + @UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemUpdateDTO)) + markTodoItemsAsCompleted(@MutationHookArgs() { input }: MarkTodoItemsAsCompletedArgs): Promise { return this.service.updateMany( - { ...input.update, completed: false }, + { ...input.update, completed: true }, mergeFilter(input.filter, { completed: { is: false } }), ); } } + ``` The first thing to notice is the ```ts -@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO)) +@UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemUpdateDTO)) ``` This interceptor adds the correct hook to the `context` to be used by the param decorator. +There are a few things to take note of: +* The `HookTypes.BEFORE_UPDATE_MANY` lets the interceptor know we are wanting the BeforeUpdateMany hook to be used +for this mutation. +* We use the `TodoItemUpdateDTO`, that is because the `@BeforeUpdateMany` decorator was put on the +`TodoItemUpdateDTO` not the `TodoItemDTO`. + +:::warning +When using the HookInterceptor you must use the DTO that you added the hook decorator to. +::: + :::note In this example we bind the `BEFORE_UPDATE_MANY` hook, you can use any of the hooks available to bind to the correct one when `creating`, `updating`, or `deleting` records. diff --git a/examples/hooks/e2e/todo-item.resolver.spec.ts b/examples/hooks/e2e/todo-item.resolver.spec.ts index 9c9e5dc45..ef36a89c4 100644 --- a/examples/hooks/e2e/todo-item.resolver.spec.ts +++ b/examples/hooks/e2e/todo-item.resolver.spec.ts @@ -1168,6 +1168,58 @@ describe('TodoItemResolver (hooks - e2e)', () => { })); }); + describe('markAllAsCompleted', () => { + it('should call the beforeUpdateMany hook when marking all items as completed', async () => { + const queryService = app.get>(getQueryServiceToken(TodoItemEntity)); + const todoItems = await queryService.createMany([ + { title: 'To Be Marked As Completed - 1', completed: false }, + { title: 'To Be Marked As Completed - 2', completed: false }, + ]); + expect(todoItems).toHaveLength(2); + const ids = todoItems.map((ti) => ti.id); + return request(app.getHttpServer()) + .post('/graphql') + .set({ + [AUTH_HEADER_NAME]: config.auth.header, + [USER_HEADER_NAME]: 'e2e', + }) + .send({ + operationName: null, + variables: {}, + query: `mutation { + markTodoItemsAsCompleted( + input: { + filter: {id: { in: [${ids.join(',')}]} }, + update: { } + } + ) { + updatedCount + } + }`, + }) + .expect(200, { + data: { + markTodoItemsAsCompleted: { + updatedCount: 2, + }, + }, + }) + .then(async () => { + const updatedTodoItems = await queryService.query({ filter: { id: { in: ids } } }); + expect( + updatedTodoItems.map((ti) => ({ + title: ti.title, + completed: ti.completed, + updatedBy: ti.updatedBy, + })), + ).toEqual([ + { title: 'To Be Marked As Completed - 1', completed: true, updatedBy: 'e2e@nestjs-query.com' }, + { title: 'To Be Marked As Completed - 2', completed: true, updatedBy: 'e2e@nestjs-query.com' }, + ]); + }); + }); + }); + afterAll(async () => { await app.close(); }); diff --git a/examples/hooks/src/todo-item/todo-item.module.ts b/examples/hooks/src/todo-item/todo-item.module.ts index 6d6b4b065..1f4492541 100644 --- a/examples/hooks/src/todo-item/todo-item.module.ts +++ b/examples/hooks/src/todo-item/todo-item.module.ts @@ -7,9 +7,11 @@ import { TodoItemInputDTO } from './dto/todo-item-input.dto'; import { TodoItemUpdateDTO } from './dto/todo-item-update.dto'; import { TodoItemDTO } from './dto/todo-item.dto'; import { TodoItemEntity } from './todo-item.entity'; +import { TodoItemResolver } from './todo-item.resolver'; const guards = [AuthGuard]; @Module({ + providers: [TodoItemResolver], imports: [ NestjsQueryGraphQLModule.forFeature({ imports: [NestjsQueryTypeOrmModule.forFeature([TodoItemEntity]), AuthModule], diff --git a/examples/hooks/src/todo-item/todo-item.resolver.ts b/examples/hooks/src/todo-item/todo-item.resolver.ts index d836dfd87..ac2902564 100644 --- a/examples/hooks/src/todo-item/todo-item.resolver.ts +++ b/examples/hooks/src/todo-item/todo-item.resolver.ts @@ -1,10 +1,12 @@ import { InjectQueryService, mergeFilter, QueryService, UpdateManyResponse } from '@nestjs-query/core'; import { HookTypes, HookInterceptor, MutationHookArgs, UpdateManyResponseType } from '@nestjs-query/query-graphql'; -import { UseInterceptors } from '@nestjs/common'; +import { UseInterceptors, UseGuards } from '@nestjs/common'; import { Mutation, Resolver } from '@nestjs/graphql'; import { TodoItemDTO } from './dto/todo-item.dto'; import { TodoItemEntity } from './todo-item.entity'; -import { UpdateManyTodoItemsArgs } from './types'; +import { MarkTodoItemsAsCompletedArgs } from './types'; +import { AuthGuard } from '../auth/auth.guard'; +import { TodoItemUpdateDTO } from './dto/todo-item-update.dto'; @Resolver(() => TodoItemDTO) export class TodoItemResolver { @@ -12,10 +14,11 @@ export class TodoItemResolver { // Set the return type to the TodoItemConnection @Mutation(() => UpdateManyResponseType()) - @UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemDTO)) - markTodoItemsAsCompleted(@MutationHookArgs() { input }: UpdateManyTodoItemsArgs): Promise { + @UseGuards(AuthGuard) + @UseInterceptors(HookInterceptor(HookTypes.BEFORE_UPDATE_MANY, TodoItemUpdateDTO)) + markTodoItemsAsCompleted(@MutationHookArgs() { input }: MarkTodoItemsAsCompletedArgs): Promise { return this.service.updateMany( - { ...input.update, completed: false }, + { ...input.update, completed: true }, mergeFilter(input.filter, { completed: { is: false } }), ); } diff --git a/examples/hooks/src/todo-item/types.ts b/examples/hooks/src/todo-item/types.ts index b5d0fce26..8cb5d7ac5 100644 --- a/examples/hooks/src/todo-item/types.ts +++ b/examples/hooks/src/todo-item/types.ts @@ -1,10 +1,13 @@ import { MutationArgsType, UpdateManyInputType } from '@nestjs-query/query-graphql'; -import { ArgsType, InputType } from '@nestjs/graphql'; +import { ArgsType, InputType, OmitType } from '@nestjs/graphql'; import { TodoItemDTO } from './dto/todo-item.dto'; import { TodoItemUpdateDTO } from './dto/todo-item-update.dto'; @InputType() -class UpdateManyTodoItemsInput extends UpdateManyInputType(TodoItemDTO, TodoItemUpdateDTO) {} +class MarkTodoItemAsCompleted extends OmitType(TodoItemUpdateDTO, ['completed']) {} + +@InputType() +class MarkTodoItemsAsCompletedInput extends UpdateManyInputType(TodoItemDTO, MarkTodoItemAsCompleted) {} @ArgsType() -export class UpdateManyTodoItemsArgs extends MutationArgsType(UpdateManyTodoItemsInput) {} +export class MarkTodoItemsAsCompletedArgs extends MutationArgsType(MarkTodoItemsAsCompletedInput) {} diff --git a/packages/query-graphql/src/interceptors/hook.interceptor.ts b/packages/query-graphql/src/interceptors/hook.interceptor.ts index 8d823d73d..10eb13494 100644 --- a/packages/query-graphql/src/interceptors/hook.interceptor.ts +++ b/packages/query-graphql/src/interceptors/hook.interceptor.ts @@ -1,43 +1,33 @@ import { Class } from '@nestjs-query/core'; -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; +import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; +import { getHookForType } from '../decorators'; import { HookTypes, Hook, getHookToken } from '../hooks'; export type HookContext> = { hook?: H }; -export function HookInterceptor(type: HookTypes, ...DTOClasses: Class[]): Class { - const tokens = DTOClasses.map((Cls) => getHookToken(type, Cls)); +class DefaultHookInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler) { + return next.handle(); + } +} +export function HookInterceptor(type: HookTypes, ...DTOClasses: Class[]): Class { + const HookedClass = DTOClasses.find((Cls) => getHookForType(type, Cls)); + if (!HookedClass) { + return DefaultHookInterceptor; + } + const hookToken = getHookToken(type, HookedClass); @Injectable() class Interceptor implements NestInterceptor { - private hook?: Hook; - - constructor(private readonly moduleRef: ModuleRef) { - this.hook = this.lookupHook(); - } + constructor(@Inject(hookToken) readonly hook: Hook) {} intercept(context: ExecutionContext, next: CallHandler) { const gqlContext = GqlExecutionContext.create(context); const ctx = gqlContext.getContext>>(); - if (this.hook) { - ctx.hook = this.hook; - } + ctx.hook = this.hook; return next.handle(); } - - private lookupHook(): Hook | undefined { - return tokens.reduce((h: Hook | undefined, hookToken) => { - if (h) { - return h; - } - try { - return this.moduleRef.get>(hookToken); - } catch { - return undefined; - } - }, undefined); - } } Object.defineProperty(Interceptor, 'name', { writable: false,