nestjs / graphql

GraphQL (TypeScript) module for Nest framework (node.js) 🍷
https://docs.nestjs.com/graphql/quick-start
MIT License
1.46k stars 396 forks source link

Batching and caching #2

Closed marvinroger closed 6 years ago

marvinroger commented 6 years ago

Hi!

I just discovered this framework and I have to say it’s awesome. Kudos!

The GraphQL module is great, but I did not find any information regarding how to do batching and caching, which is pretty required to avoid a big waste of resources (see https://github.com/facebook/dataloader).

Given the fact that resolvers are automatically mapped, I guess there’s currently no way to do that, right? An integration with dataloader would be awesome, if not mandatory for any medium to large application.

And, happy new year, by the way. ☺️

marvinroger commented 6 years ago

Well, never mind! Resolvers are actually still functions, so we can use a dataloader there... sorry.

j commented 6 years ago

@marvinroger any idea on clean ways to initialize a new DataLoader per request NOT in a resolver and using Nest's DI?

marvinroger commented 6 years ago

I was thinking about this, but I did not have time to implement this in my side-project yet. I thought about a ‘dataloader’ module which would create every dataloaders. A middleware might be added right before the graphql one, it would call a method on the ‘dataloader’ module that would return a map of all dataloaders. Then, this map can be added as context for the graphql resolvers

Jonatthu commented 6 years ago

Any example of how to use this? @marvinroger

obedm503 commented 6 years ago

this is how I ended up implementing it

// app.module.ts
import {
  Module,
  MiddlewaresConsumer,
  NestModule,
  RequestMethod,
} from '@nestjs/common';
import { GraphQLModule, GraphQLFactory } from '@nestjs/graphql';
import { graphqlExpress } from 'apollo-server-express';
import * as DataLoader from 'dataloader';
import { Request } from 'express';                                                                                                                                

import { CatService } from './cat/cat.service';
import { CatResolver } from './cat/cat.resolver';

@Module({
  imports: [
    GraphQLModule,
  ],
  components: [
    CatService,
    CatResolver,
  ],
})
export class ApplicationModule implements NestModule {
  constructor(
    private readonly graphQLFactory: GraphQLFactory,
    private readonly catService: CatService,
  ) {}

  configure(consumer: MiddlewaresConsumer) {
    const typeDefs = this.graphQLFactory.mergeTypesByPaths('./**/*.gql');
    const schema = this.graphQLFactory.createSchema({ typeDefs });
    consumer
      .apply(
        graphqlExpress((req: Request) => {
          // this function is executed on every request
          // so a new catLoader is created each time

          const context = {
            catLoader: new DataLoader((catIds: string[]) =>
              // then your service can query you db or something
              // just make sure whatever CatService#getMany returns 
              // in the same order as the ids as per DataLoader rules
              this.catService.getMany(catIds),
            ),
          };
          return {
            schema,
            rootValue: req,
            context,
          };
        }),
      )
      .forRoutes({ path: '/graphql', method: RequestMethod.ALL });
  }
}

// ./cat/cat.resolver.ts
import { Query, Resolver } from '@nestjs/graphql';
import { Request } from 'express';

@Resolver('Cat')
export class CatResolver {
  @Query('cat')
  get(req: Request, args, context) {
    // use the catLoader that comes through the "context"
    return context.catLoader.load(id);
  }
}
Jonatthu commented 6 years ago

@obedm503 That's great thanks, Do you have more online references or different use cases for this one?

obedm503 commented 6 years ago

@Jonatthu I based it on this https://youtu.be/2cSVIWDUSn4?t=4m6s. even tho he's using just express without nest, the same concepts apply

pelssersconsultancy commented 6 years ago

Hi all, I think the example given is pretty useless. You typically would want to use a dataloader for property resolvers, so the question is... how can we pass a dataloader to the property resolver function? I managed to inject the dataloaders using the example but a property resolver does not receive this context as parameter

obedm503 commented 6 years ago

@pelssersconsultancy all resolvers get the context, the signature always is

@ResolveProperty()
propertyResolver(root, args, context, info){}
pelssersconsultancy commented 6 years ago

@obedm503 I missed the args in my property resolver.. thx for this pointer

caseyduquettesc commented 6 years ago

If anyone is looking for a more "Nest-y" recipe. Here's a DataLoaderInterceptor I use to create new loaders per request. Things to keep in mind -- in GQL requests, the interceptors are triggered multiple times so the interceptor needs to check if it actually needs to create the loaders or not. Secondly, because the interceptor isn't executed before the GQL context creation, the GQL can't assign the actual data loaders because they don't yet exist. Instead, I've wrapped them in a function to be called by any resolvers that want the loaders.

custom.d.ts

/** Patch the Request type to know about custom properties we assign */
declare namespace Express {
  export interface Request {
    id?: string;
    user?: string;
    dataLoaders: import('./src/common/interceptors/dataloader.interceptor').DataLoaders;
  }
}

src/common/utils.ts

import { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Request } from 'express';
import { GraphQLResolveInfo } from 'graphql';

export function getRequestFromContext(context: ExecutionContext): Request {
  const request = context.switchToHttp().getRequest<Request>();

  // Graphql endpoints need a context creation
  if (!request) {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  } else {
    // Interestingly, graphql field resolvers pass through the guards again. I suppose that's good?
    // These executions however provide different inputs than a fresh Http or GQL request.
    // In order to authenticate these, we can retrieve the original request from the context
    // that we configured in the GraphQL options in app.module.
    // I assign a user to every request in a middleware not shown here
    if (!request.user) {
      const [parent, , ctx, info]: [any, never, any, GraphQLResolveInfo] = context.getArgs();

      // Checking if this looks like a GQL subquery, is this hacky?
      if (parent && info.parentType) {
        return ctx.req;
      }
    }

    return request;
  }
}

src/modules/item/item.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import autobind from 'autobind-decorator';
import { Repository } from 'typeorm';

import { RelatedItem } from '../related-item/related-item.entity';
import { Item } from './item.entity';

@Injectable()
export class ItemService {
  constructor(@InjectRepository(Item) private readonly itemRepository: Repository<Item>) {}

  public async findAll(): Promise<Item[]> {
    return await this.itemRepository.find();
  }

  public async findOneById(id: number): Promise<Item | undefined> {
    return await this.itemRepository.findOne({ where: { id } });
  }

  @autobind
  public async relatedItemsOfItems(ids: number[]): Promise<(RelatedItem | undefined)[]> {
    const items = await this.itemRepository
      .createQueryBuilder('item')
      .leftJoinAndSelect('item.relatedItem', 'relatedItem')
      .where('item.id IN (:...ids)', { ids })
      .getMany();
    return items.map(item => item.relatedItem);
  }
}

src/common/interceptors/dataloader.interceptor.ts

import { ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import DataLoader from 'dataloader';
import { Observable } from 'rxjs';

import { MyLogger } from '../../logger/my-logger.service';
import { ItemService } from '../../modules/item/item.service';
import { RelatedItem } from '../../modules/related-item/related-item.entity';
import { getRequestFromContext } from '../utils';

/**
 * The DataLoaders type available on the request.
 * In custom.d.ts, I've set this type on request
 */
export interface DataLoaders {
  relatedItemLoader: DataLoader<number, RelatedItem | undefined>;
}

/**
 * GQL context function type to get DataLoaders. When the GQL context is created, the interceptor
 * hasn't actually run yet, so a function is provided to return them at time of execution.
 */
export type GetDataLoaders = () => DataLoaders;

/**
 * Creates new instances of DataLoaders on every request and makes them available on `request.dataLoaders`.
 */
@Injectable()
export class DataLoaderInterceptor implements NestInterceptor {
  constructor(private readonly logger: MyLogger, private readonly itemService: ItemService) {}

  public intercept(context: ExecutionContext, call$: Observable<any>): Observable<any> {
    const request = getRequestFromContext(context);

    // If the request already has data loaders, then do not create them again or the benefits are negated.
    if (request.dataLoaders) {
      this.logger.debug('Data loaders exist', this.constructor.name);
    } else {
      this.logger.debug('Creating data loaders', this.constructor.name);

      // Create new instances of DataLoaders per request
      request.dataLoaders = {
        relatedItemLoader: new DataLoader<number, RelatedItem | undefined>(this.itemService.relatedItemsOfItems),
      };
    }

    return call$;
  }
}

src/modules/item/item.resolver.ts

import { Args, Context, Parent, Query, ResolveProperty, Resolver } from '@nestjs/graphql';

import { GetDataLoaders } from '../../common/interceptors/dataloader.interceptor';
import { RelatedItem } from '../related-item/related-item.entity';
import { Item } from './item.entity';
import { ItemService } from './item.service';

@Resolver('Item')
export class ItemResolver {
  constructor(private readonly itemService: ItemService) {}

  @Query()
  public async getItems(): Promise<Item[]> {
    return this.itemService.findAll();
  }

  @Query('item')
  public async getItem(@Args('id') id: number): Promise<Item | undefined> {
    return await this.itemService.findOneById(id);
  }

  @ResolveProperty('relatedItem')
  public async getRelatedItem(
    @Parent() item: Item,
    @Context('getDataLoaders') getDataLoaders: GetDataLoaders,
  ): Promise<RelatedItem | undefined> {
    return getDataLoaders().relatedItemLoader.load(item.id);
  }
}

src/modules/application/app.module.ts

import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { GraphQLModule } from '@nestjs/graphql';
import { Request } from 'express';
import depthLimit from 'graphql-depth-limit';
import { join } from 'path';

import { DataLoaderInterceptor } from '../../common/interceptors/dataloader.interceptor';
import { CSPMiddleware } from '../../common/middlewares/csp.middleware';
import { CSRFMiddleware } from '../../common/middlewares/csrf.middleware';
import { RequestLoggerMiddleware } from '../../common/middlewares/request-logger.middleware';
import { ThrottleMiddleware } from '../../common/middlewares/throttle.middleware';
import { LoggerModule } from '../../logger/my-logger.module';
import { MyLogger } from '../../logger/my-logger.service';
import { DatabaseModule } from '../database/database.module';
import { ItemModule } from '../item/item.module';
import { RelatedItemModule } from '../related-item/related-item.module';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    DatabaseModule,
    GraphQLModule.forRootAsync({
      imports: [LoggerModule],
      inject: [MyLogger],
      useFactory: async (logger: MyLogger) => ({
        context: ({ req }: { req: Request }) => ({
          req,
          getDataLoaders: () => req.dataLoaders,
        }),
        definitions: {
          path: join(process.cwd(), '../shared/src/graphql.schema.ts'),
        },
        formatError: (error: Error) => {
          logger.error(error);
          return error;
        },
        typePaths: ['src/modules/**/*.graphql'],
        validationRules: [depthLimit(10)],
      }),
    }),
    LoggerModule,
    ItemModule,
    RelatedItemModule,
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_INTERCEPTOR,
      useClass: DataLoaderInterceptor,
    },
  ],
})
export class AppModule implements NestModule {
  public configure(consumer: MiddlewareConsumer): void {
    consumer
      .apply(RequestLoggerMiddleware, CSPMiddleware, ThrottleMiddleware, CSRFMiddleware)
      .forRoutes({ path: '*', method: RequestMethod.ALL });
  }
}
mohaalak commented 6 years ago

