🌶️

PrismaのTypedSQLがなぜアツイのか

2024/08/29に公開

Prisma界隈で話題沸騰中(自分調べ)のTypedSQLだが、自分の中ではかなりアツいと思っているので、その理由を語ろう。なおTypedSQLの機能とか仕組みについては記述しないのでドキュメントや以下の記事を参照するとよい。

https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/typedsql

https://zenn.dev/tockn/articles/0e6eac6220e072

Prismaの難しさ

複雑なクエリを組み立てるのが特に難しい。複雑といっても何10行もあるようなクエリとかではなく、joinとか集計関数がいくつかあるくらいで十分複雑になる。たとえば特定のユーザーに紐づく記事をコメントの数を含めて取得したいとする。クエリは雰囲気こんな感じ。SQLとしては全然難しくない。

SELECT
  posts.id,
  count(comments.id) AS cnt
FROM
  posts
INNER JOIN
  users ON posts.author_id = users.id
LEFT JOIN
  comments ON posts.id = comments.post_id
WHERE
  posts.author_id = $1
  AND users.deleted_at is null
GROUP BY
  posts.id

これをPrismaで書いてみる。

await prisma.post.findMany({
  where: {
    authorId: userId,
    author: { deletedAt: null },
  },
  select: {
    id: true,
    _count: {
      select: { comments: true },
    },
  },
});

まあ難しくないけど、実現したいことからこの書き方にたどり着くにはけっこう時間がかかる。なんか全然直感的じゃないんだよな。PrismaよりSQLのほうが慣れてるからと言ってしまえばそうだけど、多くの人はそうじゃなかろうか。

そして発行されるSQLはこうだ。

SELECT
  "public"."posts"."id",
  COALESCE("aggr_selection_0_Comment"."_aggr_count_comments", 0) AS "_aggr_count_comments"
FROM
  "public"."posts"
LEFT JOIN (
  SELECT
    "public"."comments"."post_id",
    COUNT(*) AS "_aggr_count_comments"
  FROM
    "public"."comments"
  WHERE
    1=1
  GROUP BY
    "public"."comments"."post_id"
) AS "aggr_selection_0_Comment"
  ON ("public"."posts"."id" = "aggr_selection_0_Comment"."post_id")
LEFT JOIN
  "public"."users" AS "j1" ON ("j1"."id" = "public"."posts"."author_id")
WHERE (
  ("j1"."id" = $1
  AND "j1"."deleted_at" IS NULL
  AND ("j1"."id" IS NOT NULL))
  AND ("public"."posts"."id") IN (SELECT "t2"."post_id" FROM "public"."comments" AS "t2" WHERE (1=1 AND "t2"."post_id" IS NOT NULL))
)
OFFSET
  $2
;

🤯

元々やりたかったことがこれで達成できているかを確認するのにだいぶ時間を要する。自分で実装して確認するならまだしも、このロジックに間違いがあったときにレビューで気付けるひとはPrismaの達人じゃないと無理そうだ。テストをがんばって書こう。

もちろん $queryRaw などを使って生SQLを実行することはできるが、今度は実行結果が型安全じゃなくなるし、SQLのシンタックスエラーも動かしてみないとわからない。こういった問題から、Prismaをやめてsqlcにしたいと思っていたのがつい最近の話だった。

sqlc

sqlcというGoのライブラリがある。

https://github.com/sqlc-dev/sqlc

これは基本的にはTypedSQLと同じコンセプトのもので、生SQLから型付きのコードを生成する。最近TypeScriptプラグインも開発が進んでいる。

https://github.com/sqlc-dev/sqlc-gen-typescript

自分はGoでは基本的にsqlcを使っていて、sqlcのアプローチが好きだったのでTypeScriptプラグインが実用段階になったらPrismaから乗り換えたいと思っていた。

しかし、sqlcのアプローチも万能ではない。例えばパラメータによる動的なクエリの構築が難しい。例えばPrismaで書くとこういうやつ。

function searchUser(params: SearchUerParams): Promise<User[]> {
  const where: Prisma.UserWhereInput = {};

  if (params.age) {
    where.age = params.age;
  }

  if (params.gender) {
    where.gender = params.gender;
  }

  return prisma.user.findMany({ where });
}

これをsqlcでやるとすると、一応こんな感じのクエリでできなくはない。

SELECT
  *
FROM
  users
WHERE
  (CASE WHEN @has_age::bool THEN age = @age ELSE true END)
  AND (CASE WHEN @has_gender::bool THEN gender = @gender ELSE true END)

そしてこれはPostgreSQL限定で、MySQLだと無理らしい。これについては以下で議論されているがあまり進展はない。

https://github.com/sqlc-dev/sqlc/discussions/364

それから、シンプルなクエリであればクエリを別途定義するよりPrismaのようなORMを使う方が便利だと思う。

// シンプルなSELECT
const user = await prisma.user.findUnique({ where: { id: userId } });

// INSERTするだけ
await prisma.user.create({ data });

まあこれは好みの問題なのでどっちでもいいけど、個人的にはこういうシンプルなクエリを毎回別ファイルに書いて型生成みたいなものは冗長に感じる。

つまり何がアツイのか

sqlcやTypedSQLが苦手とするケースがあって、そういったケースは従来のPrismaのAPIのようなORM(というかクエリビルダかな)を使ったほうが便利だと思っている。しかしこれら両方を解決するようなライブラリはなかった。そう、TypedSQLが現れるまでは。

自分がアツイと感じている点は、シンプルなクエリや動的なクエリはPrisma APIで、joinや集計関数を伴うような複雑なクエリはTypedSQL、という使い分けが同じライブラリでできるようになったというところなのだ。

Prismaは他にも細かいところで気になる点は色々あるけど、TypedSQLの登場はそういう問題はどうでもいいのでPrismaを使い続けようと思わせるぐらいのでインパクトがあった。これはアツい。アツいのだ。

Discussion