リレーション

1-1 / 1-N / N-N の定義とクエリ、include / select、入れ子書き込み(nested writes)、 N+1 問題、connect/disconnect まで。

リレーションフィールド

Prisma では関連を 2 種類で表現する:

両方を model に書くのが Prisma の流儀。リレーションフィールドは TypeScript 上の便利機能で、 DB には外部キー(authorId)だけが存在する。

1-N(最も一般的)

model User {
  id    String @id @default(cuid())
  email String @unique
  posts Post[]                      // 多側
}

model Post {
  id       String @id @default(cuid())
  title    String
  authorId String
  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)

  @@index([authorId])
}

クエリ

// 親 → 子
const user = await prisma.user.findUnique({
  where: { id },
  include: { posts: true }
})

// 子 → 親
const post = await prisma.post.findUnique({
  where: { id },
  include: { author: true }
})

// 関連数
const u = await prisma.user.findUnique({
  where: { id },
  include: { _count: { select: { posts: true } } }
})

1-1

多側にユニーク制約を付ける。profile は 1 ユーザに 1 つだけ:

model User {
  id      String   @id @default(cuid())
  email   String   @unique
  profile Profile?
}

model Profile {
  id     String  @id @default(cuid())
  bio    String?
  userId String  @unique               // 1-1 を保証
  user   User    @relation(fields: [userId], references: [id], onDelete: Cascade)
}

N-N(多対多)

暗黙の中間テーブル(Prisma が自動作成)

model Post {
  id    String @id @default(cuid())
  tags  Tag[]
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]
}

Prisma が自動で _PostToTag テーブルを作る。シンプルな関連はこれで OK。

明示的な中間テーブル(カラムを足したい時)

model Post {
  id   String     @id @default(cuid())
  tags PostTag[]
}

model Tag {
  id    String     @id @default(cuid())
  name  String     @unique
  posts PostTag[]
}

model PostTag {
  postId    String
  tagId     String
  createdAt DateTime @default(now())   // 中間に固有データ
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag       Tag      @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([postId, tagId])
}

同じテーブルへの複数 FK(曖昧解消)

sender と receiver が両方 User の場合、リレーション名で区別する:

model Message {
  id         String @id @default(cuid())
  body       String
  senderId   String
  receiverId String
  sender     User   @relation("MessagesSent",     fields: [senderId],   references: [id])
  receiver   User   @relation("MessagesReceived", fields: [receiverId], references: [id])
}

model User {
  id              String    @id @default(cuid())
  messagesSent    Message[] @relation("MessagesSent")
  messagesReceived Message[] @relation("MessagesReceived")
}

自己参照(ツリー / グラフ)

model Comment {
  id       String    @id @default(cuid())
  body     String
  parentId String?
  parent   Comment?  @relation("ChildComments", fields: [parentId], references: [id])
  children Comment[] @relation("ChildComments")
}

onDelete / onUpdate のオプション

挙動
Cascade親が消えたら子も消す
SetNull子の FK を NULL に(FK が optional 必要)
SetDefault子の FK をデフォルト値に
Restrict子があれば親を消せない
NoActionDB のデフォルト

include / select / 入れ子

深いネスト

await prisma.user.findUnique({
  where: { id },
  include: {
    posts: {
      include: {
        comments: {
          orderBy: { createdAt: "desc" },
          take: 5,
          include: { author: { select: { id: true, name: true } } }
        },
        tags: true,
      }
    }
  }
})

関連の絞り込み

await prisma.user.findUnique({
  where: { id },
  include: {
    posts: {
      where: { published: true },     // 公開済みのみ
      orderBy: { createdAt: "desc" },
      take: 10,
    }
  }
})

入れ子書き込み(Nested Writes)

create

// 親と子を一緒に作る
await prisma.user.create({
  data: {
    email: "alice@example.com",
    posts: {
      create: [
        { title: "first" },
        { title: "second" },
      ]
    }
  }
})

connect(既存と紐付け)

await prisma.post.create({
  data: {
    title: "...",
    author: { connect: { id: userId } }
  }
})