I've written a more generic approach to this with the help of decorator and moduleRef and interceptors, first of all, there is an interface for writing data loader wrapper

import DataLoader from 'dataloader';
export interface NestDataLoader {
  /**
   * Should return a new instance of dataloader each time
   */
  generateDataLoader(): DataLoader<any, any>;
}

then we make a decorator

import { ReflectMetadata, Type } from '@nestjs/common';
import { NestDataLoader } from './dataloader.interface';

/**
 * it's just a decorator for reflecting metaData
 * @param loader class that implement nestDataLoader
 */
export const Loader = (loader: Type<NestDataLoader>) =>
  ReflectMetadata('dataloader', loader);

now let's make data loader then I'll tell you how can we inject it to our resolver function

import { NestDataLoader } from 'src/common/dataloader.interface';
import { UserService } from './users.service';
import { Injectable } from '@nestjs/common';
import * as DataLoader from 'dataloader';
import { User } from './model/user';

@Injectable()
export class UserLoader implements NestDataLoader {
  constructor(private readonly userService: UserService) {}

  generateDataLoader(): DataLoader<any, any> {
    // it should instantiate a data laoder each time
    return new DataLoader<number, User>(this.userService.findMany);
  }
}

here we can see that we added @Injectable() so in the constructor we can get any service that we want to use for our data loader, and generateDataLoader should generate a new data loader it will call on each request then we should add it to our module's providers

