7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NIJIBOXAdvent Calendar 2023

Day 4

DBスキーマファーストな Prisma の型定義に対する zod の使い方 と Fragment colocation ライクな実装

Last updated at Posted at 2023-12-07

どうも、アドカレの時期だけ記事を投稿するマンです🧘

去年に引き続き今年もこれと言ったネタがありませんが、自分の学んだことをつらつらと書きます。

(ちなみに)
去年のアドカレ記事でご紹介したライブラリ Lenis は既にメジャーバージョンがリリースされており Weekly Downloads も 10K超え(2023/11/23 - 2023/11/29)と、かなりプロダクション環境にも使えるようになっていそうです👀(詳しくは以下の記事をご覧ください)

はじめに

REST API のような既存の型情報がない場合は、zod のスキーマを正とした実装が可能です。それに対して Prisma は DB ソリューションと型生成が一つになっています。

そのため Prisma のスキーマ を Single Source Of Truth とする設計に対して、Prisma スキーマから生成される型情報に沿っている事を担保する zod の schema を実装します。

(これだけではあまりにも内容が薄すぎたので、Fragment colocation ライクな実装という別トピックも記載しましたw)

結論

TypeScript4.9 から追加された satisfies 演算子を使う事で、型の Widening を抑えつつ簡単に zod のスキーマが既存型情報に沿っていることを担保 & コロケーションが実現できます。

