Prisma实体关系定义详解
学习目标:掌握 Prisma Schema 中一对一、一对多、多对多关系的定义方式,理解关系查询原理,对比 Prisma 和 TypeORM 的差异。
一、实体关系类型概览
1.1 三种常见关系类型
code
数据库实体关系类型:
│
├── One-to-One(一对一)
│ ├── 示例:用户 ↔ 个人资料
│ ├── 特点:一个用户只有一个个人资料
│ └── 定义关键字:@relation(fields, references)
│
├── One-to-Many(一对多)
│ ├── 示例:用户 → 文章
│ ├── 特点:一个用户可以有多篇文章
│ └── 定义关键字:@relation(fields, references)
│
└── Many-to-Many(多对多)
├── 示例:文章 ↔ 分类
├── 特点:一篇文章可以有多个分类,一个分类可以有多篇文章
└── 定义方式:需要中间关联表1.2 Prisma 关系定义核心概念
| 概念 | 说明 | 示例 |
|---|---|---|
| fields | 当前表的外键字段 | userId |
| references | 关联表的主键字段 | id |
| @relation | 定义关系的装饰器 | @relation(fields: [userId], references: [id]) |
| 关联表 | 多对多关系的中间表 | _PostToCategory |
二、一对一关系(One-to-One)
2.1 一对一关系定义
prisma
// prisma/schema.prisma
// 用户表
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
// 一对一关系:一个用户有一个个人资料
profile Profile? // 可选的个人资料
@@map("users")
}
// 个人资料表
model Profile {
id Int @id @default(autoincrement())
bio String?
// 外键字段
userId Int @unique // 外键必须唯一
// 定义关系
user User @relation(fields: [userId], references: [id])
@@map("profiles")
}2.2 一对一关系要点
code
一对一关系定义要点:
│
├── 主表(User)
│ ├── 关联字段类型:Profile?(可选)
│ └── 不存储外键
│
├── 从表(Profile)
│ ├── 外键字段:userId(必须)
│ ├── 外键约束:@unique(一对一必须)
│ └── @relation 定义
│ ├── fields: [userId](当前表的外键)
│ └── references: [id](主表的主键)
│
└── 关系特点
├── 一个 User 有一个 Profile
├── 一个 Profile 属于一个 User
└── 外键必须唯一2.3 联合主键的一对一关系
prisma
// 联合主键示例
model User {
firstName String
lastName String
profile Profile?
@@id([firstName, lastName]) // 联合主键
@@map("users")
}
model Profile {
id Int @id @default(autoincrement())
bio String?
// 联合外键
userFirstName String
userLastName String
// 定义联合关系
user User @relation(
fields: [userFirstName, userLastName],
references: [firstName, lastName]
)
@@unique([userFirstName, userLastName]) // 联合唯一约束
@@map("profiles")
}三、一对多关系(One-to-Many)
3.1 一对多关系定义
prisma
// prisma/schema.prisma
// 用户表
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
// 一对多关系:一个用户有多篇文章
posts Post[] // 数组类型
@@map("users")
}
// 文章表
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
// 外键字段
authorId Int // 外键
// 定义关系
author User @relation(fields: [authorId], references: [id])
@@map("posts")
}3.2 一对多关系要点
code
一对多关系定义要点:
│
├── 主表(User)
│ ├── 关联字段类型:Post[](数组)
│ └── 不存储外键
│
├── 从表(Post)
│ ├── 外键字段:authorId(必须)
│ ├── 外键约束:不需要 @unique(一对多)
│ └── @relation 定义
│ ├── fields: [authorId]
│ └── references: [id]
│
└── 关系特点
├── 一个 User 有多个 Post
├── 一个 Post 属于一个 User
└── 外键可以重复3.3 一对多关系查询示例
typescript
// 查询用户及其所有文章
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: true, // 包含所有文章
},
});
// 结果
{
id: 1,
email: 'user@example.com',
name: 'John',
posts: [
{ id: 1, title: '文章1', content: '...', authorId: 1 },
{ id: 2, title: '文章2', content: '...', authorId: 1 },
]
}四、多对多关系(Many-to-Many)
4.1 多对多关系定义
prisma
// prisma/schema.prisma
// 文章表
model Post {
id Int @id @default(autoincrement())
title String
content String?
// 多对多关系
categories Category[] // 数组类型
@@map("posts")
}
// 分类表
model Category {
id Int @id @default(autoincrement())
name String
// 多对多关系
posts Post[] // 数组类型
@@map("categories")
}
// 中间关联表(显式定义)
model _PostToCategory {
postId Int
categoryId Int
post Post @relation(fields: [postId], references: [id])
category Category @relation(fields: [categoryId], references: [id])
@@id([postId, categoryId]) // 联合主键
@@map("_PostToCategory")
}4.2 多对多关系查询原理
code
多对多关系查询路径:
│
├── 第一步:查询主表(Post)
│ └── SELECT * FROM posts WHERE id = 1
│
├── 第二步:查询中间表
│ └── SELECT * FROM _PostToCategory WHERE postId = 1
│ └── 得到:postId=1, categoryId=1, 2
│
├── 第三步:查询关联表(Category)
│ └── SELECT * FROM categories WHERE id IN (1, 2)
│
└── 最终结果
├── Post 数据
└── Categories 数组4.3 多对多关系查询示例
typescript
// 查询文章及其所有分类
const postWithCategories = await prisma.post.findUnique({
where: { id: 1 },
include: {
categories: true, // 包含所有分类
},
});
// 结果
{
id: 1,
title: '文章标题',
content: '文章内容',
categories: [
{ id: 1, name: '技术' },
{ id: 2, name: '前端' },
]
}
// 查询分类及其所有文章
const categoryWithPosts = await prisma.category.findUnique({
where: { id: 1 },
include: {
posts: true, // 包含所有文章
},
});4.4 多对多关系要点
code
多对多关系定义要点:
│
├── 实体表(Post、Category)
│ ├── 关联字段类型:数组
│ └── 不存储外键
│
├── 中间表(_PostToCategory)
│ ├── 存储两个外键:postId, categoryId
│ ├── 联合主键:@@id([postId, categoryId])
│ ├── 两个 @relation 定义
│ └── 实际存储数据:只有 postId 和 categoryId
│
└── 关系特点
├── 一个 Post 有多个 Category
├── 一个 Category 有多个 Post
└── 通过中间表关联五、Prisma vs TypeORM 对比
5.1 定义方式对比
Prisma 定义方式
prisma
// 一对多关系:简洁明了
model User {
id Int @id @default(autoincrement())
posts Post[]
@@map("users")
}
model Post {
id Int @id @default(autoincrement())
authorId Int
author User @relation(fields: [authorId], references: [id])
@@map("posts")
}TypeORM 定义方式
typescript
// 一对多关系:复杂繁琐
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToOne } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@OneToMany(() => Post, post => post.author)
posts: Post[];
}
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
authorId: number;
@ManyToOne(() => User, user => user.posts)
author: User;
}5.2 对比总结
| 维度 | Prisma | TypeORM |
|---|---|---|
| 定义语法 | 声明式 Schema | 装饰器 + TypeScript |
| 可读性 | 非常清晰 | 需要熟悉装饰器 |
| 学习曲线 | 平缓 | 陡峭 |
| 关系定义 | @relation(fields, references) | @OneToMany + @ManyToOne |
| 类型安全 | 自动生成 | 手动定义 |
| 迁移工具 | 内置 | 需要额外配置 |
| 查询 API | 直观简洁 | 类似 SQL |
5.3 Prisma 的优势
code
Prisma 的优势:
│
├── 1. 简洁的 Schema 定义
│ ├── 声明式语法
│ ├── 直观的关系定义
│ └── 自动生成类型
│
├── 2. 自动类型安全
│ ├── Prisma Client 自动生成
│ ├── TypeScript 完美支持
│ └── 编译时错误检查
│
├── 3. 强大的查询 API
│ ├── 直观的方法命名
│ ├── 类型推断
│ └── 关系查询简化
│
└── 4. 内置迁移工具
├── prisma migrate dev
├── 版本控制
└── 团队协作友好六、MongoDB 非关系型数据库差异
6.1 MongoDB 关系定义
prisma
// MongoDB 多对多关系:无中间表
model Post {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
content String?
// 直接存储 ObjectId 数组
categoryIds String[] @db.ObjectId
categories Category[] @relation(fields: [categoryIds], references: [id])
@@map("posts")
}
model Category {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String
posts Post[]
@@map("categories")
}6.2 MongoDB vs 关系型数据库
| 维度 | 关系型数据库(PostgreSQL) | 非关系型数据库(MongoDB) |
|---|---|---|
| 多对多实现 | 中间表 | ObjectId 数组 |
| 数据存储 | 关联表存储外键 | 文档内嵌 ObjectId 数组 |
| 查询方式 | JOIN 查询 | 多次查询或聚合 |
| 性能特点 | 适合复杂查询 | 适合简单查询 |
| 扩展性 | 垂直扩展 | 水平扩展 |
| 事务支持 | 强一致性 | 最终一致性 |
七、关系查询 API
7.1 嵌套查询(include)
typescript
// 一对一:查询用户及其个人资料
const userWithProfile = await prisma.user.findUnique({
where: { id: 1 },
include: {
profile: true,
},
});
// 一对多:查询用户及其所有文章
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: true,
},
});
// 多对多:查询文章及其所有分类
const postWithCategories = await prisma.post.findUnique({
where: { id: 1 },
include: {
categories: true,
},
});
// 深度嵌套查询
const userWithPostsAndCategories = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
include: {
categories: true,
},
},
},
});7.2 选择字段(select)
typescript
// 只选择需要的字段
const userWithSelectedFields = await prisma.user.findUnique({
where: { id: 1 },
select: {
id: true,
name: true,
posts: {
select: {
id: true,
title: true,
},
},
},
});7.3 过滤关联数据
typescript
// 查询用户及其已发布的文章
const userWithPublishedPosts = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
where: {
published: true, // 过滤条件
},
orderBy: {
createdAt: 'desc', // 排序
},
take: 5, // 限制数量
},
},
});7.4 创建关联数据
typescript
// 创建用户并同时创建个人资料
const userWithProfile = await prisma.user.create({
data: {
email: 'user@example.com',
name: 'John',
profile: {
create: {
bio: 'Developer',
},
},
},
});
// 创建文章并同时关联分类
const postWithCategories = await prisma.post.create({
data: {
title: '文章标题',
content: '文章内容',
author: {
connect: { id: 1 }, // 连接已存在的用户
},
categories: {
connect: [
{ id: 1 }, // 连接已存在的分类
{ id: 2 },
],
},
},
});7.5 更新关联数据
typescript
// 更新用户的个人资料
const updatedUser = await prisma.user.update({
where: { id: 1 },
data: {
profile: {
update: {
bio: 'Updated bio',
},
},
},
});
// 为文章添加分类
const updatedPost = await prisma.post.update({
where: { id: 1 },
data: {
categories: {
connect: { id: 3 }, // 添加新分类
},
},
});
// 为文章移除分类
const updatedPost = await prisma.post.update({
where: { id: 1 },
data: {
categories: {
disconnect: { id: 1 }, // 移除分类
},
},
});八、关系定义最佳实践
8.1 命名规范
code
关系字段命名规范:
│
├── 外键字段
│ ├── 格式:<关联表名>Id
│ ├── 示例:userId, authorId, postId
│ └── 类型:Int(自增 ID)或 String(UUID)
│
├── 关系字段
│ ├── 单数:一对一关系(profile, author)
│ └── 复数:一对多、多对多关系(posts, categories)
│
└── 中间表
├── 格式:_<表1>To<表2>
├── 示例:_PostToCategory
└── 字段:<表1>Id, <表2>Id8.2 性能优化建议
code
关系查询性能优化:
│
├── 1. 使用 select 代替 include
│ ├── select:只查询需要的字段
│ └── include:查询所有字段
│
├── 2. 限制嵌套深度
│ ├── 避免深度嵌套查询
│ └── 使用分步查询
│
├── 3. 分页查询关联数据
│ ├── 使用 take 和 skip
│ └── 避免一次性查询大量数据
│
├── 4. 使用索引
│ ├── 外键字段添加索引
│ └── 频繁查询字段添加索引
│
└── 5. 批量操作
├── 使用 createMany
└── 使用 updateMany8.3 常见陷阱
code
关系定义常见陷阱:
│
├── 1. 忘记外键约束
│ ├── 一对一:必须 @unique
│ └── 一对多:不需要 @unique
│
├── 2. 循环依赖
│ ├── 避免双向关系导致循环
│ └── 使用 @relation(onDelete: Cascade)
│
├── 3. N+1 查询问题
│ ├── 使用 include 批量查询
│ └── 避免循环中单独查询
│
└── 4. 中间表命名
├── Prisma 约定:_ 前缀
└── 显式定义中间表九、完整实战示例
9.1 项目结构
code
prisma/
├── schema.prisma # 数据库 Schema
└── migrations/ # 迁移文件
src/
├── modules/
│ ├── user/ # 用户模块
│ │ ├── user.module.ts
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ └── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ ├── post/ # 文章模块
│ │ ├── post.module.ts
│ │ ├── post.controller.ts
│ │ ├── post.service.ts
│ │ └── dto/
│ │ ├── create-post.dto.ts
│ │ └── update-post.dto.ts
│ └── category/ # 分类模块
│ ├── category.module.ts
│ ├── category.controller.ts
│ ├── category.service.ts
│ └── dto/
│ ├── create-category.dto.ts
│ └── update-category.dto.ts
└── prisma/
└── prisma.service.ts # Prisma 服务9.2 完整 Schema 定义
prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
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[]
@@map("users")
}
// 个人资料表
model Profile {
id Int @id @default(autoincrement())
bio String?
avatar String?
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profiles")
}
// 文章表
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], onDelete: Cascade)
categories Category[] @relation("PostToCategory")
@@map("posts")
}
// 分类表
model Category {
id Int @id @default(autoincrement())
name String @unique
posts Post[] @relation("PostToCategory")
@@map("categories")
}
// 文章-分类关联表(多对多)
model _PostToCategory {
postId Int
categoryId Int
post Post @relation("PostToCategory", fields: [postId], references: [id], onDelete: Cascade)
category Category @relation("PostToCategory", fields: [categoryId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
@@map("_PostToCategory")
}9.3 Service 层实现
typescript
// src/modules/post/post.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
@Injectable()
export class PostService {
constructor(private prisma: PrismaService) {}
// 创建文章并关联分类
async create(title: string, content: string, authorId: number, categoryIds: number[]) {
return this.prisma.post.create({
data: {
title,
content,
author: {
connect: { id: authorId },
},
categories: {
connect: categoryIds.map(id => ({ id })),
},
},
include: {
author: true,
categories: true,
},
});
}
// 查询文章列表(包含作者和分类)
async findAll(page: number = 1, size: number = 10) {
const skip = (page - 1) * size;
const [data, total] = await this.prisma.$transaction([
this.prisma.post.findMany({
skip,
take: size,
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
categories: true,
},
orderBy: {
createdAt: 'desc',
},
}),
this.prisma.post.count(),
]);
return { data, total };
}
// 查询文章详情
async findOne(id: number) {
return this.prisma.post.findUnique({
where: { id },
include: {
author: true,
categories: true,
},
});
}
// 更新文章分类
async updateCategories(postId: number, categoryIds: number[]) {
return this.prisma.post.update({
where: { id: postId },
data: {
categories: {
set: categoryIds.map(id => ({ id })), // 替换所有分类
},
},
include: {
categories: true,
},
});
}
// 删除文章
async remove(id: number) {
return this.prisma.post.delete({
where: { id },
});
}
}9.4 Controller 层实现
typescript
// src/modules/post/post.controller.ts
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { PostService } from './post.service';
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@Post()
async create(
@Body('title') title: string,
@Body('content') content: string,
@Body('authorId') authorId: number,
@Body('categoryIds') categoryIds: number[],
) {
return this.postService.create(title, content, authorId, categoryIds);
}
@Get()
async findAll(
@Query('page') page: number = 1,
@Query('size') size: number = 10,
) {
return this.postService.findAll(page, size);
}
@Get(':id')
async findOne(@Param('id') id: number) {
return this.postService.findOne(id);
}
@Put(':id/categories')
async updateCategories(
@Param('id') id: number,
@Body('categoryIds') categoryIds: number[],
) {
return this.postService.updateCategories(id, categoryIds);
}
@Delete(':id')
async remove(@Param('id') id: number) {
return this.postService.remove(id);
}
}十、关系查询 API 速查表
10.1 查询 API 速查
| 操作 | API | 说明 |
|---|---|---|
| 嵌套查询 | include: { posts: true } | 包含关联数据 |
| 选择字段 | select: { id: true, name: true } | 只查询指定字段 |
| 过滤关联 | include: { posts: { where: {...} } } | 过滤关联数据 |
| 排序关联 | include: { posts: { orderBy: {...} } } | 排序关联数据 |
| 限制数量 | include: { posts: { take: 5 } } | 限制关联数据数量 |
| 深度嵌套 | include: { posts: { include: {...} } } | 深度嵌套查询 |
10.2 关联操作 API 速查
| 操作 | API | 说明 |
|---|---|---|
| 创建关联 | create: { bio: '...' } | 创建新关联 |
| 连接关联 | connect: { id: 1 } | 连接已存在关联 |
| 断开关联 | disconnect: { id: 1 } | 断开关联 |
| 设置关联 | set: [{ id: 1 }, { id: 2 }] | 替换所有关联 |
| 删除关联 | delete: true | 删除关联 |
| 更新关联 | update: { bio: '...' } | 更新关联 |
十一、常见问题与解决方案
11.1 外键约束错误
问题:删除用户时报错,提示有外键约束。
解决方案:
prisma
// 方案一:级联删除
model Profile {
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// 方案二:设置为 NULL
model Profile {
userId Int?
user User @relation(fields: [userId], references: [id], onDelete: SetNull)
}
// 方案三:阻止删除
model Profile {
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
}11.2 N+1 查询问题
问题:查询多个用户时,每个用户都单独查询文章,导致性能问题。
错误示例:
typescript
// 错误:循环中单独查询
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({
where: { authorId: user.id },
});
}正确示例:
typescript
// 正确:使用 include 批量查询
const users = await prisma.user.findMany({
include: {
posts: true,
},
});11.3 多对多关系更新
问题:如何更新文章的分类?
解决方案:
typescript
// 方案一:添加分类
await prisma.post.update({
where: { id: 1 },
data: {
categories: {
connect: { id: 3 }, // 添加分类 ID 为 3
},
},
});
// 方案二:移除分类
await prisma.post.update({
where: { id: 1 },
data: {
categories: {
disconnect: { id: 1 }, // 移除分类 ID 为 1
},
},
});
// 方案三:替换所有分类
await prisma.post.update({
where: { id: 1 },
data: {
categories: {
set: [
{ id: 1 },
{ id: 2 },
{ id: 3 },
], // 替换为这三个分类
},
},
});十二、学习要点总结
12.1 核心概念总结
code
Prisma 关系定义核心要点:
│
├── 一对一关系
│ ├── 从表外键必须 @unique
│ ├── @relation(fields: [外键], references: [主键])
│ └── 主表关联字段类型:Profile?(可选)
│
├── 一对多关系
│ ├── 从表外键不需要 @unique
│ ├── @relation(fields: [外键], references: [主键])
│ └── 主表关联字段类型:Post[](数组)
│
├── 多对多关系
│ ├── 需要中间关联表
│ ├── 中间表存储两个外键
│ └── 联合主键 @@id([postId, categoryId])
│
├── 关系查询
│ ├── include:包含关联数据
│ ├── select:选择指定字段
│ └── 过滤、排序、分页关联数据
│
└── 关系操作
├── create:创建新关联
├── connect:连接已存在关联
├── disconnect:断开关联
└── set:替换所有关联12.2 学习路径规划
code
学习路径规划:
│
├── 第一阶段:理解概念(1 天)
│ ├── 理解三种关系类型
│ ├── 掌握 Schema 定义语法
│ └── 理解关系查询原理
│
├── 第二阶段:实践使用(2-3 天)
│ ├── 创建一对一关系
│ ├── 创建一对多关系
│ ├── 创建多对多关系
│ └── 实现关系查询
│
└── 第三阶段:深入应用(持续)
├── 性能优化
├── 复杂关系查询
└── 事务处理12.3 重要提示
重要提示:这一节是 Prisma 关系定义的核心内容,掌握一对一、一对多、多对多关系的定义和查询,对实际项目开发非常重要!特别是要理解多对多关系需要中间表,以及如何使用
include和select进行关系查询。下一节将结合实际业务,实战演练关系定义!