now how we can use this data loader in our resolver with a global interceptor

import {
  NestInterceptor,
  ExecutionContext,
  Injectable,
  Type,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { GqlExecutionContext } from 'nest-type-graphql';
import { Reflector, ModuleRef } from '@nestjs/core';
import { NestDataLoader } from './dataloader.interface';

@Injectable()
export class DataLoaderInterceptor implements NestInterceptor {
  constructor(
    private readonly reflector: Reflector,
    private readonly moduleRef: ModuleRef,
  ) {}

  intercept(context: ExecutionContext, call$: Observable<any>) {
    // we get from reflector if there is requested any dataloader for this handler
    const type = this.reflector.get<Type<NestDataLoader>>(
      'dataloader',
      context.getHandler(),
    );

    if (type) {
      // GqlExecutionContext is available in @nestjs/graphql also nest-type-graphql
      const graphqlExecutionContext = GqlExecutionContext.create(context);
      const ctx = graphqlExecutionContext.getContext();
      // check if we have add this dataloader on context or not and name it the loader class
      if (!ctx[type.name]) {
        /*
        module ref will get the injected data loader {strict: false} is there
        so it search imported modules too
        **/
        ctx[type.name] = this.moduleRef
          .get<NestDataLoader>(type, { strict: false })
          .generateDataLoader();
      }
    }

    return call$;
  }
}

I used reflector to get the reflection that we used in our decorator, and then I check if this dataloader is loaded before if not then I get an instance of Loader Class that we wrote, with moduleRef with all services injected, now we generate a new DataLoader and provide it in the context of graphql.

now we can get in resolver this way

class PostResolver {
...
   @Loader(UserLoader)
  user(
    @Parent() post: Post,
    @Context('UserLoader') userLoader: Dataloader<number, User>,
  ) {
    return userLoader.load(post.userId);
  }
}

pay attention that in @Loader we provide the Class but in @Context we provide the name of class in string, also it will give us the dataloader not the class.

remember to add interceptor globally using

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: DataLoaderInterceptor,
    },
  ],
})
export class AppModule {}