// または authorId 直接(簡単)
await prisma.post.create({
  data: { title: "...", authorId: userId }
})

connectOrCreate

await prisma.post.update({
  where: { id },
  data: {
    tags: {
      connectOrCreate: ["js", "react"].map((name) => ({
        where: { name },
        create: { name }
      }))
    }
  }
})

disconnect / set / delete

// 紐付けを外す(行は残る)
await prisma.post.update({
  where: { id },
  data: { tags: { disconnect: { id: tagId } } }
})

// セット(指定したものだけになる、それ以外は外す)
await prisma.post.update({
  where: { id },
  data: { tags: { set: [{ id: t1 }, { id: t2 }] } }
})

// 関連の行を削除
await prisma.user.update({
  where: { id },
  data: { posts: { delete: { id: postId } } }
})

// 関連の更新
await prisma.user.update({
  where: { id },
  data: {
    posts: {
      update: {
        where: { id: postId },
        data: { published: true }
      }
    }
  }
})

updateMany / deleteMany(関連内)

await prisma.user.update({
  where: { id },
  data: {
    posts: {
      updateMany: {
        where: { published: false },
        data: { archived: true }
      },
      deleteMany: { spam: true }
    }
  }
})

関連の where

some / every / none

// 「ある投稿でも公開していれば」
await prisma.user.findMany({
  where: { posts: { some: { published: true } } }
})

// 「すべての投稿が公開」
await prisma.user.findMany({
  where: { posts: { every: { published: true } } }
})

// 「draft の投稿が無い」
await prisma.user.findMany({
  where: { posts: { none: { status: "draft" } } }
})

1-1 の where

await prisma.user.findMany({
  where: { profile: { is: { country: "JP" } } }
})

await prisma.user.findMany({
  where: { profile: { isNot: { country: "JP" } } }
})

N+1 問題

一見便利な include だが、意図せず N+1 クエリが走ることがある。

悪い例

const users = await prisma.user.findMany()
for (const u of users) {
  const posts = await prisma.post.findMany({ where: { authorId: u.id } })
  // ループの中でクエリ → N+1
}

良い例

const users = await prisma.user.findMany({
  include: { posts: true }      // JOIN 1 回で済む
})

巨大なリレーションを持つときの工夫

relationLoadStrategy(v5+)

Prisma の関連取得アルゴリズムを選べる:

await prisma.user.findMany({
  relationLoadStrategy: "join",
  include: { posts: true },
})

FK のないリレーション(PlanetScale など)

datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"   // FK を作らず Prisma 側で擬似制約
}

PlanetScale のようなブランチング DB は FK 非対応。relationMode = "prisma" で対応。

多段階リレーションの設計例

Workspace → Member → User

model Workspace {
  id      String   @id @default(cuid())
  name    String
  members Member[]
}

model Member {
  id          String    @id @default(cuid())
  workspaceId String
  userId      String
  role        Role      @default(MEMBER)
  workspace   Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([workspaceId, userId])
}

model User {
  id      String   @id @default(cuid())
  email   String   @unique
  members Member[]
}

enum Role {
  OWNER
  ADMIN
  MEMBER
}

典型的な「テナント機能」。Member が中間テーブル + 役割を持つ。

結合された型を取り出す

import { Prisma } from "@prisma/client"

const userWithPosts = Prisma.validator<Prisma.UserDefaultArgs>()({
  include: { posts: true }
})
type UserWithPosts = Prisma.UserGetPayload<typeof userWithPosts>

function render(u: UserWithPosts) {
  console.log(u.posts.length)
}

失敗パターン

症状原因 / 対処
重いinclude 過剰。select で絞る
循環的に深い関連を減らす / 別クエリに分ける
connect が動かないwhere に unique 列を指定
delete cascade が起きないonDelete: Cascade を設定 + migration 適用
FK エラーで insert 失敗親が存在するか先に確認、connectOrCreate
暗黙中間テーブルに列を足したい明示的中間モデルに移行
設計の指針

1-1 はインデックスを忘れない。1-N は子側に @@index([authorId])。 N-N は将来「中間に列が増えるか」を検討して、迷ったら明示的中間テーブル