Skip to content

Commit

Permalink
feat(graphql,hooks): Add before hooks to graphql mutations
Browse files Browse the repository at this point in the history
  • Loading branch information
doug-martin committed Jul 23, 2020
1 parent bb72af0 commit 3448955
Show file tree
Hide file tree
Showing 11 changed files with 223 additions and 40 deletions.
8 changes: 8 additions & 0 deletions packages/query-graphql/src/decorators/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
41 changes: 41 additions & 0 deletions packages/query-graphql/src/decorators/decorator.utils.ts
Original file line number Diff line number Diff line change
@@ -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 <TFunction extends Function, Y>(
// eslint-disable-next-line @typescript-eslint/ban-types
target: TFunction | object,
propertyKey?: string | symbol,
descriptorOrIndex?: TypedPropertyDescriptor<Y> | 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<Y>,
);
});
};
}

type ClassDecoratorDataFunc<Data> = (data: Data) => ClassDecorator;
export const classMetadataDecorator = <Data>(key: string): ClassDecoratorDataFunc<Data> => {
// eslint-disable-next-line @typescript-eslint/ban-types
return (data: Data) => (target: Function): void => {
Reflect.defineMetadata(key, data, target);
};
};

export type MetaValue<MetaType> = MetaType | undefined;
export function getClassMetadata<DTO, MetaType>(DTOClass: Class<DTO>, key: string): MetaValue<MetaType> {
return Reflect.getMetadata(key, DTOClass) as MetaValue<MetaType>;
}
63 changes: 63 additions & 0 deletions packages/query-graphql/src/decorators/hook.decorator.ts
Original file line number Diff line number Diff line change
@@ -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<T, Context = any> = (instance: T, context: Context) => T | Promise<T>;
export type CreateOneHook<DTO> = HookFunc<CreateOneInputType<DTO>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const BeforeCreateOne = classMetadataDecorator<CreateOneHook<any>>(BEFORE_CREATE_ONE);
export function getCreateOneHook<DTO>(DTOClass: Class<DTO>): MetaValue<CreateOneHook<DTO>> {
return getClassMetadata(DTOClass, BEFORE_CREATE_ONE);
}

export type CreateManyHook<DTO> = HookFunc<CreateManyInputType<DTO>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const BeforeCreateMany = classMetadataDecorator<CreateManyHook<any>>(BEFORE_CREATE_MANY);
export function getCreateManyHook<DTO>(DTOClass: Class<DTO>): MetaValue<CreateManyHook<DTO>> {
return getClassMetadata(DTOClass, BEFORE_CREATE_MANY);
}

export type UpdateOneHook<DTO> = HookFunc<UpdateOneInputType<DTO>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const BeforeUpdateOne = classMetadataDecorator<UpdateOneHook<any>>(BEFORE_UPDATE_ONE);
export function getUpdateOneHook<DTO, U extends DeepPartial<DTO>>(DTOClass: Class<DTO>): MetaValue<UpdateOneHook<U>> {
return getClassMetadata(DTOClass, BEFORE_UPDATE_ONE);
}

export type UpdateManyHook<DTO, U extends DeepPartial<DTO>> = HookFunc<UpdateManyInputType<DTO, U>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const BeforeUpdateMany = classMetadataDecorator<UpdateManyHook<any, any>>(BEFORE_UPDATE_MANY);
export function getUpdateManyHook<DTO, U extends DeepPartial<DTO>>(
DTOClass: Class<DTO>,
): MetaValue<UpdateManyHook<DTO, U>> {
return getClassMetadata(DTOClass, BEFORE_UPDATE_MANY);
}

export type DeleteOneHook = HookFunc<DeleteOneInputType>;
export const BeforeDeleteOne = classMetadataDecorator<DeleteOneHook>(BEFORE_DELETE_ONE);
export function getDeleteOneHook<DTO>(DTOClass: Class<DTO>): MetaValue<DeleteOneHook> {
return getClassMetadata(DTOClass, BEFORE_DELETE_ONE);
}

export type DeleteManyHook<DTO> = HookFunc<DeleteManyInputType<DTO>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const BeforeDeleteMany = classMetadataDecorator<DeleteManyHook<any>>(BEFORE_DELETE_MANY);
export function getDeleteManyHook<DTO>(DTOClass: Class<DTO>): MetaValue<DeleteManyHook<DTO>> {
return getClassMetadata(DTOClass, BEFORE_DELETE_MANY);
}
3 changes: 3 additions & 0 deletions packages/query-graphql/src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
24 changes: 24 additions & 0 deletions packages/query-graphql/src/decorators/mutation-args.decorator.ts
Original file line number Diff line number Diff line change
@@ -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 = <HookType>(
HookArgsClass: Class<MutationArgsType<HookType>>,
hook?: HookFunc<HookType>,
): 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;
})(),
);
};
28 changes: 3 additions & 25 deletions packages/query-graphql/src/decorators/skip-if.decorator.ts
Original file line number Diff line number Diff line change
@@ -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 <TFunction extends Function, Y>(
// eslint-disable-next-line @typescript-eslint/ban-types
target: TFunction | object,
propertyKey?: string | symbol,
descriptorOrIndex?: TypedPropertyDescriptor<Y> | 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<Y>,
);
});
};
return composeDecorators(...decorators);
}
12 changes: 12 additions & 0 deletions packages/query-graphql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
39 changes: 30 additions & 9 deletions packages/query-graphql/src/resolvers/create.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<DTO> = { [eventName: string]: DTO };
Expand Down Expand Up @@ -71,6 +79,20 @@ const defaultCreateManyInput = <C>(dtoNames: DTONames, InputDTO: Class<C>): Clas
return CM;
};

