跳转到主要内容

Nest 使用笔记

第九章:TypeORM 关系映射——三种基数、自关联、owning 与 inverse、N+1 与序列化

打开 learnhub 的 Post 实体,在一份真实代码里讲透三种关系基数(多对一 / 一对多 / 多对多)、Comment 自关联的嵌套回复、owning 与 inverse 怎么选、eager vs lazy 为什么几乎总该选 lazy、relations 对象 vs QueryBuilder 两种加载写法、N+1 怎么发现怎么治,以及关系字段在序列化里怎么处理。每段代码都能在 learnhub 里指到对应文件。

  • Nest
  • TypeORM
  • 数据层

第八章的 PostService 已经跑起来了,但当时我们把 author / comments / tags 当黑盒用——「能用,细节留给第九章」。这一章打开那个黑盒。和「写个 Article 加几条 Comment 跑通一对多」不同,这一章直接打开 learnhub 的 Post 实体——它一份代码里同时长了三种关系基数(多对一、一对多、多对多),外加 Comment 的自关联、两个中间表、几个序列化坑,足够把 TypeORM 关系映射讲透。

先搞懂:TypeORM 关系映射是什么 / 为什么需要 / 企业级怎么用

关系映射是什么:TypeORM 用一组装饰器把表与表之间的关联写进 Entity 本身——@ManyToOne(多对一)、@OneToMany(一对多)、@ManyToMany(多对多)、@OneToOne(一对一)。FK 列、中间表由它生成和管理;保存父对象时能按声明的关系级联保存子对象,查询时一次 JOIN 把关联数据带出来,不用手写 SQL JOIN。

为什么需要它:真实业务的实体几乎都有关系——用户↔帖子↔标签↔评论,单表存不下也查不痛快。手写多表 JOIN 既冗长又容易踩坑:FK 方向写反、级联删漏、中间表忘了建、LEFT JOIN 拼成笛卡尔积。ORM 把关系声明一次,save / 查询都按这个关系走,还能享受类型提示。

企业级怎么用:关系查询的性能是重点。要主动避开 N+1(查 N 条帖子,又各自查 N 次作者)——用 relations: {...} 或 QueryBuilder 的 leftJoinAndSelect 批量 JOIN 一次带出;eager 几乎总是错——它让每次查询都带全部关联,列表接口会被拖垮,learnhub 全用 lazy(显式 relations: 按需带);深层级 + 分页要在 QueryBuilder 里手动控制 JOIN 和 take / skip,别让 ORM 无脑 JOIN;cascade 只在父子一方设,两边都设会循环报错。

这一章你会做出什么

  • 打开 learnhub 的 Post 实体,一份代码里讲透三种关系基数@ManyToOne author → User@OneToMany comments → Comment@ManyToMany tags → Tag(中间表 post_tag),再加第二个 M:N——favoritedBy → User(中间表 post_favorite_user)。
  • owning side vs inverse side 这件事讲清楚:谁持 FK,谁持 @JoinTable,为什么 @JoinTable 只在 owning 方加一遍。
  • Comment.parent 这个自关联怎么用一张表实现无限层嵌套回复。
  • 在真实 PostService 里对比两种加载关系的方式findOne({ relations: {...} }) 嵌套对象 vs QueryBuilder 的 leftJoinAndSelect,各自什么时候用。
  • N+1 这个 ORM 头号杀手讲透:怎么发现(开 logging 看 SQL 条数翻倍),怎么治。
  • 解释 eager vs lazy:为什么 learnhub 一处 eager 都不开。
  • cascade 和 learnhub 为什么宁可手写事务也不用 cascade: true
  • 看关系字段和序列化怎么配合:@Exclude favoritedBy 不泄露收藏者、@Expose get authorName() 派生字段。

前置:第八章已经把 TypeORM 接进 learnhub、PostService 跑通(事务、分页、所有权校验)。这一章只展开它点到的关系字段。

第一步:Post 实体——一份代码长出三种基数

直接打开 learnhub 的 Post,这一段几乎能讲完整章的东西:

// learnhub/src/modules/post/entities/post.entity.ts
@Entity('post')
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  title: string;

  // ... 其他标量字段省略

  // 多对一:多个 Post 属于一个 User(作者)
  @ManyToOne(() => User, (u) => u.posts)
  author: User;

  // 一对多:一个 Post 有多条 Comment(反向侧)
  @OneToMany(() => Comment, (c) => c.post)
  comments: Comment[];

  // 多对多:Post ↔ Tag(本方持中间表)
  @ManyToMany(() => Tag, (t) => t.posts)
  @JoinTable({ name: 'post_tag' })
  tags: Tag[];

  // 多对多:Post ↔ User(收藏关系,User 那边没反向属性也行)
  @ManyToMany(() => User)
  @JoinTable({ name: 'post_favorite_user' })
  @Exclude()
  favoritedBy: User[];

  @Expose()
  get authorName(): string {
    return this.author?.username;
  }
}

