Введение

В этой статье я дам краткое объяснение того, что такое ГрафQL проблема n+1, как решить их с помощью Dataloaders и как реализовать их в NestJS с подходом Code First.


Проблема GraphQL n+1

Одно из самых больших преимуществ использования GraphQL по сравнению с REST заключается в том, что он уменьшает количество запросов, которые вам нужно выполнять к серверу, поскольку вы можете запрашивать все, что вам нужно, за один раз. Например, для простой диаграммы сущностей SQL, подобной этой:

Диаграмма отношений сущностей

В REST API вам потребуется три маршрута для получения сообщения с его автором и комментариями:

  • /api/post/:id получить один пост по заданному id;
  • /api/post/:id/author получить автора поста;
  • /api/post/:id/comments?first=10 чтобы получить пост первые 10 комментариев.

В то время как в GraphQL вы могли не только выбирать, какие данные вам нужны из каждой сущности, но и запрашивать все связанные поля в одном запросе:

query PostById($postId: Int!, $commentsFirst: Int = 10) {
  postById(postId: $postId) {
    title
    body
    createdAt
    updatedAt
    author {
      name
      username
    }
    comments(first: $commentsFirst) {
      edges {
        node {
          id
          body
          author {
            name
            username
          }
        }
        cursor
      }
      pageInfo {
        hasNextPage
      }
    }
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Чтобы иметь возможность получить всю эту информацию в одном запросе, вам нужно было бы СОЕДИНИТЬ все эти отношения вместе, но это было бы расточительно, когда они не нужны.

Лучшим вариантом является разрешение данных только тогда, когда они вам нужны, с @ResolveField декоратор:

ПРИМЕЧАНИЕ: это только пример.

import {
  Args,
  Context,
  Mutation,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';
import { PostEntity } from './entities/post.entity';
import { CommentsService } from '../comments/comments.service';
import { PaginatedCommentsType } from '../comments/entities/gql/paginated-comments-type';
import { FilterRelationDto } from '../common/dtos/filter-relation.dto';
import { UsersService } from '../users/users.service';
import { UserEntity } from '../users/entities/user.entity';

@Resolver(() =>  PostEntity)
export class PostsResolver {
  constructor(
    private readonly postsService: PostsService,
    private readonly usersService: UsersService,
    private readonly commentsService: CommentsService,
  ) {}
  //...

  @ResolveField('author', () => UserEntity)
  public async resolveAuthor(
    @Parent() post: PostEntity
  ) {
    return this.usersService.userById(post.author.id);
  }

  @ResolveField('comments', () => PaginatedCommentsType)
  public async resolveComments(
    @Parent() post: PostEntity,
    @Args() dto: FilterRelationDto,
  ) {
    return this.commentsService.postComments(post.id, dto);
  }
}

Войти в полноэкранный режим

Выйти из полноэкранного режима

Это будет работать, когда вы получаете только одно сообщение, но что произойдет, если вы получите несколько сообщений, например:

query UsersPosts($userId: Int!, $first: Int = 10, $after: String) {
  usersPosts(userId: $userId, first: $first, after: $after) {
    edges {
      node {
        title
        body
        createdAt
        updatedAt
        author {
          name
          username
        }
        comments(first: 10) {
          edges {
            node {
              id
              body
              author {
                name
                username
              }
            }
            cursor
          }
          pageInfo {
            hasNextPage
            hasPreviousPage
            startCursor
            endCursor
          }
        }
      }
    }
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

В конечном итоге вам придется делать несколько запросов к базе данных как для автора, так и для комментариев, если быть точным, по одному для каждого сообщения, ведущего к n+1 проблема. Ваше взаимодействие с базой данных будет выглядеть примерно так:

SELECT * FROM posts WHERE user_id = 1 LIMIT 3;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
SELECT * FROM users WHERE id = 4;
SELECT * FROM comments WHERE post_id = 2 LIMIT 10;
SELECT * FROM comments WHERE post_id = 3 LIMIT 10;
SELECT * FROM comments WHERE post_id = 4 LIMIT 10;
Войти в полноэкранный режим

Выйти из полноэкранного режима


Как загрузчики данных решают эту проблему

По сути загрузчики данных кэшировать и группировать ваши запросы, в основном они кэшируют все родительские объекты (или только идентификатор) и выполняют один запрос, чтобы получить необходимые связанные данные.

Таким образом, предыдущие запросы будут преобразованы во что-то близкое к этому:

SELECT * FROM posts WHERE user_id = 1 LIMIT 3;
SELECT * FROM users WHERE id IN (2, 3, 4);
-- ...
Войти в полноэкранный режим

Выйти из полноэкранного режима

ПРИМЕЧАНИЕ. Я пропустил запрос с разбивкой на страницы, так как он длинный и более продвинутый. Расширенные загрузчики данных рассматриваются далее в этой статье.


Реализация загрузчика данных NestJS

Способ настройки загрузчиков данных в NestJS зависит от выбранного вами адаптера: Аполлон или же Меркурий.


Начальная настройка (общая для обоих)

В папке вашего проекта создайте новый модуль (и службу) для ваших загрузчиков:

$ nest g mo loaders
$ nest g s loaders
Войти в полноэкранный режим

Выйти из полноэкранного режима

ПРИМЕЧАНИЕ. Не забудьте экспортировать службу загрузчиков из модуля загрузчиков.

Переместите файл спецификации в папку с тестами и создайте папку interfaces с интерфейсом загрузчика (loader.interface.ts):

export interface ILoader<T extends Object, P = undefined> {
  obj: T;
  params: P;
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

После этого перейдите в свой сервис загрузчиков, добавьте менеджер объекта в качестве зависимости и создайте один общедоступный метод с именем getLoaders:

import { EntityManager } from '@mikro-orm/postgresql';
import { Injectable, Type } from '@nestjs/common';

@Injectable()
export class LoadersService {
  constructor(
    private readonly em: EntityManager,
    private readonly commonService: CommonService,
  ) {}

  public getLoaders() {}
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Поскольку многие загрузчики будут иметь одинаковую логику, я рекомендую создать следующие вспомогательные статические методы:

ПРИМЕЧАНИЕ. IBase — это база всех сущностей с такими общими полями, как id а также createdAtя рекомендую иметь один в вашем проекте для вывода типов.

  • Получить сущности чтобы получить объекты из объекта загрузчика:

    // ...
    @Injectable()
    export class LoadersService {
      constructor(
        private readonly em: EntityManager,
        private readonly commonService: CommonService,
      ) {}
    
      /**
       * Get Entities
       *
       * Maps the entity object to the entity itself.
       */
      private static getEntities<T extends IBase, P = undefined>(
        items: ILoader<T, P>[],
      ): T[] {
        const entities: T[] = [];
    
        for (let i = 0; i < items.length; i++) {
          entities.push(items[i].obj);
        }
    
        return entities;
      }
    
      // ...
    }
    
  • Получить идентификаторы сущностей чтобы получить идентификаторы сущностей объекта загрузчика:

    // ...
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Get Entity IDs
       *
       * Maps the entity object to an array of IDs.
      */
      private static getEntityIds<T extends IBase, P = undefined>(
        items: ILoader<T, P>[],
      ): number[] {
        const ids: number[] = [];
    
        for (let i = 0; i < items.length; i++) {
          ids.push(items[i].obj.id);
        }
    
        return ids;
      }
    
      // ...
    }
    
  • Получить идентификаторы отношений получает идентификаторы отношения «многие к одному»:

    // ...
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Get Relation IDs
       *
       * Maps the entity object many-to-one relation to an array of IDs.
       */
      private static getRelationIds<T extends IBase, P = undefined>(
        items: ILoader<T, P>[],
        relationName: string,
      ): number[] {
        const ids: number[] = [];
    
        for (let i = 0; i < items.length; i++) {
          ids.push(items[i].obj[relationName].id);
        }
    
        return ids;
      }
    
      // ...
    }
    
  • Получить карту объектапревращает массив сущностей (обычно из findAll/getResult метод) в JavaScript HashMap:

    // ...
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Get Entity Map
       *
       * Turns an array of entity objects to a map of entity objects
       * with its ID as the key.
       */
      private static getEntityMap<T extends IBase>(entities: T[]): Map<number, T> {
        const map = new Map<number, T>();
    
        for (let i = 0; i < entities.length; i++) {
          const entity = entities[i];
          map.set(entity.id, entity);
        }
    
        return map;
      }
    
      // ...
    }
    
  • Получить результат принимает массив идентификаторов отношений и карту и возвращает окончательный массив результатов:

    // ...
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Get Results
       *
       * With the IDs of the relation id array, gets the results of the map.
       */
      private static getResults<T>(
        ids: number[],
        map: Map<number, T>,
        defaultValue: T | null = null,
      ): T[] {
        const results: T[] = [];
    
        for (let i = 0; i < ids.length; i++) {
          const id = ids[i];
          results.push(map.get(id) ?? defaultValue);
        }
    
        return results;
      }
    
      // ...
    }
    


Комплексные погрузчики

Следующие частные методы являются совершенно необязательными и работают только с SQL, но если вы используете SQL, я рекомендую их, они являются универсальными для сложных отношений, таких как отношения с разбивкой на страницы, подсчет связанных сущностей и проверка существования (логические случаи).

Чтобы иметь возможность использовать эти дженерики в первую очередь, вам необходимо сначала выполнить следующие шаги в своем проекте:

  1. Удалите все отношения «многие ко многим» и вручную создайте все сводные таблицы (я знаю, это раздражает, но необходимо иметь возможность создавать подзапрос разбиения на страницы), которые реализуются из интерфейса создания:

    export interface ICreation {
      createdAt: Date;
    }
    
  2. Создайте функцию разбиения курсора на ваш общий сервис, как в другой моей статье;

  3. Создайте следующий dto для разбивки на страницы в вашей общей папке dtos (filter.dto.ts):

    import { ArgsType, Field, Int } from '@nestjs/graphql';
    import { IsEnum, IsInt, Max, Min } from 'class-validator';
    import { QueryOrderEnum } from '../enums/query-order.enum';
    
    @ArgsType()
    export abstract class FilterRelationDto {
      @Field(() => QueryOrderEnum, { defaultValue: QueryOrderEnum.ASC })
      @IsEnum(QueryOrderEnum)
      public order: QueryOrderEnum = QueryOrderEnum.ASC;
    
      @Field(() => Int, { defaultValue: 10 })
      @IsInt()
      @Min(1)
      @Max(50)
      public first = 10;
    }
    
  4. Для подсчета создайте следующий интерфейс в папке интерфейсов загрузчиков (count-result.interface.ts):

    export interface ICountResult {
      id: number;
      count: number;
    }
    
  5. Наконец, для проверки существования создайте этот интерфейс (existence-result.interface.ts):

    export interface IExistenceResult {
      id: number;
      existence: number;
    }
    

Поскольку у нас может быть два типа отношений, всегда есть две версии для каждого универсального: одна для отношений «многие ко многим» (со сводными таблицами) и одна для отношений «один ко многим», поэтому универсальные шаблоны таковы:

  • Загрузчики страниц:

    // ...
    import { Injectable, Type } from '@nestjs/common';
    import { ILoader } from './interfaces/loader.interface';
    // ...
    
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Basic Paginator
       *
       * Loads paginated one-to-many relationships
       */
      private async basicPaginator<T extends IBase, C extends IBase>(
        data: ILoader<T, FilterRelationDto>[],
        parent: Type<T>,
        child: Type<C>,
        parentRelation: keyof T,
        childRelation: keyof C,
        cursor: keyof C,
      ): Promise<IPaginated<C>[]> {
        if (data.length === 0) return [];
    
        const { first, order } = data[0].params;
        const parentId = 'p.id';
        const childAlias = 'c';
        const childId = 'c.id';
        const knex = this.em.getKnex();
        const parentRef = knex.ref(parentId);
        const parentRel = String(parentRelation);
        const ids = LoadersService.getEntityIds(data);
    
        const countQuery = this.em
          .createQueryBuilder(child, childAlias)
          .count(childId)
          .where({
            [childRelation]: parentRef,
          })
          .as('count');
        const entitiesQuery = this.em
          .createQueryBuilder(child, childAlias)
          .select(`${childAlias}.id`)
          .where({
            [childRelation]: {
              id: parentRef,
            },
          })
          .orderBy({ [cursor]: order })
          .limit(first)
          .getKnexQuery();
        const results = await this.em
          .createQueryBuilder(parent, 'p')
          .select([parentId, countQuery])
          .leftJoinAndSelect(`p.${parentRel}`, childAlias)
          .groupBy([parentId, childId])
          .where({
            id: { $in: ids },
            [parentRelation]: { $in: entitiesQuery },
          })
          .orderBy({ [parentRelation]: { [cursor]: order } })
          .getResult();
        const map = new Map<number, IPaginated<C>>();
    
        for (let i = 0; i < results.length; i++) {
          const result = results[i];
    
          map.set(
            result.id,
            this.commonService.paginate(
              result[parentRelation].getItems(),
              result.count,
              0,
              cursor,
              first,
            ),
          );
        }
    
        return LoadersService.getResults(
          ids,
          map,
          this.commonService.paginate([], 0, 0, cursor, first),
        );
      }
    
      /**
       * Pivot Paginator
       *
       * Loads paginated many-to-many relationships
       */
      private async pivotPaginator<
        T extends IBase,
        P extends ICreation,
        C extends IBase,
      >(
        data: ILoader<T, FilterRelationDto>[],
        parent: Type<T>,
        pivot: Type<P>,
        pivotName: keyof T,
        pivotParent: keyof P,
        pivotChild: keyof P,
        cursor: keyof C,
      ): Promise<IPaginated<C>[]> {
        if (data.length === 0) return [];
    
        // Because of runtime
        const strPivotName = String(pivotName);
        const strPivotChild = String(pivotChild);
        const strPivotParent = String(pivotParent);
    
        const { first, order } = data[0].params;
        const parentId = 'p.id';
        const knex = this.em.getKnex();
        const parentRef = knex.ref(parentId);
        const ids = LoadersService.getEntityIds(data);
    
        const countQuery = this.em
          .createQueryBuilder(pivot, 'pt')
          .count(`pt.${strPivotChild}_id`, true)
          .where({ [strPivotParent]: parentRef })
          .as('count');
        const pivotQuery = this.em
          .createQueryBuilder(pivot, 'pt')
          .select('pc.id')
          .leftJoin(`pt.${strPivotChild}`, 'pc')
          .where({ [strPivotParent]: parentRef })
          .orderBy({
            [strPivotChild]: { [cursor]: order },
          })
          .limit(first)
          .getKnexQuery();
        const results = await this.em
          .createQueryBuilder(parent, 'p')
          .select([parentId, countQuery])
          .leftJoinAndSelect(`p.${strPivotName}`, 'e')
          .leftJoinAndSelect(`e.${strPivotChild}`, 'f')
          .where({
            id: { $in: ids },
            [strPivotName]: {
              [strPivotChild]: { $in: pivotQuery },
            },
          })
          .orderBy({
            [strPivotName]: {
              [strPivotChild]: { [cursor]: order },
            },
          })
          .groupBy([`e.${strPivotParent}_id`, 'f.id'])
          .getResult();
        const map = new Map<number, IPaginated<C>>();
    
        for (let i = 0; i < results.length; i++) {
          const result = results[i];
          const pivots: P[] = result[strPivotName];
          const entities: C[] = [];
    
          for (let j = 0; j < pivots.length; j++) {
            entities.push(pivots[j][strPivotChild]);
          }
    
          map.set(
            result.id,
            this.commonService.paginate(entities, result.count, 0, cursor, first),
          );
        }
    
        return LoadersService.getResults(
          ids,
          map,
          this.commonService.paginate([], 0, 0, cursor, first),
        );
      }
    
      // ...
    }
    
  • Загрузчики счетчиков:

    // ...
    
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Get Counter Results
       *
       * Maps the count result to a number array
       */
      private static getCounterResults(
        ids: number[],
        raw: ICountResult[],
      ): number[] {
        const map = new Map<number, number>();
    
        for (let i = 0; i < raw.length; i++) {
          const count = raw[i];
          map.set(count.id, count.count);
        }
    
        return LoadersService.getResults(ids, map, 0);
      }
    
      // ...
    
      /**
       * Basic Counter
       *
       * Loads the count of one-to-many relationships.
       */
      private async basicCounter<T extends IBase, C extends IBase>(
        data: ILoader<T>[],
        parent: Type<T>,
        child: Type<C>,
        childRelation: keyof C,
      ): Promise<number[]> {
        if (data.length === 0) return [];
    
        const parentId = 'p.id';
        const knex = this.em.getKnex();
        const parentRef = knex.ref(parentId);
        const ids = LoadersService.getEntityIds(data);
    
        const countQuery = this.em
          .createQueryBuilder(child, 'c')
          .count('c.id')
          .where({ [childRelation]: { $in: parentRef } })
          .as('count');
        const raw: ICountResult[] = await this.em
          .createQueryBuilder(parent, 'p')
          .select([parentId, countQuery])
          .where({ id: { $in: ids } })
          .groupBy(parentId)
          .execute();
    
        return LoadersService.getCounterResults(ids, raw);
      }
    
      /**
       * Pivot Counter
       *
       * Loads the count of many-to-many relationships.
       */
      private async pivotCounter<T extends IBase, P extends ICreation>(
        data: ILoader<T>[],
        parent: Type<T>,
        pivot: Type<P>,
        pivotParent: keyof P,
        pivotChild: keyof P,
      ): Promise<number[]> {
        if (data.length === 0) return [];
    
        const strPivotChild = String(pivotChild);
        const parentId = 'p.id';
        const knex = this.em.getKnex();
        const parentRef = knex.ref(parentId);
        const ids = LoadersService.getEntityIds(data);
    
        const countQuery = this.em
          .createQueryBuilder(pivot, 'pt')
          .count(`pt.${strPivotChild}_id`, true)
          .where({ [pivotParent]: { $in: parentRef } })
          .as('count');
        const raw: ICountResult[] = await this.em
          .createQueryBuilder(parent, 'p')
          .select([parentId, countQuery])
          .where({ id: { $in: ids } })
          .groupBy(parentId)
          .execute();
    
        return LoadersService.getCounterResults(ids, raw);
      }
    
      // ...
    }
    
  • Загрузчик существования:
    ПРИМЕЧАНИЕ: в этом вам нужно передать весь оператор from.

    // ...
    import { IExistenceResult } from './interfaces/existence-result.interface';
    // ...
    
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Get Existence
       *
       * Finds if the entity relation exists and returns a boolean array
       */
      private async getExistence<T extends IBase>(
        data: ILoader<T, FilterRelationDto>[],
        parent: Type<T>,
        fromCondition: string,
      ): Promise<boolean[]> {
        if (data.length === 0) return [];
    
        const ids = LoadersService.getEntityIds(data);
        const caseString = `
            CASE
              WHEN EXISTS (
                SELECT 1
                ${fromCondition}
              )
              THEN 1
              ELSE 0
            END AS 'existence'
          `;
        const raw: IExistenceResult[] = await this.em
          .createQueryBuilder(parent, 'p')
          .select(['p.id', caseString])
          .where({ id: { $in: ids } })
          .execute();
    
        const map = new Map<number, boolean>();
    
        for (let i = 0; i < raw.length; i++) {
          const { id, existence } = raw[i];
          map.set(id, existence === 1);
        }
    
        return LoadersService.getResults(ids, map);
      }
    
      // ...
    }
    


