Prismaでページネーションを実装する(Client extensionsも使ってみる)
Prismaでページ番号ベースのページネーションを実装してみます。また、ページネーションに関する処理をPrisma Client extensionsで共通化してみました。
書いたコードはこちらにあります。
ページネーションを実装する
PrismaのfindMany
には、SQLのOFFSETとLIMITに対応するskip
とtake
があります。
そのため、ページ番号ベースのページネーションは以下のように実装できます。合計ページ数は、フロント側で欲しいため計算しています。
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 }
}
上の実装には、改善したいところがいくつかあります。
- ページネーションを他でも実装する場合、
offset
やpageCount
を計算するロジックが複数の箇所に書かれてしまう - 返却するレスポンスの形式がバラバラになってしまう可能性がある(例えば、
pageCount
がtotalPages
になってしまう)
そのため、ページネーション用の関数を作って共通化します。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
と実行すると、T
はtypeof xprisma.post
になります。これに対し、Prisma.Args
やPrisma.Result
などの型を使って入出力に型をつけています。エディタ上でも問題なく補完や型チェックができました。
まとめ
Prismaのページネーションを共通化する方法を考えました。最初のBlitz.jsを参考にした方法は、正確に型がつけられるものの記述が少し冗長でした。また、prisma-paginateなどのライブラリの方法では、記述は簡潔になるものの、引数によって戻り値の型が変化することに対応できませんでした。
これらの問題は、Prisma Client extensionsを使って解決できました。
Discussion