TypeORM 关系映射如何配置一对一、一对多和多对多?
TypeORM 的关系映射,说白了就是把数据库里的外键、中间表和对象属性对应起来。真正容易出错的地方不在装饰器名字,而在谁拥有外键、什么时候自动加载、级联会不会误删数据。
下面按常用关系类型讲清楚:OneToOne、ManyToOne / OneToMany、ManyToMany,再补上查询、级联、删除策略和性能上的坑。
一对一关系:JoinColumn 放在拥有外键的一侧
OneToOne 适合两个实体一一对应的场景,比如一个用户只有一份个人资料。关键点是:只有拥有外键的一侧需要写 @JoinColumn()。
typescript@Entity() export class Profile { @PrimaryGeneratedColumn() id: number; @Column() gender: string; @Column() bio: string; @OneToOne(() => User, user => user.profile) @JoinColumn({ name: 'user_id' }) user: User; } @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToOne(() => Profile, profile => profile.user, { cascade: ['insert', 'update'] }) profile: Profile; }
这里 Profile 表里会有 user_id 外键,因为 @JoinColumn() 写在 Profile.user 上。另一侧的 User.profile 只是反向引用,不会再生成一根外键。
cascade 不建议一上来就写 true。如果只是希望保存用户时顺便保存资料,写 cascade: ['insert', 'update'] 更安全,避免后面误触发删除类操作。
多对一和一对多:外键永远在 ManyToOne 这一侧
ManyToOne / OneToMany 是项目里最常见的关系,比如一个用户有多篇文章,一篇文章只属于一个作者。
typescript@Entity() export class Post { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column() content: string; @ManyToOne(() => User, user => user.posts, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'author_id' }) author: User; } @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToMany(() => Post, post => post.author) posts: Post[]; }
这里要记住两句话:
@ManyToOne()放在“多”的一侧,也就是Post.author,外键列会出现在post表里。@OneToMany()只是反向集合,它必须依赖另一侧的@ManyToOne(),自己不能单独存在。
反过来说,ManyToOne 可以不写对应的 OneToMany。如果业务里只需要从文章查作者,不需要从用户查文章,可以只保留 Post.author:
typescript@Entity() export class Post { @ManyToOne(() => User) @JoinColumn({ name: 'author_id' }) author: User; }
这在大表里反而更清爽,少暴露一个容易被误加载的集合属性。
多对多关系:JoinTable 只写在拥有中间表的一侧
ManyToMany 需要中间表,比如一篇文章有多个标签,一个标签也能关联多篇文章。
typescript@Entity() export class Tag { @PrimaryGeneratedColumn() id: number; @Column() name: string; @ManyToMany(() => Post, post => post.tags) posts: Post[]; } @Entity() export class Post { @PrimaryGeneratedColumn() id: number; @Column() title: string; @ManyToMany(() => Tag, tag => tag.posts, { cascade: ['insert'] }) @JoinTable({ name: 'post_tags', joinColumn: { name: 'post_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' } }) tags: Tag[]; }
@JoinTable() 只能放一侧,放它的一侧就是关系拥有方。TypeORM 会根据它创建中间表,默认包含两边的外键。
多对多看起来方便,但如果中间表有额外字段,比如 createdAt、sort、role,就不要继续硬用 ManyToMany。这时更适合把中间表建成独立实体,例如 PostTag,再用两个 ManyToOne 表达关系。
JoinColumn 和 JoinTable 分别解决什么问题
@JoinColumn() 用在有外键列的一侧,常见于 OneToOne 拥有方和 ManyToOne 一侧。它可以自定义外键列名和引用列:
typescript@ManyToOne(() => User, user => user.posts) @JoinColumn({ name: 'author_id', referencedColumnName: 'id' }) author: User;
@JoinTable() 用在 ManyToMany 的拥有方,用来配置中间表:
typescript@ManyToMany(() => Tag, tag => tag.posts) @JoinTable({ name: 'post_tags', joinColumn: { name: 'post_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' } }) tags: Tag[];
如果你发现数据库里生成了不符合预期的列名,第一时间就该检查这两个装饰器是不是放错了位置。
cascade 要少开,尤其别随手开删除
cascade 控制的是 ORM 层面的级联保存、更新、删除,不等同于数据库外键的 ON DELETE CASCADE。
typescript@OneToMany(() => Post, post => post.author, { cascade: ['insert', 'update'] }) posts: Post[];
可选值包括:
insert:保存父实体时自动插入新关联实体update:保存父实体时自动更新关联实体remove:删除父实体时自动删除关联实体soft-remove:软删除关联实体recover:恢复软删除实体
项目里最危险的是 cascade: true 和 cascade: ['remove']。比如删除一个用户时,如果关系配置不清楚,可能把文章、评论甚至更多关联数据一起删掉。除非你非常确认生命周期绑定关系,否则级联删除最好交给明确的业务代码处理。
onDelete 和 onUpdate 是数据库外键行为
onDelete、onUpdate 会影响数据库外键约束,通常写在拥有外键的一侧,也就是 ManyToOne 或带 JoinColumn 的一侧。
typescript@ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) @JoinColumn({ name: 'author_id' }) author: User;
常见选择有:
CASCADE:父记录删除或更新时,子记录跟着处理SET NULL:父记录删除后,子表外键置空,外键列必须允许nullableRESTRICT/NO ACTION:阻止删除仍被引用的父记录
业务数据通常不建议默认 CASCADE。例如用户删除后文章是否也删除,这是产品规则,不只是数据库规则。很多系统会选择 SET NULL 或软删除,避免误删历史内容。
Eager 和 Lazy 加载不要混着滥用
关系加载有三种常见方式:显式 relations、QueryBuilder join、以及关系配置里的 eager / lazy。
eager: true 会在使用 Repository 的 find* 方法时自动加载关系:
typescript@OneToMany(() => Post, post => post.author, { eager: true }) posts: Post[];
它省事,但容易让一个简单查询变成大查询。列表页、后台筛选页、导出任务里尤其要谨慎。
Lazy loading 在 TypeORM 里通常通过 Promise 类型表达:
typescript@Entity() export class User { @OneToMany(() => Post, post => post.author) posts: Promise<Post[]>; } const user = await userRepository.findOneBy({ id: 1 }); const posts = await user.posts;
它的坑也不少:属性类型必须是 Promise<T>,访问属性时会触发额外查询;在序列化、事务边界、测试 mock、部分运行环境配置下都可能带来意外行为。新项目如果没有明确理由,更推荐显式使用 relations 或 QueryBuilder,把要查什么写清楚。
使用 relations 查询关联数据
简单场景可以用 find 的 relations 选项:
typescriptconst users = await userRepository.find({ relations: { profile: true, posts: true } });
也可以对关联字段加条件:
typescriptconst users = await userRepository.find({ relations: { posts: true }, where: { posts: { title: Like('%TypeORM%') } } });
这种写法适合中小规模查询。关系层级一深,或者需要选择部分字段、排序、分页、聚合,就应该换 QueryBuilder。
复杂关联查询用 QueryBuilder 更稳
QueryBuilder 能明确控制 join、条件、字段和排序:
typescriptconst users = await dataSource .getRepository(User) .createQueryBuilder('user') .leftJoinAndSelect('user.profile', 'profile') .leftJoinAndSelect('user.posts', 'post') .where('post.title LIKE :keyword', { keyword: '%TypeORM%' }) .orderBy('post.createdAt', 'DESC') .getMany();
如果只需要关联表的部分字段,不要直接 leftJoinAndSelect 全量加载,可以用 leftJoin 配合 addSelect:
typescriptconst users = await dataSource .getRepository(User) .createQueryBuilder('user') .leftJoin('user.posts', 'post', 'post.status = :status', { status: 'published' }) .addSelect(['post.id', 'post.title']) .getMany();
这类写法对性能更友好,也方便查看最终 SQL。
typescriptconst qb = userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post'); console.log(qb.getSql());
外键和中间表要补索引
TypeORM 会帮你表达关系,但不代表所有性能问题都自动解决。高频查询的外键列、中间表两侧外键,通常都应该有索引。
typescript@Entity() export class Post { @Index() @ManyToOne(() => User, user => user.posts) @JoinColumn({ name: 'author_id' }) author: User; }
多对多中间表也要关注索引。如果经常按 post_id 查标签、按 tag_id 查文章,两边都要能走索引。否则数据量一上来,关联查询会很快变慢。
实际配置时怎么选
如果只是“多条记录属于同一个父记录”,优先用 ManyToOne,需要反向集合时再补 OneToMany。如果是一对一扩展信息,把 JoinColumn 放在真正保存外键的一侧。多对多只有在中间表没有额外业务字段时才适合直接用 ManyToMany。
关系配置里最该克制的是两个东西:过度 eager 和过度 cascade。前者容易把查询拖慢,后者容易把数据删错。把拥有方、外键位置和加载方式想清楚,TypeORM 的关系映射才会稳定好维护。