Специально для Аполлона

Для Apollo, во-первых, нам нужно установить загрузчик данных упаковка.

Впоследствии вам нужно создать интерфейс под названием Loaders (loaders.interface.ts) со всеми загрузчиками, которые вам нужны в вашем проекте, для примера выше это будет выглядеть примерно так:

// or import * as Dataloader if you don't have "esModuleInterop": true
import DataLoader from 'dataloader';
import { ILoader } from './loader.interface';
import { IComment } from '../../comments/interfaces/comment.interface'
import { IPaginated } from '../../common/interfaces/paginated.interface';
import { FilterRelationDto } from '../../common/dtos/filter-relation.dto';
import { IPost } from '../../posts/interfaces/post.interface';
import { IUser } from '../../users/interfaces/user.interface';

export interface ILoaders {
  author: DataLoader<ILoader<IPost | IComment>, IUser>;
  comments: DataLoader<ILoader<IPost, FilterRelationDto>, IPaginated<IComment>>;
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь нам нужно добавить логику загрузчиков в службу загрузчиков, поэтому создайте два новых частных метода, которые создают экземпляр нового загрузчика данных с логикой пакетной обработки внутри:

  • Загрузчик отношений с авторами:
    Во-первых, я бы создал интерфейс для каждой сущности, у которой есть автор, так как это очень распространено:

