{T}

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 对比总结

维度PrismaTypeORM
定义语法声明式 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>Id

8.2 性能优化建议

code
关系查询性能优化:
│
├── 1. 使用 select 代替 include
│   ├── select:只查询需要的字段
│   └── include:查询所有字段
│
├── 2. 限制嵌套深度
│   ├── 避免深度嵌套查询
│   └── 使用分步查询
│
├── 3. 分页查询关联数据
│   ├── 使用 take 和 skip
│   └── 避免一次性查询大量数据
│
├── 4. 使用索引
│   ├── 外键字段添加索引
│   └── 频繁查询字段添加索引
│
└── 5. 批量操作
    ├── 使用 createMany
    └── 使用 updateMany

8.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 关系定义的核心内容,掌握一对一、一对多、多对多关系的定义和查询,对实际项目开发非常重要!特别是要理解多对多关系需要中间表,以及如何使用 includeselect 进行关系查询。下一节将结合实际业务,实战演练关系定义!


十三、参考资料