🍰

PrismaPromise とは何か

2021/12/28に公開約6,000字

はじめに

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 の解説はこちら:

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/await#thenable_objects

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 は下記で定義されています。

https://github.com/prisma/prisma/blob/f0631b2d97b895645b35a40d0327aa58191ce056/packages/client/src/runtime/getPrismaClient.ts#L1532-L1550

また、PrismaPromise の型定義のすぐ下に createPrismaPromise という関数が定義されており、コメントで次のようなことが書かれています。

Creates a [[PrismaPromise]]. It is Prisma's implementation of Promise which is essentially a proxy for Promise. 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 つですね。

https://www.prisma.io/docs/guides/performance-and-optimization/prisma-client-transactions-guide#transaction-api

Fluent API

UserPost が 1-n の関係にあるとき、ある UserPost を複数取得するときは次のように書けます。

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 問題を解決するための仕組みなどに使われます。

https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries#fluent-api

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 つでした。

GitHubで編集を提案

Discussion

ログインするとコメントできます