so from now on you should write a DataLoader Wrapper Class that implements NestDataLoader with all the services you need, provide it in the module, and use it in any resolver you want.

@kamilmysliwiec should I make this changes and give a pull request?

phillip-hall commented 5 years ago

@mohaalak I have tried your solution, very nice by the way, however the dataloader is failing to call my service method. I am getting this error "Cannot read property 'fieldRepository' of undefined"

Here is my Loader class implementation;

@Injectable()
export class FieldLoader implements NestDataLoader {
  constructor(private readonly fieldService: FieldService) {}

  generateDataLoader(): DataLoader<any, any> {
    return new DataLoader<string, Field>(this.fieldService.findManyByTable);
  }
}

Here is my FieldService class implementation

@Injectable()
export class FieldService {
  constructor(@InjectRepository(Field) private readonly fieldService: Repository<Field>) {}

  async findManyByTable(ids: string[]): Promise<Field[]> {
    return await this.fieldRepository.find({tableId: In(ids)});
  }

From what I can gather, "this" is not bound to an instance of FieldService when DataLoader calls the "findManyByTable" method. Any idea why?

caseyduquettesc commented 5 years ago

I use @autobind from the autobind package on all my dataloader methods

mohaalak commented 5 years ago

@mohaalak I have tried your solution, very nice by the way, however the dataloader is failing to call my service method. I am getting this error "Cannot read property 'fieldRepository' of undefined"

Here is my Loader class implementation;

@Injectable()
export class FieldLoader implements NestDataLoader {
  constructor(private readonly fieldService: FieldService) {}

  generateDataLoader(): DataLoader<any, any> {
    return new DataLoader<string, Field>(this.fieldService.findManyByTable);
  }
}

Here is my FieldService class implementation

@Injectable()
export class FieldService {
  constructor(@InjectRepository(Field) private readonly fieldService: Repository<Field>) {}

  async findManyByTable(ids: string[]): Promise<Field[]> {
    return await this.fieldRepository.find({tableId: In(ids)});
  }

From what I can gather, "this" is not bound to an instance of FieldService when DataLoader calls the "findManyByTable" method. Any idea why?

cause we send just method to dataloader this.fieldService.findManyByTable so this is not bound to FieldService but is bounded to dataloader cause it's the dataloader that calls this method, you can use AutoBind package that @caseyduquettesc mentioned, but I simply write my function as a property of class with arrow functions.

@Injectable()
export class FieldService {
  constructor(@InjectRepository(Field) private readonly fieldService: Repository<Field>) {}

