第 14 章:实体与关联
学习目标
- 掌握 OneToOne / OneToMany / ManyToOne / ManyToMany
- 理解 lazy vs eager 加载
- 用 relations 嵌套查询
- 避开 4 个关联查询坑
一、四种关系
| 关系 | 例子 |
|---|---|
| OneToOne | User ↔ Profile |
| OneToMany / ManyToOne | User → Posts(一个用户多篇文章) |
| ManyToMany | Post ↔ Tag(文章 ↔ 标签) |
二、OneToOne
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@Column() username: string;
@OneToOne(() => Profile, profile => profile.user, { cascade: true })
@JoinColumn()
profile: Profile;
}
// profile.entity.ts
@Entity()
export class Profile {
@PrimaryGeneratedColumn() id: number;
@Column() bio: string;
@OneToOne(() => User, user => user.profile)
user: User;
}存:
const user = new User();
user.username = 'tom';
user.profile = new Profile();
user.profile.bio = '...';
await this.repo.save(user); // 级联保存读:
const user = await this.repo.findOne({
where: { id: 1 },
relations: ['profile'], // ⚠️ 不写 relations 不会 join
});
user.profile.bio; // '...'@JoinColumn() 是什么?
@OneToOne(() => Profile, profile => profile.user)
@JoinColumn() // ← 声明:"这个实体的字段持有外键"
profile: Profile;@JoinColumn() = "外键住在我这里" 的标记,告诉数据库:"外键字段写在我的表里"。
位置规则(很关键):
| 关系类型 | 写在哪一侧 |
|---|---|
@OneToOne | 任选一侧(Owner) |
@OneToMany / @ManyToOne | ManyToOne 那侧(外键跟着多的那边) |
@ManyToMany | 用 @JoinTable()(中间表) |
@JoinColumn vs @JoinTable:
| 装饰器 | 场景 | 实际效果 |
|---|---|---|
@JoinColumn() | 一对多 / 一对一 | 在本表加外键列(authorId 这种) |
@JoinTable() | 多对多 | 新建中间表(post_tag 这种) |
记忆口诀:
@JoinColumn= "外键住在我这里"(加一列外键)@JoinTable= "我们之间有个中间表"(多对多才用)- 位置 = 外键字段在哪一侧的实体,
@JoinColumn就写在哪一侧
三、OneToMany / ManyToOne(最常见)
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@Column() username: string;
@OneToMany(() => Post, post => post.author, { cascade: true })
posts: Post[];
}
// post.entity.ts
@Entity()
export class Post {
@PrimaryGeneratedColumn() id: number;
@Column() title: string;
@ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE' })
author: User;
}@JoinColumn 写在拥有外键的那侧(Post.author),User 侧不写。
⚠️ 坑 1:ManyToOne 没指定
onDelete→ 删 User 时 Post 还在,外键悬空。
第二个参数 = "反向路标":告诉 TypeORM "顺着这个字段走回去能找到对方"。
@OneToMany(() => Post, post => post.author)
// ^^^^ ^^^^
// 对方 Post 上的 `author` 字段指回我(User)
@ManyToOne(() => User, user => user.posts)
// ^^^^ ^^^^
// 对方 User 上的 `posts` 字段指回我(Post)不写第二个参数 = TypeORM 不知道对方哪个字段指回来,双向访问、cascade、join 都失效。
第三个参数 = 关系选项对象:
{
cascade: true, // TypeORM 层:save/remove 父表时自动带子表
onDelete: 'CASCADE', // 数据库层:删父表时自动 DELETE 子表
onUpdate: 'CASCADE', // 父表主键更新时
nullable: true, // 外键可空
eager: true, // 查父表自动 join 子表
lazy: true, // 延迟加载(Promise)
orphanedRowAction: 'delete', // 解除父子关系时删子表
}cascade vs onDelete:
| 选项 | 写在哪 | 谁执行 | 触发时机 |
|---|---|---|---|
cascade: true | @OneToMany | TypeORM(应用) | userRepo.save(user) 时 |
onDelete: 'CASCADE' | @ManyToOne | 数据库 | DELETE FROM user 时 |
位置很关键:TypeORM 校验过合法位置,cascade 写在 ManyToOne 或 onDelete 写在 OneToMany 都不生效(被忽略)。
记忆口诀:
- 第二个参数 = 路标(对方哪个字段指回来)
- 第三个参数 = 选项(
cascade/onDelete等) cascade→ 写在 OneToMany,TypeORM 层onDelete→ 写在 ManyToOne,DB 层
四、ManyToMany
// post.entity.ts
@ManyToMany(() => Tag, tag => tag.posts, { cascade: true })
@JoinTable() // ⚠️ 一侧必须写 JoinTable
tags: Tag[];
// tag.entity.ts
@ManyToMany(() => Post, post => post.tags)
posts: Post[];TypeORM 自动建中间表 post_tags_tag。
@JoinTable() 只写一侧,写在 owning side(主控方) 上:
// Post(主控方,写 @JoinTable)
@ManyToMany(() => Tag, tag => tag.posts, { cascade: true })
@JoinTable() // ← 写在这
tags: Tag[];
// Tag(被控方,不写)
@ManyToMany(() => Post, post => post.tags)
posts: Post[]; // 不写 @JoinTable为什么只写一侧?
- 多对多创建中间表(如
post_tags),只需要一个地方声明这张表 - 两边都写会冲突(两张中间表 / 表名重复)
主控方怎么选?
| 场景 | 谁当主控方 |
|---|---|
| 业务上"拥有"对方 | 比如 Post "拥有" Tag |
| 先创建的那个实体 | 比如 Post 通常先于 Tag 存在 |
| 看心情 | 没有强制规则,保持一致即可 |
实际项目例子:
// 用户 ↔ 角色(User 通常主控)
@ManyToMany(() => Role, role => role.users)
@JoinTable() // ← User 上写
roles: Role[];
// 文章 ↔ 标签(Post 通常主控)
@ManyToMany(() => Tag, tag => tag.posts)
@JoinTable() // ← Post 上写
tags: Tag[];记忆口诀:
- 只写一侧,两边写会冲突
- 写在"主控方"(owning side)
- 主控方选择 = 看业务,谁"拥有"谁 / 谁先创建(没标准,保持一致就行)
- 被控方 = 不写,只写
@ManyToMany
五、eager vs lazy 加载
// eager:find 时自动 join
@OneToOne(() => Profile, p => p.user, { eager: true })
profile: Profile;
// lazy:用时再查(返回 Promise)
@ManyToOne(() => User, { lazy: true })
author: Promise<User>;⚠️ 坑 2:全用 eager → N+1 查询、循环引用、性能差。 ⚠️ 坑 3:
find()不写 relations,eager 也无效 → eager 仅在 findOne 时自动 join。
eager vs lazy 具体返回例子:
// eager:字段直接是对象(同步)
@OneToOne(() => Profile, p => p.user, { eager: true })
profile: Profile;
const user = await userRepo.findOne({ where: { id: 1 } });
// 不需要 relations,自动 join
user.profile.bio; // 直接用,不用 await
// 返回结构:
// {
// id: 1,
// username: 'Tom',
// profile: { id: 100, bio: '...', avatar: '...' } ← 直接是对象
// }// lazy:字段是 Promise(异步)
@ManyToOne(() => User, { lazy: true })
author: Promise<User>;
const post = await postRepo.findOne({ where: { id: 1 } });
post.author.name; // ❌ post.author 是 Promise<User>,不是 User
const author = await post.author;
author.name; // ✅ 'Tom'(await 之后才是对象)
// 返回结构:
// {
// id: 100,
// title: '...',
// author: Promise<User> ← Promise,要 await
// }对比:
| 维度 | eager | lazy |
|---|---|---|
| 字段类型 | Profile(对象) | Promise<Profile> |
| 直接拿值 | user.profile.bio | ❌ 必须 await user.profile.bio |
| 触发时机 | find 时自动 join | 用到时才查 |
| 性能 | 有 N+1 风险 | 按需查(更省) |
eager vs relations 不是二选一,可以并存:
| 组合 | findOne | find |
|---|---|---|
无 eager + 传 relations | ✅ join | ✅ join |
eager: true + 不传 relations | ✅ 自动 join | ❌ 不 join(坑 3) |
eager: true + 传 relations | ✅ 自动 join(可叠加) | ✅ join |
实战推荐:不用 eager,查询时显式 relations(最可控):
// entity:不 eager(默认)
@ManyToOne(() => User) author: User;
// 查询时按需 join
const post = await postRepo.findOne({
where: { id: 1 },
relations: ['author', 'author.profile'], // 显式声明
});记忆口诀:
eager: true=findOne自动 join,find不自动relations: [...]= 显式 join,findOne和find都生效- 两者不是二选一,可以并存
- 实战推荐:不用
eager,查询时显式relations(最可控)
六、QueryBuilder 显式 join
const list = await this.repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('user.id = :id', { id: 1 })
.getOne();leftJoinAndSelect 强制 join,leftJoin 不取字段。
QueryBuilder 逐行拆解:
const list = await this.repo
// 1️⃣ 创建查询构造器,起别名 'user'
.createQueryBuilder('user')
// 2️⃣ LEFT JOIN posts + 取数据(类似 SQL 的 LEFT JOIN + SELECT)
.leftJoinAndSelect('user.posts', 'post')
// 3️⃣ WHERE 过滤(:id 是占位符,实际值放对象里,防 SQL 注入)
.where('user.id = :id', { id: 1 })
// 4️⃣ 执行查询,只返回一条(null 如果没有)
.getOne();生成的 SQL(console.log(query.sql) 可以看):
SELECT user.*, post.*
FROM user user
LEFT JOIN post post ON post.authorId = user.id
WHERE user.id = ?返回结构:
{
id: 1,
username: 'Tom',
posts: [ // ← leftJoinAndSelect 自动 join 出来
{ id: 100, title: '...', authorId: 1 },
{ id: 101, title: '...', authorId: 1 },
],
}每个方法的含义:
| 方法 | 作用 | 类似 SQL |
|---|---|---|
createQueryBuilder('别名') | 创建构造器,起别名 | FROM user AS 别名 |
leftJoinAndSelect('关系', '对方别名') | join + 取数据 | LEFT JOIN ... SELECT |
where('字段 = :占位符', { 值 }) | WHERE 过滤 | WHERE 字段 = ? |
getOne() / getMany() | 执行(一条/全部) | LIMIT 1 / 无 LIMIT |
leftJoin vs leftJoinAndSelect:
// leftJoinAndSelect:join + 取数据
.leftJoinAndSelect('user.posts', 'post')
// user.posts 会有数据 ✅
// leftJoin:只 join,不取(只用于 WHERE 过滤)
.leftJoin('user.posts', 'post')
.where('post.published = :p', { p: true }) // 用 join 过滤
// user.posts = undefined ❌(join 了但没 select)实际项目复杂查询模板:
const result = await this.repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.leftJoinAndSelect('post.comments', 'comment') // 嵌套
.where('user.status = :s', { s: 'active' })
.andWhere('post.published = :p', { p: true })
.orderBy('user.createdAt', 'DESC')
.skip(0).take(20) // 分页
.getMany();什么时候用 QueryBuilder vs find?
| 场景 | 推荐 |
|---|---|
| 简单 WHERE | find / findOne |
| 简单 join | find + relations |
| 复杂 join / 子查询 / 聚合 | QueryBuilder |
| 分页 + 排序 + 多 join 全套 | QueryBuilder |
记忆口诀:
createQueryBuilder('别名')= 创建构造器leftJoinAndSelect= join + 取数据leftJoin= 只 join,不取(用于过滤)where(':占位符', { 值 })= 防 SQL 注入- 简单用
find,复杂用 QueryBuilder
七、嵌套 relations
this.repo.find({
relations: ['profile', 'posts', 'posts.tags'], // 多层
});八、保存级联
// User(OneToMany 用 cascade)
@OneToMany(() => Post, post => post.author, {
cascade: true, // insert/update/delete 级联(TypeORM 层)
})
posts: Post[];
// Post(ManyToOne 用 onDelete)
@ManyToOne(() => User, user => user.posts, {
onDelete: 'CASCADE', // 数据库层(更可靠)
})
author: User;cascade 是 ORM 帮你级联,onDelete 是数据库层面。建议两者都加。
位置规则(TypeORM 校验过,放错位置被默默忽略):
| 选项 | @OneToMany | @ManyToOne |
|---|---|---|
cascade: true | ✅ 有效 | ❌ 被忽略 |
onDelete | ❌ 被忽略 | ✅ 有效 |
九、删除关联
// 解绑但不删
@OneToMany(() => Post, post => post.author, {
cascade: ['insert', 'update'], // 不级联删除
})
posts: Post[];⚠️ 坑 4:cascade 用了
true(全开)→ 删父时把子全删,可能误操作。
十、实战:博客 Post + Tag
// post.entity.ts
@Entity()
export class Post {
@PrimaryGeneratedColumn() id: number;
@Column() title: string;
@Column('text') content: string;
@ManyToOne(() => User, u => u.posts, { onDelete: 'CASCADE' })
author: User;
@ManyToMany(() => Tag, t => t.posts, { cascade: true })
@JoinTable()
tags: Tag[];
}创建带标签的文章:
async createWithTags(dto: CreatePostDto, authorId: number) {
const tags = await this.tagRepo.findBy({ id: In(dto.tagIds) });
const post = this.postRepo.create({
title: dto.title,
content: dto.content,
author: { id: authorId } as User,
tags,
});
return this.postRepo.save(post);
}十一、本章小结
| 关系 | 装饰器 | 注意点 |
|---|---|---|
| 1-1 | @OneToOne + @JoinColumn | 一侧写 JoinColumn |
| 1-N / N-1 | @OneToMany + @ManyToOne | Many 侧写外键 |
| N-N | @ManyToMany + @JoinTable | 一侧写 JoinTable |
| 加载 | relations: [...] 或 leftJoinAndSelect | 不写不 join |
| 级联 | cascade + onDelete 双保险 | 慎用 cascade 删除 |
动手练习
- 1-N:User(1) ↔ Post(N),Post.author 用 ManyToOne
- N-N:Post ↔ Tag,创建带标签的文章
- 嵌套查询:find user 时带
relations: ['posts', 'posts.tags']
下一章:第 15 章:查询构造器 →