const lookupCreateOneHook = <DTO, C extends DeepPartial<DTO>>(
DTOClass: Class<DTO>,
CreateDTOClass: Class<C>,
): CreateOneHook<C> | undefined => {
return (getCreateOneHook(CreateDTOClass) ?? getCreateOneHook(DTOClass)) as CreateOneHook<C> | undefined;
};

const lookupCreateManyHook = <DTO, C extends DeepPartial<DTO>>(
DTOClass: Class<DTO>,
CreateDTOClass: Class<C>,
): CreateManyHook<C> | undefined => {
return (getCreateManyHook(CreateDTOClass) ?? getCreateManyHook(DTOClass)) as CreateManyHook<C> | undefined;
};

/**
* @internal
* Mixin to add `create` graphql endpoints.
Expand All @@ -91,7 +113,8 @@ export const Creatable = <DTO, C extends DeepPartial<DTO>>(DTOClass: Class<DTO>,
CreateOneInput = defaultCreateOneInput(dtoNames, CreateDTOClass),
CreateManyInput = defaultCreateManyInput(dtoNames, CreateDTOClass),
} = opts;

const createOneHook = lookupCreateOneHook(DTOClass, CreateDTOClass);
const createManyHook = lookupCreateManyHook(DTOClass, CreateDTOClass);
const commonResolverOpts = omit(
opts,
'dtoName',
Expand Down Expand Up @@ -120,19 +143,17 @@ export const Creatable = <DTO, C extends DeepPartial<DTO>>(DTOClass: Class<DTO>,
@Resolver(() => DTOClass, { isAbstract: true })
class CreateResolverBase extends BaseClass {
@ResolverMutation(() => DTOClass, { name: `createOne${baseName}` }, commonResolverOpts, opts.one ?? {})
async createOne(@Args() input: CO): Promise<DTO> {
const createOne = await transformAndValidate(CO, input);
const created = await this.service.createOne(createOne.input.input);
async createOne(@MutationArgs(CO, createOneHook) input: CO): Promise<DTO> {
const created = await this.service.createOne(input.input.input);
if (enableOneSubscriptions) {
await this.publishCreatedEvent(created);
}
return created;
}

@ResolverMutation(() => [DTOClass], { name: `createMany${pluralBaseName}` }, commonResolverOpts, opts.many ?? {})
async createMany(@Args() input: CM): Promise<DTO[]> {
const createMany = await transformAndValidate(CM, input);
const created = await this.service.createMany(createMany.input.input);
async createMany(@MutationArgs(CM, createManyHook) input: CM): Promise<DTO[]> {
const created = await this.service.createMany(input.input.input);
if (enableManySubscriptions) {
await Promise.all(created.map((c) => this.publishCreatedEvent(c)));
}
Expand Down
14 changes: 11 additions & 3 deletions packages/query-graphql/src/resolvers/delete.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DTO> = { [eventName: string]: DTO };
Expand Down Expand Up @@ -63,6 +69,8 @@ export const Deletable = <DTO>(DTOClass: Class<DTO>, opts: DeleteResolverOpts<DT
const deletedOneEvent = getDTOEventName(EventType.DELETED_ONE, DTOClass);
const deletedManyEvent = getDTOEventName(EventType.DELETED_MANY, DTOClass);
const { DeleteOneInput = DeleteOneInputType(), DeleteManyInput = defaultDeleteManyInput(dtoNames, DTOClass) } = opts;
const deleteOneHook = getDeleteOneHook(DTOClass);
const deleteManyHook = getDeleteManyHook(DTOClass);
const DMR = DeleteManyResponseType();

const commonResolverOpts = omit(opts, 'dtoName', 'one', 'many', 'DeleteOneInput', 'DeleteManyInput');
Expand Down Expand Up @@ -90,7 +98,7 @@ export const Deletable = <DTO>(DTOClass: Class<DTO>, opts: DeleteResolverOpts<DT
@Resolver(() => DTOClass, { isAbstract: true })
class DeleteResolverBase extends BaseClass {
@ResolverMutation(() => DeleteOneResponse, { name: `deleteOne${baseName}` }, commonResolverOpts, opts.one ?? {})
async deleteOne(@Args() input: DO): Promise<Partial<DTO>> {
async deleteOne(@MutationArgs(DO, deleteOneHook) input: DO): Promise<Partial<DTO>> {
const deleteOne = await transformAndValidate(DO, input);
const deletedResponse = await this.service.deleteOne(deleteOne.input.id);
if (enableOneSubscriptions) {
Expand All @@ -100,7 +108,7 @@ export const Deletable = <DTO>(DTOClass: Class<DTO>, opts: DeleteResolverOpts<DT
}

@ResolverMutation(() => DMR, { name: `deleteMany${pluralBaseName}` }, commonResolverOpts, opts.many ?? {})
async deleteMany(@Args() input: DM): Promise<DeleteManyResponse> {
async deleteMany(@MutationArgs(DM, deleteManyHook) input: DM): Promise<DeleteManyResponse> {
const deleteMany = await transformAndValidate(DM, input);
const deleteManyResponse = await this.service.deleteMany(deleteMany.input.filter);
if (enableManySubscriptions) {
Expand Down
1 change: 1 addition & 0 deletions packages/query-graphql/src/resolvers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const createSubscriptionFilter = <DTO, Input extends SubscriptionFilterIn
payloadKey: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ((payload: any, variables: SubscriptionArgsType<Input>, context: any) => boolean | Promise<boolean>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return async (payload: any, variables: SubscriptionArgsType<Input>): Promise<boolean> => {
const { input } = variables;
if (input) {
Expand Down
Loading

0 comments on commit 3448955

Please sign in to comment.