每个装饰器逐个拆:

@ManyToOne(() => User, (u) => u.posts):声明「多个 Post 指向同一个 User」。第一个参数 () => User 是返回关联实体类型的函数——用函数而不是直接写 User,是因为类之间互相 import 容易触发循环引用,函数延迟到运行时求值就避开了;第二个参数 (u) => u.posts反向函数,告诉 TypeORM「User 那边的 posts 属性是这条关系的另一端」——两边互指,关系就闭合了。@ManyToOne 所在的这一端永远持 FK(看下面 migration 里 post 表的 authorId 列)。

@OneToMany(() => Comment, (c) => c.post):声明「一个 Post 有多条 Comment」。这是反向侧——它不持 FK,FK 在 Comment 那边的 postId 列上。@OneToMany 单独存在没意义,必须配对另一端的 @ManyToOne。这里的反向函数 (c) => c.post 指 Comment 上的 post 属性。

@ManyToMany(() => Tag, (t) => t.posts) @JoinTable({ name: 'post_tag' }):多对多关系,两边都持集合。@JoinTable 标在 owning 侧,告诉 TypeORM「中间表叫 post_tag,由这一端管理」。另一端(Tag.posts)没有 @JoinTable,是 mirror。

@ManyToMany(() => User) @JoinTable({ name: 'post_favorite_user' }) @Exclude() favoritedBy:第二个 M:N——收藏关系。注意它的反向函数被省略了:User 那边没有 favoritedPosts 属性,因为业务上「这个用户收藏了哪些帖」目前不需要从 User 出发反查。M:N 的反向端可以不写,TypeORM 不要求对称——少一边的便利功能换来少一份维护负担。@Exclude() 是序列化时把这个字段剥掉,第七步细讲。

注意@ManyToOne / @OneToMany / @ManyToMany 的第二个参数(反向函数)「可以」省略,但强烈建议永远写。少了它,TypeORM 就不知道另一端是哪个属性,你就失去了从另一端反查的能力,而且某些 relations: {...} 嵌套查询会失效。learnhub 的反向函数都写满,只有 favoritedBy 是刻意单边。

User 那边的反向属性长这样,对照看一眼就理解什么叫「互指」:

// learnhub/src/modules/user/entities/user.entity.ts
@OneToMany(() => Post, (p) => p.author)
posts: Post[];

@OneToMany(() => Comment, (c) => c.author)
comments: Comment[];

u.posts 的反向函数指 p.author,正好对应 Post 那边 @ManyToOne(..., (u) => u.posts)。两边互相指对方的属性名,关系就闭合了。

第二步:自关联——Comment.parent 实现嵌套回复

评论下面能回复评论,是常见需求。learnhub 的做法不是开一张 reply 表,而是在 Comment 里加一个指向自己的 @ManyToOne

// learnhub/src/modules/comment/entities/comment.entity.ts
@Entity('comment')
export class Comment {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'text' })
  content: string;

  @ManyToOne(() => Post, (p) => p.comments)
  post: Post;

  @ManyToOne(() => User, (u) => u.comments)
  author: User;

  // 自关联:父评论(nullable,顶层评论没有父)
  @ManyToOne(() => Comment, (c) => c.children, { nullable: true })
  parent: Comment | null;

  // 自关联的另一端:子评论
  @OneToMany(() => Comment, (c) => c.parent)
  children: Comment[];

  @CreateDateColumn({ name: 'create_time' })
  createTime: Date;
}

parent 持 FK(指向 comment.id),是 owning 侧;children 是 mirror。一条评论要么是顶层(parent === null),要么是某条评论的回复(parent 指向父)。一张表就能存任意深度的回复树。

思考:为什么 comment.parent 自关联比开一张 reply 表好?——一张表意味着「评论」和「回复」是同一类对象:同一套字段(content / author / createTime)、同一套权限校验、同一个查询接口,回复的回复也能直接再挂到 parent 上,没有「第 N 层之后要换表」的边界问题。开两张表的话,「评论的回复」和「回复的回复」要不要分?分到几层为止?这是自找麻烦。自关联配合递归查询(或 TypeORM 的 @Tree 装饰器,learnhub 注释里提到了但没启用)是把树结构存进关系库的标准模式。

