リレーション
1-1 / 1-N / N-N の定義とクエリ、include / select、入れ子書き込み(nested writes)、 N+1 問題、connect/disconnect まで。
リレーションフィールド
Prisma では関連を 2 種類で表現する:
- スカラー(外部キー列):
authorId String - リレーションフィールド:
author User @relation(fields: [authorId], references: [id])
両方を 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 | 子があれば親を消せない |
| NoAction | DB のデフォルト |
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 回で済む
})
巨大なリレーションを持つときの工夫
- include で take を指定: 関連も常に最新 N 件だけ
- 別クエリに分けてPromise.allで並列化
- 集計したいだけなら
_countを include - 本当にデカいなら$queryRaw で SQL を書く
relationLoadStrategy(v5+)
Prisma の関連取得アルゴリズムを選べる:
- "join": 1 クエリで JOIN(デフォルトに近づきつつある)
- "query": 関連ごとに別クエリ(旧来)
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 は将来「中間に列が増えるか」を検討して、迷ったら明示的中間テーブル。