 public findManyByTable=  async  (ids: string[]): Promise<Field[]> {
    return await this.fieldRepository.find({tableId: In(ids)});
  }
}
EdouardBougon commented 5 years ago

Since @nestjs/graphql": "^6.2.0" (https://github.com/nestjs/graphql/commit/775becaa198ede54fca88a4646a37aabdc2445d7#diff-b57b423096aa8ca93a6f5575b56e3f3f), Interceptor (and guard, filter) are disabled for properties. So the example of @mohaalak isn't working anymore.

I've adapted it in this way:

Update DataLoaderInterceptor. It will be executed only one time for the query, mutation or subscription:

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  InternalServerErrorException,
  NestInterceptor,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { GqlExecutionContext, GraphQLExecutionContext } from '@nestjs/graphql';
import { Observable } from 'rxjs';

import { NestDataLoader } from '../interfaces/nest-dataloader';

/**
 * Context key where get loader function will be store
 */
export const GET_LOADER_CONTEXT_KEY: string = 'GET_LOADER_CONTEXT_KEY';

@Injectable()
export class DataLoaderInterceptor implements NestInterceptor {

  constructor(
    private readonly moduleRef: ModuleRef,
  ) {}

  /**
   * @inheritdoc
   */
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const graphqlExecutionContext: GraphQLExecutionContext = GqlExecutionContext.create(context);
    const ctx: any = graphqlExecutionContext.getContext();

    if (ctx[GET_LOADER_CONTEXT_KEY] === undefined) {

      ctx[GET_LOADER_CONTEXT_KEY] = (type: string): NestDataLoader => {

        if (ctx[type] === undefined) {
          try {
            ctx[type] = this.moduleRef
              .get<NestDataLoader>(type, { strict: false })
              .generateDataLoader();
          } catch (e) {
            throw new InternalServerErrorException(`The loader ${type} is not provided`);
          }
        }

        return ctx[type];
      };
    }

    return next.handle();
  }
}

Transform Loader decorator to a parameter decorator:

import { createParamDecorator, InternalServerErrorException } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';

import { DataLoaderInterceptor, GET_LOADER_CONTEXT_KEY } from '../interceptors/data-loader.interceptor';

export const Loader: (type: string) => ParameterDecorator = createParamDecorator(
  (type: string, [__, ___, ctx, ____]: any) => {

    if (ctx[GET_LOADER_CONTEXT_KEY] === undefined) {
      throw new InternalServerErrorException(`
        You should provide interceptor ${DataLoaderInterceptor.name} globaly with ${APP_INTERCEPTOR}
      `);
    }

    return ctx[GET_LOADER_CONTEXT_KEY](type);
  },
);

And now, how to used it with a property:

@ResolveProperty('photo', () => Photo)
  async photo(
    @Root() user: User,
    @Loader(PhotoLoader.name) photoLoader: DataLoader<User['id'], Photo>,
  ): Promise<Photo> {
    return photoLoader.load(user.id);
  }
Javaman44 commented 5 years ago

Thanks EdouardBougon i search this morning for the same issue :)

alfonmga commented 5 years ago

@EdouardBougon How NestDataLoader interface looks like? Could you share it? thanks

EDIT: Nevermind I found it.

import DataLoader from 'dataloader';

export interface NestDataLoader {
  /**
   * Should return a new instance of dataloader each time
   */
  generateDataLoader(): DataLoader<any, any>;
}
secmohammed commented 5 years ago

@EdouardBougon It keeps logging "The loader UserDataLoader is not provided" , any idea to tackle this? , I checked on the error and apparently Nest cannot find the user loader as it does not exist in current context.

gouroujo commented 4 years ago

@EdouardBougon It keeps logging "The loader UserDataLoader is not provided" , any idea to tackle this? , I checked on the error and apparently Nest cannot find the user loader as it does not exist in current context.

you should try import * as DataLoader from 'dataloader'; There is an issue in the typing of dataloader and the default import, don't know what exactly

lock[bot] commented 4 years ago

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.