migration 里这条 FK 把自关联落到了库层:

-- learnhub/src/migrations/1784362383603-Init.ts
ALTER TABLE `comment`
  ADD CONSTRAINT `FK_e3aebe2bd1c53467a07109be596`
  FOREIGN KEY (`parentId`) REFERENCES `comment`(`id`)
  ON DELETE NO ACTION ON UPDATE NO ACTION;

comment 表的 parentIdnullable(顶层评论允许为 null),FK 引用的是 comment(id) 自己——这种「FK 指向本表」的结构在 DB 层就是自关联的标志。

第三步:owning side vs inverse side——谁持 FK,谁持 @JoinTable

新手最容易把 owning / inverse 搞混,结果保存时不落库、或中间表写不进去。规则其实就一句话:owning 方是真正持存储的那一端,inverse 方是纯 mirror

  • owning side(拥有方)@ManyToOne 永远是 owning(FK 在这一端的表上);@ManyToMany 的 owning 是标了 @JoinTable 的那一端。
  • inverse side(反向方)@OneToMany 永远是 inverse;@ManyToMany 没有 @JoinTable 的那一端是 inverse。

learnhub 的关系按这个规则归类:

关系owning side(持 FK / 中间表)inverse side(mirror)
Post → User(author)Post.author(FK 在 post.authorIdUser.posts
Post → Comment(comments)Comment.post(FK 在 comment.postIdPost.comments
Post ↔ Tag(tags)Post.tags(中间表 post_tagTag.posts
Post ↔ User(favoritedBy)Post.favoritedBy(中间表 post_favorite_user
Comment → Comment(parent)Comment.parent(FK 在 comment.parentIdComment.children

为什么 @JoinTable 只能标一遍:M:N 的存储是一张独立的中间表,结构是 (postId, tagId)。如果两边都标 @JoinTable,TypeORM 会试图建两张中间表、或读写两个不同的表,造成数据分裂。规则就是 owning 方标一次、inverse 方完全不标——Tag.posts 那边一个 @JoinTable 都没有。

下面两条是 Init 迁移里真实生成的中间表 DDL,看一眼就有感觉:

-- learnhub/src/migrations/1784362383603-Init.ts
CREATE TABLE `post_tag` (
  `postId` int NOT NULL,
  `tagId` int NOT NULL,
  INDEX `IDX_444c1b4f6cd7b632277f557935` (`postId`),
  INDEX `IDX_346168a19727fca1b1835790a1` (`tagId`),
  PRIMARY KEY (`postId`, `tagId`)
) ENGINE=InnoDB;

CREATE TABLE `post_favorite_user` (
  `postId` int NOT NULL,
  `userId` int NOT NULL,
  INDEX `IDX_96c56ef0bf6ed6882cde31aa7b` (`postId`),
  INDEX `IDX_fd8a313b4f47f08bf7c43f947c` (`userId`),
  PRIMARY KEY (`postId`, `userId`)
) ENGINE=InnoDB;

两个关键点:

(1) 复合主键——PRIMARY KEY (postId, tagId),意味着同一对 (post, tag) 在中间表里只能出现一次(同一标签不能给同一帖子打两次),这是 M:N 关系的天然约束。

(2) 两个单列索引——除了复合 PK 自动的索引,TypeORM 还给两列各加了一个普通 index,方便从 tagId 反查「这个标签下都有哪些帖」。复合 PK 的索引是按 (postId, tagId) 顺序建的,最左前缀只能加速 postId 的查询;要从 tagId 反查就得靠这个额外的单列索引,否则全表扫。

注意:FK 在 migration 里自动生成的命名是 FK_<hash>,是 TypeORM migration:generate 算出来的哈希,不是人写的。改关系时别手动改这些 FK 名——下次 generate 时它会按新 hash 重建。owning 方要选哪一端是有意的设计:FK / @JoinTable 落在「业务上更自然地持有这条关系」的一方,learnhub 选 Post.tags 而不是 Tag.posts 持中间表,是因为「帖子打标签」这个动作是从 Post 发起的、PostService 也最常操作它。

第四步:加载关系——两种写法,两种场景

定义好关系只是声明,查询时不会自动带出来。learnhub 的 PostService 用两种写法加载关联数据,对应两个不同场景。

findOne:relations: {...} 嵌套对象(详情接口)

详情页要展示一篇帖子的作者、标签、评论(连带评论的作者),用 findOne 的对象语法:

// learnhub/src/modules/post/post.service.ts
async findOne(id: number): Promise<Post> {
  const post = await this.postRepo.findOne({
    where: { id },
    relations: { author: true, tags: true, comments: { author: true } },
  });
  if (!post) throw new NotFoundException('帖子不存在');
  // 浏览量 +1 走 Redis(增量),由 RankingService 定时 flush 进库
  this.ranking.recordView(id).catch(() => undefined);
  return post;
}

relations: { author: true, tags: true, comments: { author: true } } 这种写法一次声明所有要带的关系,TypeORM 底层转成 JOIN。注意嵌套:comments: { author: true } 表示「评论要带,而且评论的作者也要带」——对象语法天然支持多层嵌套,写起来像在描述返回数据的形状。生成的 SQL 大致是 post LEFT JOIN user AS author LEFT JOIN tag LEFT JOIN comment LEFT JOIN user AS comment_author,一次查询把整棵树带回来。

findMany:QueryBuilder leftJoinAndSelect(列表接口)

列表页要分页、过滤、按置顶排序,对象语法不够灵活,改用 QueryBuilder:

// learnhub/src/modules/post/post.service.ts
async findMany(q: QueryPostDto): Promise<PageResult<Post>> {
  const qb = this.postRepo
    .createQueryBuilder('p')
    .leftJoinAndSelect('p.author', 'author')
    .leftJoinAndSelect('p.tags', 'tag')
    .orderBy('p.pinned', 'DESC')          // 置顶优先
    .addOrderBy('p.createTime', 'DESC')
    .skip(q.skip)
    .take(q.take);

  if (q.keyword) {
    qb.andWhere('(p.title LIKE :kw OR p.content LIKE :kw)', { kw: `%${q.keyword}%` });
  }
  if (q.authorId) qb.andWhere('author.id = :aid', { aid: q.authorId });
  if (q.tagId) qb.andWhere('tag.id = :tid', { tid: q.tagId });

  const [list, total] = await qb.getManyAndCount();
  return PageResult.of(list, total, q.page, q.pageSize);
}

leftJoinAndSelect('p.author', 'author') 的两个参数:第一个 'p.author' 是「Post 实体上的 author 关系」,第二个 'author' 是给它起的别名,后面 WHERE / ORDER BY 用这个别名引用它(author.id = :aid)。leftJoinAndSelectleftJoin 的区别要记牢:前者把关联数据 SELECT 出来塞进实体,后者只 JOIN 不带出(用于过滤但不返回)。

什么时候用哪个

  • 简单按 id 查、关系嵌套层数少findOne({ relations: {...} })。写起来直观,不用考虑别名。
  • 要分页、动态过滤、复杂排序、跨表 WHERE → QueryBuilder。对象语法做不到 LIKE :kw 这种自定义谓词,也没有 skip / take + getManyAndCount 这种组合。

注意:第八章的 findMany 没有带出 comments——列表页不需要每条帖子都展开评论,否则数据量爆炸。带哪些关系是按接口需要定的,不是能带就带。详情接口带评论、列表接口不带,这是同一份关系在不同场景下的取舍。

N+1:怎么发现,怎么治

ORM 头号杀手。症状:你想拿 N 条帖子的作者,写出来是这样——

// 反面教材:N+1(别学这个)
const posts = await this.postRepo.find();                // 1 条 SQL,拿 N 条帖子
for (const p of posts) {
  const author = await this.userRepo.findOne({           // N 条 SQL,每个作者各查一次
    where: { id: p.authorId },
  });
}

1 + N 条 SQL——N 一大,数据库就被同一模式的查询打死。怎么发现?开 logging: true(第八章 forRootAsynclogging: config.get('nodeEnv') === 'development'),看终端的 SQL 条数。本来该一次 JOIN 的查询,如果 SQL 翻倍出现、长得像「同一个 user 表查了十遍」,就是 N+1。

治法就是显式声明要带的关系,让 ORM 用 JOIN 一次带出——正是 learnhub 的两种写法在做的事。find({ relations: { author: true } })leftJoinAndSelect('p.author', 'author') 都生成 LEFT JOIN,把 N+1 条 SQL 压成 1 条。第八章 findManyleftJoinAndSelect 把 author 和 tags 一起带出来,就是为了避开列表接口的 N+1;这里 findOne 用对象语法的 relations: { comments: { author: true } } 嵌套 JOIN,避开了「评论带出来了、但每条评论的作者又各查一次」的二层 N+1。

第五步:eager vs lazy——为什么 learnhub 一处 eager 都不开

每个关系装饰器都可以传 { eager: true }

// 反面教材:千万别这么干
@ManyToOne(() => User, (u) => u.posts, { eager: true })
author: User;

eager: true 的意思是「每次查 Post,不管你要不要,都把 author 一并 JOIN 带出来」。听起来方便,实际是个炸弹。

eager 的代价:每个查询都被这个关系拖累,包括那些根本不需要 author 的接口。learnhub 的 findMany 已经显式 leftJoinAndSelect('p.author', 'author'),如果 author 又开了 eager,TypeORM 不会智能去重,可能 JOIN 两遍;更糟的是 admin 后台只想查帖子标题列表、不需要作者信息,eager 还是强加 JOIN,等于所有查询都付了这个成本。一旦实体上挂了三五个 eager 关系,每次查询都是几张表的笛卡尔积,性能直接塌。

lazy(默认)的好处:关系字段不查不出——你必须显式 relations: { author: true }leftJoinAndSelect 才带出来。每个接口只为自己需要的关系付成本:列表带 author、详情带 comments、改帖不需要带任何关系就一个都不带。learnhub 全用 lazy,所以你能看到 findOne 里手写 relations: { author: true, tags: true, comments: { author: true } }——这是「我确认要这些关联」的明示。

注意:eager loading 几乎总是错。除非那个关系真的「不带上就没意义」(比如某个配置实体的明细),否则一律 lazy、查询时按需声明。哪怕只省下一次 JOIN,长期下来也是数据库的喘息空间。

第六步:cascade vs 显式事务——learnhub 为什么宁可手写

@ManyToOne / @OneToMany / @ManyToMany 都能配 cascade: true,效果是「保存父对象时自动级联保存子对象,删父对象时自动级联删子对象」。听起来美好:

// 反面教材:cascade 全开(别学这个)
@OneToMany(() => Comment, (c) => c.post, { cascade: true })
comments: Comment[];

// 然后
const post = await this.postRepo.findOne({ where: { id: 1 } });
post.comments.push(this.commentRepo.create({ content: '新评论' }));
await this.postRepo.save(post); // 自动 INSERT 新评论

cascade 的风险:删父对象时也会级联删子,而且级联会沿关系图扩散——Post cascade 删 Comment、Comment cascade 删自己的孩子……你以为是删一条帖子,结果连带删掉了一片关联数据。更隐蔽的是 save 也会级联:从库取出来的对象带了一堆关联,调一次 save 把所有关联都写一遍,可能把不该改的字段也覆盖回去。learnhub 不用 cascade,第八章的 create 是手写事务:

// learnhub/src/modules/post/post.service.ts
async create(dto: CreatePostDto, userId: number): Promise<Post> {
  const post = await this.dataSource.transaction(async (manager) => {
    const post = manager.create(Post, {
      title: dto.title,
      content: dto.content,
      author: { id: userId } as any,
    });
    if (dto.tagIds?.length) {
      post.tags = await manager.findBy(Tag, { id: In(dto.tagIds) });
    }
    return manager.save(post); // post + 关系 + 中间表,全在一个事务里
  });
  // 事务提交成功后,再发领域事件、同步 ES
  this.amqp.publish('post.published', { postId: post.id, title: post.title, authorId: userId });
  this.indexPost(post);
  return post;
}

这里发生的事:manager.save(post) 在事务里只保存 Post 本身和它已经赋值的关系——赋了 post.tags(一批已存在的 Tag)就同步 post_tag 中间表。这是 TypeORM 对「save 时一并持久化赋值过的关联」的默认行为,不需要 cascade: true。区别在于 cascade 还会扩散到更深层(如 post.comments[*].author 这种你根本不想动的关联),learnhub 不要这个扩散,所以不开。

注意:cascade 不是绝对坏,但它把「写哪些表」的决定权从代码里拿走、交给实体声明,调试时很难追踪。除非你明确要「父子同生共死」的强一致(比如订单和订单明细),否则宁可像 learnhub 这样在 service 里手写事务,每次 save 写了什么、什么会落库,全都看得见。

第七步:序列化与关系——@Exclude 和 @Expose 怎么和关联字段配合

关系字段会出现在 API 返回里——但有些关系不该外泄,有些数据需要从关系派生。learnhub 的 Post 实体上有两个典型用法:

// learnhub/src/modules/post/entities/post.entity.ts
@ManyToMany(() => User)
@JoinTable({ name: 'post_favorite_user' })
@Exclude() // 序列化时剥掉,不返回
favoritedBy: User[];

@Expose()
get authorName(): string {
  return this.author?.username;
}

@Exclude() 标在 favoritedBy:帖子详情返回时,不能把「谁收藏了这条帖子」泄露给所有访问者(这是用户行为隐私)。@Exclude() 告诉 ClassSerializerInterceptor(第二章、第五章讲过的全局拦截器)在返回前把这个字段剥掉。注意它剥的是这个字段,不是关系本身——库里照常存 post_favorite_user,业务里照常能读 post.favoritedBy,只是 API 不返回。

@Expose() get authorName():派生字段。详情接口只需要作者名,不需要把整个 author 对象(含邮箱、头像、是否冻结……)返回出去。getter 从 this.author.username 派生出一个虚拟字段,@Expose() 让序列化器把它当普通字段输出。配合 author 本身——learnhub 这里没显式 @Exclude author,但你可以选:要么返回完整 author、要么 @Exclude author 只暴露 authorName,按业务定。

关系字段没被 relations 带出时会怎样? findOne 没带的关联字段值是 undefined——比如 post.favoritedBy 在详情接口里压根没查出来,序列化器看到 undefined 自然没东西可剥。那为什么还要写 @Exclude?是「双保险」:就算某天某个接口把它带出来了,序列化层会兜住,不会泄露。这就是为什么 learnhub 在实体上写 @Exclude 而不是在 controller 里临时处理——安全规则写在源头(实体),不写在出口(controller),避免新接口忘记脱敏。

这一章的成果

  1. 在 learnhub 的 Post 实体上认全三种基数@ManyToOne author(持 FK)、@OneToMany comments(mirror)、@ManyToMany tags / favoritedBy(持中间表 post_tag / post_favorite_user)。
  2. 理解 owning vs inverse@ManyToOne 永远 owning,@OneToMany 永远 inverse;M:N 的 owning 是标了 @JoinTable 的那一端,inverse 不标。
  3. 看到 Comment.parent 怎么用自关联 + nullable FK 在一张表里实现无限层嵌套回复。
  4. PostService 里对比两种加载写法——relations: {...} 嵌套对象适合简单查询,QueryBuilder leftJoinAndSelect 适合分页过滤。
  5. 知道 N+1 怎么发现(开 logging 看 SQL 条数翻倍)、怎么治(显式 relations 或 leftJoinAndSelect 一次 JOIN 带出)。
  6. 明白 eager 几乎总错cascade 风险大于收益,learnhub 全 lazy + 手写事务是有意的设计。
  7. 会用 @Exclude / @Expose 控制关系字段在 API 返回里的去留,把脱敏写在实体源头。

常见问题

  • 保存时报循环 / FK 错:检查是不是 @OneToMany@ManyToOne 两边都加了 cascade: true——只在 owning 方(@ManyToOne 那端)加,或干脆不开、像 learnhub 那样手写事务。
  • 查询没带出关系数据find 默认不查关系。要么 relations: { ... },要么 QueryBuilder leftJoinAndSelectleftJoin(不带 Select)只 JOIN 不带出,是常见踩坑点。
  • 中间表名不对@JoinTable({ name: 'post_tag' }) 显式指定。不指定的话 TypeORM 会按「拥有方实体名_目标实体名」自动生成,迁移和代码不一致就出问题——learnhub 都显式写名。
  • M:N 双向都标了 @JoinTable:会建两张中间表或写入冲突。@JoinTable 只标 owning 方一遍。
  • 自关联查不出来:默认 relations: { children: true } 只带一层。要整棵树用 TypeORM 的 @Tree 装饰器(TreeRepository.findDescendants),或 QueryBuilder 递归 CTE(MySQL 8+ 支持)。
  • 列表接口慢、SQL 翻倍:开 logging 看条数,N+1 几乎都是这个原因。逐个关系检查是不是漏了 leftJoinAndSelect
  • 序列化后字段没剥 / 没暴露:确认 controller 或全局上挂了 ClassSerializerInterceptor(learnhub 在 controller 上 @UseInterceptors(ClassSerializerInterceptor)),否则 @Exclude / @Expose 不生效。

下一章换一个更现代的 ORM——Prisma。learnhub 用 /api/v2/posts 跑一份和 TypeORM 对照的实现,看 schema 文件定义模型、自动生成类型安全的查询 API,和 TypeORM 的装饰器风格对比各自的取舍。