    import { IBase } from './base.interface';
    import { IUser } from '../../users/interfaces/user.interface';
    
    export interface IAuthored extends IBase {
      author: IUser;
    }
    

    И, наконец, логика загрузчиков будет следующей:

    // ...
    import DataLoader from 'dataloader';
    import { IAuthored } from '../common/interfaces/authored.interface';
    // ...
    
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Author Relation Loader
       *
       * Gets every author relation.
       */
      private authorRelationLoader<T extends IAuthored>() {
        return new DataLoader(async (data: ILoader<T>[]): Promise<UserEntity[]> => {
          if (data.length === 0) return [];
    
          const ids = LoadersService.getRelationIds(data, 'author');
          const users = await this.em.find(UserEntity, {
            id: {
              $in: ids,
            },
          });
          const map = LoadersService.getEntityMap(users);
          return LoadersService.getResults(ids, map);
        });
      }
    
      // ...
    }
    
  • Загрузчик комментариев к сообщениям:

    // ...
    
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Post Comments Loader
       *
       * Get paginated comments of post.
       */
      private postCommentsLoader() {
        return new DataLoader(async (
          data: ILoader<PostEntity, FilterRelationDto>[],
        ): Promise<IPaginated<CommentEntity>[]> => {
          return this.basicPaginator(
            data,
            PostEntity,
            CommentEntity,
            'comments',
            'post',
            'id',
          );
        });
      }
    
      // ...
    }
    

