第 4 篇:Prisma 關聯建模與查詢實戰(blog-demo 專案)

從這一篇開始,我們從只有 User 的單表世界,走向有真實業務語義的多表世界:部落格系統 blog-demo。
我們會用 Prisma 建出一套經典關係模型:User / Profile / Post / Comment / Category,並圍繞它講清楚:
一對一(一人一檔案)
一對多(一人多文章、一文多評論)
多對多(一文多分類、一分類多文章)
關係欄位 vs 外鍵欄位
巢狀寫入與關係查詢(
include/select/ relation filter)
本篇預設你已經熟悉前面講過的 Schema 基礎語法(datasource / generator / 標量類型 / 屬性),如果忘了可以隨時翻回第 3 篇。
1. blog-demo 專案與整體模型設計
我們繼續沿用前幾篇的技術棧心智模型:
Prisma 7
PostgreSQL 作為資料庫
Next.js / Node.js 作為執行環境(本篇程式碼範例用 Node 風格
main()函式來示範,方便集中講關係)
本篇的目標資料模型是一個簡化部落格系統:
User:使用者
Profile:使用者檔案(一對一)
Post:文章(User 一對多 Post)
Comment:評論(Post 一對多 Comment)
Category:分類(Post 多對多 Category)
最終會形成下面這樣的關係:
User 1 — 1 Profile
User 1 — n Post
Post 1 — n Comment
Post m — n Category
2. blog-demo 的 schema.prisma:一口氣寫完,再逐段拆開
先給出一個完整的 schema 版本,然後我們分塊解釋每種關係寫法。
// generator & datasource:與前幾篇保持一致的風格
generator client {
provider = "prisma-client"
output = "../generated/blog"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 使用者表
model User {
id Int @id @default(autoincrement())
email String @unique
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 關係欄位
profile Profile?
posts Post[]
comments Comment[]
}
// 使用者檔案(一對一)
model Profile {
id Int @id @default(autoincrement())
bio String?
avatarUrl String?
userId Int @unique
user User @relation(fields: [userId], references: [id])
}
// 文章(一對多:User -> Post)
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId Int
author User @relation(fields: [authorId], references: [id])
comments Comment[]
categories PostCategory[]
}
// 評論(一對多:Post -> Comment;User -> Comment)
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
postId Int
post Post @relation(fields: [postId], references: [id])
authorId Int
author User @relation(fields: [authorId], references: [id])
}
// 分類(多對多:Post <-> Category)
model Category {
id Int @id @default(autoincrement())
name String
slug String @unique
posts PostCategory[]
}
// 顯式中間表(多對多 + 額外欄位的預留空間)
model PostCategory {
postId Int
categoryId Int
assignedAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id])
category Category @relation(fields: [categoryId], references: [id])
@@id([postId, categoryId])
}
這個 schema 涵蓋了:
一對一:User–Profile
一對多:User–Post、Post–Comment、User–Comment
多對多:Post–Category(顯式中間表 PostCategory)
下面我們按關係類型拆開解釋。
3. 一對一關係:User – Profile
3.1 一對一的概念與建模選擇
一對一=一個使用者有一個檔案,一個檔案屬於一個使用者。常見場景:鑑權相關欄位在 User,擴充資訊在 Profile。
在 Prisma 裡,一對一關係的寫法和「一對多」的語法很像,只是關係欄位那一側用單數+非陣列。
3.2 Schema 寫法拆解
model User {
// ...
profile Profile?
}
model Profile {
id Int @id @default(autoincrement())
bio String?
avatarUrl String?
userId Int @unique
user User @relation(fields: [userId], references: [id])
}
關鍵點:
在 Profile 上有一個外鍵欄位
userId Int,並且加了@unique
這保證了「一個 Profile 只對上一個 User」,防止多檔案綁同一個使用者。關係欄位(物件類型):
user User
使用@relation(fields: [userId], references: [id])指明:
使用本表的
userId欄位作為外鍵;對應 User 表的
id欄位。
User 這邊的關係欄位:
profile Profile?Profile?表示可選(一部分使用者可能還沒有檔案);不需要再寫
@relation,因為 Prisma 可以從反向推斷。
3.3 查詢與巢狀寫入範例
查詢某使用者和他的 Profile
const userWithProfile = await prisma.user.findUnique({
where: { id: 1 },
include: {
profile: true,
},
})
include.profile = true告訴 Prisma 一併載入關聯的 Profile;回傳物件類型中會多出
profile欄位。
在建立 User 時同時建立 Profile(巢狀寫入)
const user = await prisma.user.create({
data: {
email: 'bob@example.com',
name: 'Bob',
profile: {
create: {
bio: 'I love Prisma',
avatarUrl: 'https://example.com/avatar.png',
},
},
},
include: { profile: true },
})
要點:
profile: { create: { ... } }表示巢狀建立關聯記錄;Prisma 會幫你填好 Profile.userId 外鍵;
include可以直接把剛建立的 Profile 一起回傳。
4. 一對多關係:User – Post,Post – Comment
4.1 一對多的通用模式
一對多的核心是:
「多」的那一側有外鍵欄位(如 authorId、postId);
「一」的那一側有陣列關係欄位(如 posts Post[],comments Comment[]);
透過
@relation(fields: [...], references: [...])明確外鍵關係。
4.2 User – Post(一個使用者多篇文章)
model User {
// ...
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId Int
author User @relation(fields: [authorId], references: [id])
comments Comment[]
categories PostCategory[]
}
要點:
Post 有外鍵欄位
authorId Int;author User @relation(fields: [authorId], references: [id])明確「Post.authorId 引用 User.id」;User 的關係欄位是
posts Post[],陣列類型表示一個使用者有多篇文章。
查詢某使用者及其文章
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: true,
},
})
查詢文章時一併帶出作者
const posts = await prisma.post.findMany({
include: {
author: true,
},
})
建立 User 的同時建立多篇 Post(巢狀寫入)
const user = await prisma.user.create({
data: {
email: 'writer@example.com',
name: 'Writer',
posts: {
create: [
{ title: '第一篇文章', content: 'Hello Prisma' },
{ title: '第二篇文章', content: '關係建模真好玩' },
],
},
},
include: { posts: true },
})
給已有使用者新增一篇文章(連接已有關係)
const post = await prisma.post.create({
data: {
title: '新文',
content: '內容...',
author: {
connect: { id: 1 }, // 連接已有使用者
},
},
})
這裡體現了 Prisma 巢狀寫入裡的兩個常用操作:
create:建立新記錄;connect:連接已有記錄。
4.3 Post – Comment(文章的評論)
model Post {
// ...
comments Comment[]
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
postId Int
post Post @relation(fields: [postId], references: [id])
authorId Int
author User @relation(fields: [authorId], references: [id])
}
Comment 同時連著 Post 和 User(一條評論屬於某篇文章,也屬於某個使用者),所以有兩個外鍵:postId 和 authorId。
為某篇文章建立一條評論
const comment = await prisma.comment.create({
data: {
content: '寫得不錯!',
post: {
connect: { id: 123 }, // 關聯文章
},
author: {
connect: { id: 1 }, // 關聯使用者
},
},
})
查詢一篇文章及其評論(附帶作者)
const postWithComments = await prisma.post.findUnique({
where: { id: 123 },
include: {
comments: {
include: {
author: true,
},
},
},
})
這裡有一個巢狀 include:
include.comments:帶出評論列表;include.comments.include.author:每條評論再帶出作者。
5. 多對多關係:Post – Category
5.1 多對多的兩種建模方式
在 Prisma 裡,多對多關係有兩種常見方式:
隱式多對多(implicit many-to-many)
只在兩個模型上寫categories Category[]和posts Post[],Prisma 幫你自動建立中間表。顯式多對多(explicit many-to-many)
手動建立中間表模型,例如PostCategory,適合中間表需要額外欄位(比如排序、打分、時間戳)時。
本篇為了通用性,採用顯式中間表方式(更靈活)。
5.2 Schema 寫法:Category + PostCategory
model Category {
id Int @id @default(autoincrement())
name String
slug String @unique
posts PostCategory[]
}
model PostCategory {
postId Int
categoryId Int
assignedAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id])
category Category @relation(fields: [categoryId], references: [id])
@@id([postId, categoryId])
}
要點:
PostCategory沒有單獨的自增 id,而是用複合主鍵@@id([postId, categoryId]);assignedAt是中資料的額外欄位(記錄關聯建立時間);PostCategory.post和PostCategory.category分別透過外鍵欄位postId、categoryId關聯 Post 和 Category;Category 有
posts PostCategory[]作為反向關係欄位,Post 在前面也有categories PostCategory[]。
5.3 為文章新增分類
1)先建立分類
const tech = await prisma.category.create({
data: {
name: '技術',
slug: 'tech',
},
})
const life = await prisma.category.create({
data: {
name: '生活',
slug: 'life',
},
})
2)把某篇文章掛到多個分類下
const postCategoryLinks = await prisma.postCategory.createMany({
data: [
{ postId: 123, categoryId: tech.id },
{ postId: 123, categoryId: life.id },
],
skipDuplicates: true,
})
5.4 查詢:文章及其分類 / 分類下的文章
查文章時附帶分類
const post = await prisma.post.findUnique({
where: { id: 123 },
include: {
categories: {
include: {
category: true,
},
},
},
})
回傳結構類似:
post.categories是PostCategory[];每個
PostCategory裡還有category物件(真正的 Category 資訊)。
在實際專案裡,如果你覺得這太囉嗦,可以在應用層做一層映射,把 PostCategory 轉成更簡單的 { id, name, slug } 類型給前端用。
查某個分類下的所有文章
const categoryWithPosts = await prisma.category.findUnique({
where: { slug: 'tech' },
include: {
posts: {
include: {
post: true,
},
},
},
})
同理,這裡是 Category.posts(PostCategory[]),每個元素裡再有 post 欄位。
6. 關係查詢裡的幾個關鍵概念:include / select / relation filter
6.1 include vs select:要什麼資料、拿多少欄位?
include:按關係維度「多拿一些表」,控制「帶不帶某個關聯」;select:按欄位維度「少拿一些列」,控制「只要哪些欄位」。
例子:查文章時只拿作者的名稱,而不拿作者的所有欄位:
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
author: {
select: {
id: true,
name: true,
},
},
},
})
相反,如果你只是想多帶幾個關聯,但欄位懶得篩,只要 include: { author: true, comments: true } 就夠用。
6.2 relation filter:依關聯條件篩選
Prisma Client 支援依關聯條件篩選,比如:「找出有已發布文章的使用者」,「找出有超過 3 條評論的文章」等。
例如:找出擁有至少一篇 published 文章的使用者:
const usersWithPublished = await prisma.user.findMany({
where: {
posts: {
some: {
published: true,
},
},
},
})
這裡的 posts.some 是典型 relation filter 的寫法:
some:至少有一條關聯符合條件;還有
none(沒有任何關聯符合條件)、every(所有關聯都符合條件)等變體。
再比如:找出沒有任何評論的文章(沒有「被討論」的文章):
const postsWithoutComments = await prisma.post.findMany({
where: {
comments: {
none: {}, // 沒有任何 comment 即可
},
},
})
7. 巢狀寫入的幾個常見模式
你在實際程式碼中,很少手動分步做「先插入 User,再插入 Post,再手寫外鍵」,而是更常用 Prisma 的巢狀寫入能力:
常見模式:
create:在父記錄中建立子記錄;connect:連接已有記錄;connectOrCreate:如果存在就連,不存在就建立;update/delete/set/disconnect等用於關係更新。
典型例子:註冊一個使用者的同時,建立 Profile 並發一篇歡迎文章。
const user = await prisma.user.create({
data: {
email: 'newuser@example.com',
name: 'Newbie',
profile: {
create: {
bio: '新來的',
},
},
posts: {
create: {
title: 'Hello Blog',
content: '我的第一篇部落格',
published: true,
},
},
},
include: {
profile: true,
posts: true,
},
})
一個 API 請求,一次事務,多張表一起寫完,同時 Client 回傳完整的巢狀資料。
8. 這一篇之後,你已經可以為任何「小型業務」建出一套像樣的關係模型
到這裡,你已經掌握了 Prisma 關係建模裡的幾個關鍵技法:
一對一:透過單側
@unique外鍵 + 雙向關係欄位表達;一對多:外鍵在「多」的那一側,陣列關係欄位在「一」的那一側;
多對多:用顯式中間表建模,為「關係本身」留出空間;
巢狀寫入:
create/connect等減少手動拼外鍵;關係查詢:
include/select/ relation filter(some/none/every)。
在 Google 上持續關注
把 HeyBinyang 加入 Google 首選來源
如果你希望之後在 Google 上更容易看到我的更新,可以把這個站點加入 preferred source,讓它在相關閱讀情境裡更容易被找到。
SHARE
分享
分享這篇文章。