🐕

Prismaでページネーションを実装する(Client extensionsも使ってみる)

2023/01/22に公開

Prismaでページ番号ベースのページネーションを実装してみます。また、ページネーションに関する処理をPrisma Client extensionsで共通化してみました。

書いたコードはこちらにあります。

https://github.com/tekihei2317/prisma-pagination-example/blob/main/scripts/comparison.ts

ページネーションを実装する

PrismaのfindManyには、SQLのOFFSETとLIMITに対応するskiptakeがあります。

そのため、ページ番号ベースのページネーションは以下のように実装できます。合計ページ数は、フロント側で欲しいため計算しています。

async function getPosts(input: { page: number }) {
  const perPage = 10
  const skip = perPage * (input.page - 1)
  const where: Prisma.PostWhereInput = { authorId: 1 }

  const [posts, postCount] = await Promise.all([
    prisma.post.findMany({
      where,
      orderBy: { createdAt: 'desc' },
      skip,
      take: perPage,
    }),
    prisma.post.count({ where }),
  ])
  const pageCount = Math.ceil(postCount / perPage)

  return { items: posts, count: postCount, pageCount }
}

参考: Pagination (Reference)

上の実装には、改善したいところがいくつかあります。

  • ページネーションを他でも実装する場合、offsetpageCountを計算するロジックが複数の箇所に書かれてしまう
  • 返却するレスポンスの形式がバラバラになってしまう可能性がある(例えば、pageCounttotalPagesになってしまう)

そのため、ページネーション用の関数を作って共通化します。Blitz.jsのpaginate関数のAPI(usePaginatedQuery - Blitz.js)を参考に作りました。

type PaginateInputs<Items> = {
  page: number
  perPage: number
  queryFn: (args: { skip: number; take: number }) => Promise<Items>
  countFn: () => Promise<number>
}

type PaginateOutputs<Items> = {
  items: Items
  count: number
  pageCount: number
}

/**
 * ページネーションされたデータを取得する
 */
export async function paginate<Items>({
  page,
  perPage,
  countFn,
  queryFn,
}: PaginateInputs<Items>): Promise<PaginateOutputs<Items>> {
  const [items, count] = await Promise.all([
    queryFn({
      skip: perPage * (page - 1),
      take: perPage,
    }),
    countFn(),
  ])

  return {
    items,
    count,
    pageCount: Math.ceil(count / perPage),
  }
}

以下のように使えます。

async function getPosts(input: { page: number }) {
  const where: Prisma.PostWhereInput = { authorId: 1 };

  return await paginate({
    page: input.page,
    perPage: 10,
    queryFn: (args) =>
      prisma.post.findMany({
        where,
        orderBy: { createdAt: "desc" },
        ...args,
      }),
    countFn: () => prisma.post.count({ where }),
  });
}

ひとまず上記の課題は解決できました。しかし、whereを2回書いているので、一度だけ️書くようにしたいです。また、queryFnが複雑になると、ネストが深いため少し読みづらくなるのも気になります。

prismaのページネーションのライブラリを見てみる

prismaのページネーションのライブラリを探してみると、2つ見つかりました。

// prisma-pagination
async function getPosts(input: { page: number }) {
  const paginate = createPaginator({ perPage: 10 });

  return await paginate<Post, Prisma.PostFindManyArgs>(
    prisma.post,
    {
      where: { authorId: 1 },
      orderBy: { createdAt: "desc" },
    },
    { page: input.page }
  );
}
// prisma-paginate
import * as prismaPaginate from "prisma-paginate";

async function getPosts(input: { page: number }) {
  return await prismaPaginate(prisma.post)(
    {
      where: { authorId: 1 },
      orderBy: { createdAt: "desc" },
    },
    { page: input.page, limit: 10 }
  );
}

自作のpaginate関数よりも簡潔に書けます。しかし、これらのライブラリには課題がありました。それは、findManyの引数によって戻り値を自動的に変えられないことです。

例えば、findMany{ select: { id: true, title: true } }のように取得するカラムを指定した場合、prisma-paginationでは、それに合わせてジェネリクスを変更する必要があります。また、prisma-paginateではモデル全体を返すことになってしまっていました。

サーバーサイドに@trpc/serverを使っており、APIが返した型をフロントでも使用するため、これは重要な問題でした。

PrismaのfindManyの型を参考に頑張って自作すれば解決できそうな気がしましたが、Prisma Client extensionsのことを思い出したので使ってみることにしました。

Prisma Client extensionsを使う

Prisma Client extensionsは、その名の通りPrisma Clientを拡張するための機能です。Prisma 4.7からプレビュー機能として使えるようになりました。

Prisma Client extensions (Preview)

Client extensionsには、モデルにメソッドを追加する機能があります。例えば、以下のようにするとuserモデルにsignUpメソッドを追加できます。

export const xprisma = prisma.$extends({
  model: {
    user: {
      async signUp(email: string) {
        await prisma.user.create({ data: { email } });
      },
    },
  }
})

xprisma.user.signUp('test@example.com')

また、$allModelsプロパティを使うと、全てのモデルにメソッドを追加できます。prisma 4.9.0から$allModelsに対するメソッドの入出力に厳密な型がつけられるようになったため、実用的に使えるようになりました。

Prisma Client extensions: model component (Preview)

この機能を使って、Prisma Clientにpaginateメソッドを追加してみました。

type PaginationResult<T, A> = {
  items: Prisma.Result<T, A, "findMany">;
  count: number;
  pageCount: number;
};

export const xprisma = prisma.$extends({
  model: {
    $allModels: {
      async paginate<T, A>(
        this: T,
        args: Prisma.Exact<A, Prisma.Args<T, "findMany">> & {
          page: number;
          perPage: number;
        }
      ): Promise<PaginationResult<T, A>> {
        const { page, perPage } = args;

        const [items, count] = await Promise.all([
          (this as any).findMany({
            // omitの実装は省略
            ...omit(args, "page", "perPage"),
            skip: perPage * (page - 1),
            take: perPage,
          }),
          (this as any).count({ where: (args as any).where }),
        ]);
        const pageCount = Math.ceil(count / perPage);

        return { items, count, pageCount };
      },
    },
  },
});
// スッキリ書けるようになった
async function getPosts(input: { page: number }) {
  return await xprisma.post.paginate({
    page: 1,
    perPage: 10,
    where: { authorId: 1 },
    orderBy: { createdAt: "desc" },
  });
}

paginate関数の第一引数のthis: Tは、関数を呼び出したときのコンテキストに型をつけるためのものです。実行するときは第二引以降を指定します。

TypeScript: Documentation - Declaring this in a Function

例えば、xprisma.post.paginateと実行すると、Ttypeof xprisma.postになります。これに対し、Prisma.ArgsPrisma.Resultなどの型を使って入出力に型をつけています。エディタ上でも問題なく補完や型チェックができました。

まとめ

Prismaのページネーションを共通化する方法を考えました。最初のBlitz.jsを参考にした方法は、正確に型がつけられるものの記述が少し冗長でした。また、prisma-paginateなどのライブラリの方法では、記述は簡潔になるものの、引数によって戻り値の型が変化することに対応できませんでした。

これらの問題は、Prisma Client extensionsを使って解決できました。

参考

GitHubで編集を提案

Discussion