schema.prisma
model User {
  id String @id @default(uuid())
  name String
  imageUrl String @db.Text
  email String @db.Text
  isPremium Boolean @default(false)
  servers Server[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

上記の prisma のスキーマがある場合における User に対して新規レコードを作成する場合のzodスキーマは、以下のように実装できます。

sample.ts
import { z } from 'zod';
import { Prisma } from '@prisma/client';

// ✅
const UserCreateSchema = z.object({
  name: z.string(),
  imageUrl: z.string(),
  email: z.string(),
  isPremium: z.boolean().optional(),
}) satisfies z.ZodType<Prisma.UserCreateInput>;

// ❌
const UserCreateSchema = z.object({
  name: z.string(),
  imageUrl: z.string(),
  email: z.number(), // error : 型 'number' を型 'string' に割り当てることはできません。
  isPremium: z.boolean().optional(),
}) satisfies z.ZodType<Prisma.UserCreateInput>;

ご存知の方は多いかと思いますが、ここで出てきているUserCreateInputという型は、Prisma Client のヘルパー型を使用して動的に生成されている型情報になります。

Prisma Client のヘルパー型 とは?

npx prisma generateを実行した際に生成されるデータモデルの型は、Prisma Client のヘルパー型を用いて動的に生成されています。そして型変数名は MODAL_NAME にはスキーマに定義したデータモデル名が割り当てられるようになっています。

node_modules/.prisma/client/index.d.ts
- Prisma.<MODAL_NAME>DefaultArgs
- Prisma.<MODAL_NAME>GetPayload
- Prisma.<MODAL_NAME>Select
- Prisma.<MODAL_NAME>CountAggregateInputType

// データモデル名が User の場合
- Prisma.UserDefaultArgs
- Prisma.UserGetPayload
- Prisma.UserSelect
- Prisma.UserCountAggregateInputType

Fragment colocationとは

コロケーション(colocate) は 文字通り、 co(一緒に) + locate(配置する) という意味であり、コンポーネント内で必要なデータをコンポーネントと同じ場所で宣言する手法です。

GraphQL の文脈では、コンポーネントが必要なデータを GraphQL の Fragment で記述し、コンポーネント内で宣言する事を指します。

Fragment colocation を使うことで、親コンポーネントは、子コンポーネントそれぞれで宣言された Fragment を取りまとめて、単一クエリを投げるという形を取ります。

そうすることで、親コンポーネントは子コンポーネントが子コンポーネントで宣言されたフラグメントだけを知っていれば良いため、どのようなフィールドが必要かを知る必要がなくなります。

要するに、 コンポーネントが必要とするデータ(クエリ)はそのコンポーネント内で管理しようぜという事です。

Fragment colocation ライクな使い方をしてみる

Prisma Client のヘルパー型 と satisfies 演算子を用いて、Next.js(App Router) で以下の3つのコンポーネントを Fragment colocation ライクに実装してみます。

  • ログインユーザーの詳細一覧を表示するページ (UserDetail.tsx) ← 親コンポーネント
  • ログインユーザーが参加しているサーバー情報一覧を表示するコンポーネント (ServerDetail.tsx
  • ログインユーザーがフォローしているメンバーを表示するコンポーネント (MemberDetail.tsx

なお、今回使用したライブラリのバージョンは以下の通りです。

"next": "13.5.6",
"typescript": "5.3.2",
"prisma": "^5.6.0",
"@prisma/client": "^5.6.0",

MemberDetail.tsx
// ログインユーザーがフォローしているメンバーを表示するコンポーネント

import { Prisma } from "@prisma/client";

export const MemberDetailFragment = {
  members: {
    select: {
      id: true,
      role: true,
      createdAt: true,
      updatedAt: true,
      profile: true,
    },
  },
} satisfies Prisma.UserSelect;

type MemberDetailFragment = Prisma.UserGetPayload<{
  select: typeof MemberDetailFragment;
}>;

const MemberDetail = ({ members }: { members: MemberDetailFragment }) => {
  return (
    <div>
      <h2>Member Detail</h2>
      <ul>
        {members.members.map((member) => (
          <li key={member.id}>{member.profile.name}</li>
        ))}
      </ul>
    </div>
  );
};
ServerDetail.tsx
// ログインユーザーが参加しているサーバーを表示するコンポーネント

import { Prisma } from "@prisma/client";

export const ServerDetailFragment = {
  servers: true,
} satisfies Prisma.UserSelect;

type ServerDetailFragment = Prisma.UserGetPayload<{
  select: typeof ServerDetailFragment;
}>;

const ServerDetail = ({ servers }: { servers: ServerDetailFragment }) => {
  return (
    <div>
      <h2>Server Detail</h2>
      <ul>
        {servers.servers.map((server) => (
          <li key={server.id}>{server.name}</li>
        ))}
      </ul>
    </div>
  );
};
UserDetail.tsx
// ログインユーザーの詳細ページ (親コンポーネント)

import { db } from "@/lib/db";
import { Prisma } from "@prisma/client";

import { ServerDetail } from "./ServerDetail";
import { MemberDetail } from "./MemberDetail";

import type { ServerDetailFragment } from "./ServerDetail";
import type { MemberDetailFragment } from "./MemberDetail";

export const UserDetailFragment = {
  userId: true,
  name: true,
  email: true,
  imageUrl: true,
  createdAt: true,
} satisfies Prisma.UserSelect;

export const UserDetail = async () => {

  const loginUser = await currentLoginUser();

  const user = await db.user.findUnique({
    where: {
      userId: loginUser.userId,
    },
    select: {
      ...UserDetailFragment,
      ...MemberDetailFragment,
      ...ServerDetailFragment,
    },
  });

  if (!user) return <div>No Result.</div>;

  return (
    <div>
      <div>
        <div>{user?.userId} : {user?.name}</div>
        <ServerDetail servers={user} />
        <MemberDetail members={user} />
      </div>
    </div>
  );
};

終わり

TypeScript4.9 にて追加されたsatisfies 演算子を使うことで、Prisma スキーマから生成される型情報に zod のスキーマが沿っている事を担保する事や、Fragment colocationライクなクエリの再利用を実装する事が出来ました。

記載している内容に何か間違いがありましたら、コメント頂ければと思います。

少しでもどなたかのお役に立てれば何よりです!

では明日のアドカレ記事にご期待ください!

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?