PrismaPromise とは何か
はじめに
Node.js の TypeScript-friendly な ORM である Prisma についての記事です。Prisma では PrismaPromise
型の値がよく使われており、それについて調べたことと、その設計が素晴らしい点をまとめています。
クエリの実行タイミング
次のような User
モデルが定義されているとき、
model User {
id Int @id @default(autoincrement())
email String @unique
}
User
モデルのレコードは次のようにして作成できます。
await prisma.user.create({
data: {
email: "foo@example.com",
},
});
ログを出力してみると、ちゃんとクエリが実行されているのが確認できます。
prisma:query BEGIN
prisma:query INSERT INTO `main`.`User` (`email`) VALUES (?) RETURNING id
prisma:query SELECT `main`.`User`.`id`, `main`.`User`.`email` FROM `main`.`User` WHERE `main`.`User`.`id` = ? LIMIT ? OFFSET ?
prisma:query COMMIT
一見すると、prisma.user.create
の返り値は Promise
でラップされた値で、それを await
で fulfilled になるのを待機しているように思えます。しかし、次のようなコードではクエリは実行されません。
// await せず、非同期でクエリを実行しておく
prisma.user.create({
data: {
email: "foo@example.com",
},
});
console.log("waiting...");
// クエリの実行が完了するまで十分な時間待ってみる
await setTimeout(5000);
console.log("done");
このとき "done"
が出力された後もクエリのログは出力されず、レコードも作成されません。findMany
や $executeRaw
など、DB 問い合わせが発生する他のメソッドでも同様です。なぜこのようなことが起こるのか、説明する前にまず Thenable オブジェクトについて知っておく必要があります。
Thenable オブジェクト
Thenable オブジェクトは then
という名前のメソッドもしくはプロパティを持つオブジェクトです。
const thenable1 = {
then: function (resolved) {
resolved();
},
};
// これも Thenable オブジェクト
const thenable2 = {
then(resolved) {
resolved();
},
};
// これも Thenable オブジェクト
const thenable3 = {
then: (resolved) => {
resolved();
},
};
MDN の解説はこちら:
Thenable オブジェクトは .then
を呼び出す、もしくは await
することで処理を実行することができます。
const thenable = {
then: (resolve) => {
console.log("foo");
resolve();
},
};
thenable.then(() => {}); // "foo"
// もしくは
await thenable; // "foo"
先に結論を述べると、prisma.user.create
の返り値の正体は Thenable オブジェクトだったというわけです。次の節では Prisma の実装を見ていきます。
PrismaPromise
prisma.user.create
の返り値の型を調べてみると Prisma.Prisma__UserClient<User>
というクラスのインスタンスになっており、このクラスは Promise
を継承した PrismaPromise
という interface を実装していることがわかります。PrismaPromise
は下記で定義されています。
また、PrismaPromise
の型定義のすぐ下に createPrismaPromise
という関数が定義されており、コメントで次のようなことが書かれています。
Creates a [[PrismaPromise]]. It is Prisma's implementation of
Promise
which is essentially a proxy forPromise
. All the transaction-compatible client methods return one, this allows for pre-preparing queries without executing them until.then
is called. It's the foundation of Prisma's query batching.
つまり、PrismaPromise
は Prisma による Promise
の実装で、transaction に対応したすべてのクライアントメソッド(prisma.user.create
など)は PrismaPromise
を返しており、 .then
が呼び出されるまで(もしくは await
するまで)クエリを実行せずに事前準備することができるということです。実際に createPrismaPromise
の実装を見てみると、返り値は Thenable オブジェクトになっており、クエリは .then
が呼び出されるタイミングで実行されることがわかります。
PrismaPromise を活用した機能
ここから先は余談です。PrismaPromise
を理解した上で、これを活用した Prisma の機能の $transaction
API と Fluent API を見ていきます。
$transaction
API
Prisma には、次のような transaction のための API があります。
const createUser = prisma.user.create({
// ...
});
const updateUser = prisma.user.update({
// ...
});
const deleteUser = prisma.user.delete({
// ...
});
await prisma.$transaction([createUser, updateUser, deleteUser]);
prisma.$transaction
は引数に PrismaPromise
の配列を受け付け、配列の先頭から順番にクエリを実行していき、失敗したクエリがあった場合すべての変更をロールバックします。これは Prisma が PrismaPromise
を返す(クエリの実行を保留する)という設計にすることによって実現されている API の 1 つですね。
Fluent API
User
と Post
が 1-n の関係にあるとき、ある User
の Post
を複数取得するときは次のように書けます。
const posts = await prisma.post.findMany({
where: {
userId: 42,
},
});
これを 3 つの userId
分のレコードを取得しようとすると次のようになります。
const getPosts1 = prisma.post.findMany({
where: {
userId: 42,
},
});
const getPosts2 = prisma.post.findMany({
where: {
userId: 43,
},
});
const getPosts3 = prisma.post.findMany({
where: {
userId: 44,
},
});
const [posts1, posts2, posts3] = await Promise.all([
getPosts1,
getPosts2,
getPosts3,
]);
このときクエリは 3 回実行されます。
prisma:query SELECT `main`.`Post`.`id`, `main`.`Post`.`title`, `main`.`Post`.`content`, `main`.`Post`.`userId` FROM `main`.`Post` WHERE `main`.`Post`.`userId` = ? LIMIT ? OFFSET ?
prisma:query SELECT `main`.`Post`.`id`, `main`.`Post`.`title`, `main`.`Post`.`content`, `main`.`Post`.`userId` FROM `main`.`Post` WHERE `main`.`Post`.`userId` = ? LIMIT ? OFFSET ?
prisma:query SELECT `main`.`Post`.`id`, `main`.`Post`.`title`, `main`.`Post`.`content`, `main`.`Post`.`userId` FROM `main`.`Post` WHERE `main`.`Post`.`userId` = ? LIMIT ? OFFSET ?
Prisma には上記のような場合においてレコードを効率的に取得するための仕組みとして Fluent API という機能が提供されています。Fluent API を使うと、同一イベントループに発生した同じ where と select のパラメータを持つクエリを 1 つのクエリに最適化してくれます。いわゆる DataLoader で、GraphQL において N+1 問題を解決するための仕組みなどに使われます。
Fluent API を使うと次のように書けます。
const getPosts1 = prisma.user
.findUnique({
where: {
id: 42,
},
})
.posts();
const getPosts2 = prisma.user
.findUnique({
where: {
id: 43,
},
})
.posts();
const getPosts3 = prisma.user
.findUnique({
where: {
id: 44,
},
})
.posts();
const [posts1, posts2, posts3] = await Promise.all([
getPosts1,
getPosts2,
getPosts3,
]);
このとき、クエリは 2 回しか実行されません。また、取得する User
の数をいくら増やしても 2 回から変わりありません。
prisma:query SELECT `main`.`User`.`id` FROM `main`.`User` WHERE `main`.`User`.`id` IN (?,?,?) LIMIT ? OFFSET ?
prisma:query SELECT `main`.`Post`.`id`, `main`.`Post`.`title`, `main`.`Post`.`content`, `main`.`Post`.`userId` FROM `main`.`Post` WHERE `main`.`Post`.`userId` IN (?) LIMIT ? OFFSET ?
こちらも PrismaPromise
を返すという設計による恩恵の 1 つでした。
Discussion