Внутри службы грузчиков завершите getLoaders метод с вновь созданными загрузчиками:

// ...
import { ILoaders } from './interfaces/loaders.interface'; 
// ...

@Injectable()
export class LoadersService {
  // ...

  public getLoaders(): ILoaders {
    return {
      author: this.authorRelationLoader(),
      comments: this.postCommentsLoader(),
    };
  }

  // ...
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Наконец, внутри вашего класса конфигурации GraphQL вам нужно добавить загрузчики в контекст:

ПРИМЕЧАНИЕ. Не забудьте добавить LoadersModule в GraphQL.forRootAsync() импортирует массив.

import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GqlOptionsFactory } from '@nestjs/graphql';
import { ICtx } from './interfaces/ctx.interface';
import { LoadersService } from '../loaders/loaders.service';

@Injectable()
export class GqlConfigService implements GqlOptionsFactory {
  constructor(
    private readonly configService: ConfigService,
    private readonly loadersService: LoadersService,
  ) {}

  public createGqlOptions(): ApolloDriverConfig {
    return {
      driver: ApolloDriver,
      context: ({ req, res }): ICtx => ({
        req,
        res,
        loaders: this.loadersService.getLoaders(),
      }),
      // ...
    };
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Чтобы использовать загрузчики, просто вырвите их из контекста, когда они вам потребуются, поэтому пример распознавателя теперь будет выглядеть примерно так:

import {
  Args,
  Context,
  Mutation,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';
import { PostEntity } from './entities/post.entity';
import { PaginatedCommentsType } from '../comments/entities/gql/paginated-comments-type';
import { FilterRelationDto } from '../common/dtos/filter-relation.dto';
import { UserEntity } from '../users/entities/user.entity';
import { ILoaders } from '../loaders/interfaces/loaders.interface';

@Resolver(() =>  PostEntity)
export class PostsResolver {
  constructor(private readonly postsService: PostsService) {}
  //...

  @ResolveField('author', () => UserEntity)
  public async resolveAuthor(
    @Context('loaders') loaders: ILoaders,
    @Parent() post: PostEntity
  ) {
    return loaders.author.load({ obj: post, params: undefined });
  }

  @ResolveField('comments', () => PaginatedCommentsType)
  public async resolveComments(
    @Context('loaders') loaders: ILoaders,
    @Parent() post: PostEntity,
    @Args() dto: FilterRelationDto,
  ) {
    return loaders.comments.load({ obj: post, params: dto });
  }
}

Войти в полноэкранный режим

Выйти из полноэкранного режима


Специфический Меркуриус

Работа с загрузчиками данных в Меркурий может быть странным. Погрузчики уже встроены в адаптер, поэтому логика должна идти внутри параметра GraphQL Config Class loaders, и нам больше не нужен пакет dataloader.

Сначала нам нужно внести некоторые изменения в наш преобразователь:

  1. Удалить resolveAuthor так как нам нужно только @Field декоратор классов сущностей;
  2. Удалить все аргументы, кроме @Args() из разрешенияКомментарии и ничего не вернуть.

Таким образом, преобразователь будет выглядеть примерно так:

import {
  Args,
  Context,
  Mutation,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';
import { PostEntity } from './entities/post.entity';
import { PaginatedCommentsType } from '../comments/entities/gql/paginated-comments-type';
import { FilterRelationDto } from '../common/dtos/filter-relation.dto';

@Resolver(() =>  PostEntity)
export class PostsResolver {
  constructor(private readonly postsService: PostsService) {}
  //...

  // The logic will go to the loaders object
  @ResolveField('comments', () => PaginatedCommentsType)
  public resolveComments(
    @Args() dto: FilterRelationDto,
  ) {
    return;
  }
}

Войти в полноэкранный режим

Выйти из полноэкранного режима

Вы можете спросить, зачем нам вообще нужен пустой метод? Нам нужен пустой метод, чтобы поле комментариев создавалось на схеме GraphQL.

Сервис загрузчиков ничем не отличается от сервиса Apollo, мы просто возвращаем стрелочную функцию без установки DataLoader:

ПРИМЕЧАНИЕ: нам по-прежнему нужно возвращать стрелочные функции, потому что нам нужно связать this ключевое слово.

  • Загрузчик отношений с авторами:

    // ...
    import DataLoader from 'dataloader';
    import { IAuthored } from '../common/interfaces/authored.interface';
    // ...
    
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Author Relation Loader
       *
       * Gets every author relation.
       */
      private authorRelationLoader<T extends IAuthored>() {
        return async (data: ILoader<T>[]): Promise<UserEntity[]> => {
          if (data.length === 0) return [];
    
          const ids = LoadersService.getRelationIds(data, 'author');
          const users = await this.em.find(UserEntity, {
            id: {
              $in: ids,
            },
          });
          const map = LoadersService.getEntityMap(users);
          return LoadersService.getResults(ids, map);
        };
      }
    
      // ...
    }
    
  • Загрузчик комментариев к сообщениям:

    // ...
    
    @Injectable()
    export class LoadersService {
      // ...
    
      /**
       * Post Comments Loader
       *
       * Get paginated comments of post.
       */
      private postCommentsLoader() {
        return async (
          data: ILoader<PostEntity, FilterRelationDto>[],
        ): Promise<IPaginated<CommentEntity>[]> => {
          return this.basicPaginator(
            data,
            PostEntity,
            CommentEntity,
            'comments',
            'post',
            'id',
          );
        };
      }
    
      // ...
    }
    

Основное отличие от Аполлона заключается в том, как getLoaders метод отформатирован, он должен следовать схеме GraphQL, как показано на Документация по Mercurius.

Следовательно getLoaders будет выглядеть примерно так:

// ...

@Injectable()
export class LoadersService {
  // ...

  public getLoaders(): ILoaders {
    return {
      Post: {
        author: this.authorRelationLoader<IPost>(),
        comments: this.postCommentsLoader(),
      },
    };
  }

  // ...
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Наконец, в классе конфигурации GraphQL есть параметр загрузчиков:

ПРИМЕЧАНИЕ. Не забудьте добавить LoadersModule в GraphQL.forRootAsync() импортирует массив.

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GqlOptionsFactory } from '@nestjs/graphql';
import { MercuriusDriver, MercuriusDriverConfig } from "@nestjs/mercurius";
import { ICtx } from './interfaces/ctx.interface';
import { LoadersService } from '../loaders/loaders.service';

@Injectable()
export class GqlConfigService implements GqlOptionsFactory {
  constructor(
    private readonly configService: ConfigService,
    private readonly loadersService: LoadersService,
  ) {}

  public createGqlOptions(): MercuriusDriverConfig {
    return {
      driver: MercuriusDriver,
      loaders: this.loadersService.getLoaders(),
      // ...
    };
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима


Вывод

Благодаря этому вы теперь можете опереться на запрос к базе данных, сохраняя при этом возможность оптимизировать запросы с помощью отношений.