🧐

NestJS x GraphQL x Prismaでさくっとページネーションを実装する

2022/08/15に公開

NestJS x GraphQL x Prismaでページネーションを実装しようとしたところ、まだデファクトっぽい方法がなさそうで色々苦労したので対応内容をまとめてみました。

はじめに

すでにnestjs/graphql x Prismaの環境が整っているうえで「細かいことはいいからページネーションをさくっと実装したい」という方向けの記事になっています。
そのため、記事中に記載するコードは必要最小限に留めています。

環境

  • @nestjs/graphql: 10.0.8
  • @prisma/client: 3.11.1
  • @devoxa/prisma-relay-cursor-connection: 2.2.2

実装方針

こちらの考えを参考にさせていただきながら、

https://dev.to/nilomiranda/using-relay-graphql-js-with-nestjs-1kpb

こちらのライブラリを利用して実装しています。

https://github.com/devoxa/prisma-relay-cursor-connection

実装内容(抜粋)

1. 利用するライブラリをインストール

yarn add @devoxa/prisma-relay-cursor-connection

2. @nestjs/graphqlで利用するためのインターフェースを実装

@devoxa/prisma-relay-cursor-connection に必要なインターフェースは揃っていますが、@nestjs/graphql で利用するために自作で定義しなおします。
※説明用に1ファイルにまとめていますが、適宜分割してください。

xxx/connection.ts
import { Type } from '@nestjs/common';
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';

import {
  Edge as PrismaRelayEdge,
  PageInfo as PrismaRelayPageInfo,
  Connection as PrismaRelayConnection,
  ConnectionArguments as PrismaRelayConnectionArguments,
} from '@devoxa/prisma-relay-cursor-connection';

export function Connection<T>(GenericClass?: Type<T>): any {
  @ObjectType({ isAbstract: true })
  class PageInfo implements PrismaRelayPageInfo {
    @Field(() => Boolean, { nullable: false })
    hasNextPage: boolean;

    @Field(() => Boolean, { nullable: false })
    hasPreviousPage: boolean;

    @Field(() => String, { nullable: true })
    startCursor: string;

    @Field(() => String, { nullable: true })
    endCursor: string;
  }

  @ObjectType({ isAbstract: true })
  class Edge<T> implements PrismaRelayEdge<T> {
    @Field(() => String, { nullable: false })
    cursor: string;

    @Field(() => GenericClass, { nullable: false })
    node: T;
  }

  @ObjectType({ isAbstract: true })
  class IConnection implements PrismaRelayConnection<T> {
    @Field(() => [GenericClass], { nullable: false })
    nodes: T[];

    @Field(() => [Edge], { nullable: false })
    edges: Edge<T>[];

    @Field(() => PageInfo, { nullable: false })
    pageInfo: PageInfo;

    @Field(() => Int, { nullable: false })
    totalCount: number;
  }

  return IConnection;
}

@InputType()
export class ConnectionArguments implements PrismaRelayConnectionArguments {
  @Field(() => Int, { nullable: true })
  first?: number;

  @Field(() => String, { nullable: true })
  after?: string;

  @Field(() => Int, { nullable: true })
  last?: number;

  @Field(() => String, { nullable: true })
  before?: string;
}

追記: 2022/08/29

Connectionクラスを継承する具体クラスが1つだけであれば上記のままで問題ありませんが、複数作成し、Resolverで定義すると以下のようなエラーが発生すると思います。

Error: Schema must contain uniquely named types but contains multiple types named "PageInfo".
Error: Schema must contain uniquely named types but contains multiple types named "Edge".

こちらに対する最適解が何なのかはわかっていませんが、ひとまず

  @ObjectType(`${GenericClass.name}PageInfo`, { isAbstract: true })
  @ObjectType(`${GenericClass.name}Edge`, { isAbstract: true })

のようにObjectTypeに対してクラスごとに一意の名称を付加することでエラーは回避できます。
公式のサンプルでも似たようなコードがあることから、おそらくこれがベターな案なのかなとは考えています。(ただ、この方法の場合isAbstractなfalseでも良さそうですね)

3. Resolverを実装

あとはResolverを実装するだけです。
※説明用に1ファイルにまとめていますが、適宜分割してください。

xxx/member.resolver.ts
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';

import { findManyCursorConnection } from '@devoxa/prisma-relay-cursor-connection';
import { PrismaService } from 'src/prisma.service'; // 記事の本筋からずれるので詳細は割愛

import {
  Connection,
  ConnectionArguments,
} from '@/domains/customs/pagination/connection';

// モデルクラス
@ObjectType()
export class Member {
  @Field(() => ID, { nullable: false })
  id!: string;

  @Field(() => String, { nullable: false })
  tenantId!: string;
}

// Member用のコネクションクラス
@ObjectType()
export class MembersConnection extends Connection<Member>(Member) {}

@Resolver(() => Member)
export class MemberResolver {
  constructor(private prisma: PrismaService) {}

  @Query(() => MembersConnection)
  async membersConnection(
    // ページネーション用の引数
    @Args('connectionArgs', { type: () => ConnectionArguments })
    connectionArgs: ConnectionArguments,
    // クエリ用の引数
    @Args('tenantId', { type: () => String })
    tenantId: string,
  ): Promise<MembersConnection> {
    const queryArgs = {
      where: {
        tenantId,
      },
    };

    // @devoxa/prisma-relay-cursor-connectionのfindManyCursorConnectionを利用
    // 利用方法はライブラリのREADMEをご覧ください
    return findManyCursorConnection(
      (args) => this.prisma.member.findMany({ ...args, ...queryArgs }),
      () => this.prisma.member.count(queryArgs),
      connectionArgs,
    );
  }
}

これで完成です。

4. クエリを発行して確認

以下のようなクエリで取得できます。

query members($tenantId: String!) {
  membersConnection(
    connectionArgs: { first: 2 }
    tenantId: $tenantId
  ) {
    edges {
      cursor
      node {
        id
      }
    }
    nodes {
      id
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    totalCount
  }
}

さいごに

まとめてみれば大したコード量ではないんですが、ここに行き着くまでになかなか苦労しました。。
色々な方が当アーキテクチャでのページネーション実装方法をまとめていましたが、「もう少しサクッと実装できないもんかな。。」と悶々としながら試行錯誤した結果、個人的にはわりと納得の結果になりました。

なお、当記事の内容は「試しに実装してみた」程度で色々と考慮が足りないケースはあると思います。
(例えばConnectionArgumentsの引数バリデーション)

そのため、間違いやさらに良い方法がありましたらご指摘いただければと思います。

Discussion