Skip to content
第 14 章 后端 ⏱ 15 分钟阅读

第 14 章:实体与关联 ​

学习目标 ​

  • 掌握 OneToOne / OneToMany / ManyToOne / ManyToMany
  • 理解 lazy vs eager 加载
  • 用 relations 嵌套查询
  • 避开 4 个关联查询坑

一、四种关系 ​

关系例子
OneToOneUser ↔ Profile
OneToMany / ManyToOneUser → Posts(一个用户多篇文章)
ManyToManyPost ↔ Tag(文章 ↔ 标签)

二、OneToOne ​

typescript
// 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;
}

存:

typescript
const user = new User();
user.username = 'tom';
user.profile = new Profile();
user.profile.bio = '...';
await this.repo.save(user);                  // 级联保存

读:

typescript
const user = await this.repo.findOne({
  where: { id: 1 },
  relations: ['profile'],                   // ⚠️ 不写 relations 不会 join
});
user.profile.bio;   // '...'

@JoinColumn() 是什么?

typescript
@OneToOne(() => Profile, profile => profile.user)
@JoinColumn()        // ← 声明:"这个实体的字段持有外键"
profile: Profile;

@JoinColumn() = "外键住在我这里" 的标记,告诉数据库:"外键字段写在我的表里"。

位置规则(很关键):

关系类型写在哪一侧
@OneToOne任选一侧(Owner)
@OneToMany / @ManyToOneManyToOne 那侧(外键跟着多的那边)
@ManyToMany用 @JoinTable()(中间表)

@JoinColumn vs @JoinTable:

装饰器场景实际效果
@JoinColumn()一对多 / 一对一在本表加外键列(authorId 这种)
@JoinTable()多对多新建中间表(post_tag 这种)

记忆口诀:

  • @JoinColumn = "外键住在我这里"(加一列外键)
  • @JoinTable = "我们之间有个中间表"(多对多才用)
  • 位置 = 外键字段在哪一侧的实体,@JoinColumn 就写在哪一侧

三、OneToMany / ManyToOne(最常见) ​

typescript
// 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 "顺着这个字段走回去能找到对方"。

typescript
@OneToMany(() => Post, post => post.author)
//               ^^^^  ^^^^
//               对方 Post 上的 `author` 字段指回我(User)

@ManyToOne(() => User, user => user.posts)
//              ^^^^  ^^^^
//              对方 User 上的 `posts` 字段指回我(Post)

不写第二个参数 = TypeORM 不知道对方哪个字段指回来,双向访问、cascade、join 都失效。

第三个参数 = 关系选项对象:

typescript
{
  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@OneToManyTypeORM(应用)userRepo.save(user) 时
onDelete: 'CASCADE'@ManyToOne数据库DELETE FROM user 时

位置很关键:TypeORM 校验过合法位置,cascade 写在 ManyToOne 或 onDelete 写在 OneToMany 都不生效(被忽略)。

记忆口诀:

  • 第二个参数 = 路标(对方哪个字段指回来)
  • 第三个参数 = 选项(cascade / onDelete 等)
  • cascade → 写在 OneToMany,TypeORM 层
  • onDelete → 写在 ManyToOne,DB 层

四、ManyToMany ​

typescript
// 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(主控方) 上:

typescript
// 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 存在
看心情没有强制规则,保持一致即可

实际项目例子:

typescript
// 用户 ↔ 角色(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 加载 ​

typescript
// 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 具体返回例子:

typescript
// 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: '...' }  ← 直接是对象
// }
typescript
// 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
// }

对比:

维度eagerlazy
字段类型Profile(对象)Promise<Profile>
直接拿值user.profile.bio❌ 必须 await user.profile.bio
触发时机find 时自动 join用到时才查
性能有 N+1 风险按需查(更省)

eager vs relations 不是二选一,可以并存:

组合findOnefind
无 eager + 传 relations✅ join✅ join
eager: true + 不传 relations✅ 自动 join❌ 不 join(坑 3)
eager: true + 传 relations✅ 自动 join(可叠加)✅ join

实战推荐:不用 eager,查询时显式 relations(最可控):

typescript
// 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 ​

typescript
const list = await this.repo
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.posts', 'post')
  .where('user.id = :id', { id: 1 })
  .getOne();

leftJoinAndSelect 强制 join,leftJoin 不取字段。

QueryBuilder 逐行拆解:

typescript
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) 可以看):

sql
SELECT user.*, post.*
FROM user user
LEFT JOIN post post ON post.authorId = user.id
WHERE user.id = ?

返回结构:

typescript
{
  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:

typescript
// 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)

实际项目复杂查询模板:

typescript
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?

场景推荐
简单 WHEREfind / findOne
简单 joinfind + relations
复杂 join / 子查询 / 聚合QueryBuilder
分页 + 排序 + 多 join 全套QueryBuilder

记忆口诀:

  • createQueryBuilder('别名') = 创建构造器
  • leftJoinAndSelect = join + 取数据
  • leftJoin = 只 join,不取(用于过滤)
  • where(':占位符', { 值 }) = 防 SQL 注入
  • 简单用 find,复杂用 QueryBuilder

七、嵌套 relations ​

typescript
this.repo.find({
  relations: ['profile', 'posts', 'posts.tags'],   // 多层
});

八、保存级联 ​

typescript
// 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❌ 被忽略✅ 有效

九、删除关联 ​

typescript
// 解绑但不删
@OneToMany(() => Post, post => post.author, {
  cascade: ['insert', 'update'],     // 不级联删除
})
posts: Post[];

⚠️ 坑 4:cascade 用了 true(全开)→ 删父时把子全删,可能误操作。

十、实战:博客 Post + Tag ​

typescript
// 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[];
}

创建带标签的文章:

typescript
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 + @ManyToOneMany 侧写外键
N-N@ManyToMany + @JoinTable一侧写 JoinTable
加载relations: [...] 或 leftJoinAndSelect不写不 join
级联cascade + onDelete 双保险慎用 cascade 删除

动手练习 ​

  1. 1-N:User(1) ↔ Post(N),Post.author 用 ManyToOne
  2. N-N:Post ↔ Tag,创建带标签的文章
  3. 嵌套查询:find user 时带 relations: ['posts', 'posts.tags']

下一章:第 15 章:查询构造器 →

本站基于 VitePress 构建 · 由 StackHub 团队维护