第 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:按字段维度“少拿一些列”,控制“只要哪些字段”。
例子:查文章时只拿作者的 name,而不拿作者的所有字段:
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)。