TypeORM 多对一 / 一对多关联(Many-to-One / One-to-Many)实战指南
2026/9/10 1:41:41 网站建设 项目流程

TypeORM 多对一 / 一对多关联(Many-to-One / One-to-Many)实战指南

【免费下载链接】typeormTypeScript & JavaScript ORM for Node.js — supports PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, Oracle, and more.项目地址: https://gitcode.com/GitHub_Trending/ty/typeorm

关系型数据库的实体建模中,“多对一 / 一对多”是最常见、也最实用的一类关联:一个User拥有多张Photo,而每张Photo只属于一个User。本篇技术指南基于 TypeORM 官方 relations 文档中的多对一 / 一对多章节展开,完整讲解@ManyToOne@OneToMany的配对声明、外键落点、级联保存、查询加载、关系 ID 用法与联合外键(composite key)细节,并对照 关系元数据与装饰器源码,让你既能直接照抄运行示例,也能理解其底层原理。读完你将掌握在多对一/一对多场景下的建表结果、四种保存姿势、find/QueryBuilder/eager 三种加载方式,以及@JoinColumn自定义外键的全部用法。

关联语义与实体声明

多对一 / 一对多关系描述的是:A 包含 B 的多个实例,而 B 只包含 A 的一个实例。以UserPhoto为例:

  • 一个用户(User)可以拥有多张照片(Photo);
  • 一张照片只属于唯一一个用户。

“多”方:使用@ManyToOne

Photo实体上声明多对一关联,它指向User

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from "typeorm" import { User } from "./User" @Entity() export class Photo { @PrimaryGeneratedColumn() id: number @Column() url: string @ManyToOne(() => User, (user) => user.photos) user: User }

“一”方:使用@OneToMany

User实体上声明反向的一对多关联:

import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm" import { Photo } from "./Photo" @Entity() export class User { @PrimaryGeneratedColumn() id: number @Column() name: string @OneToMany(() => Photo, (photo) => photo.user) photos: Photo[] }

两个装饰器的回调函数参数都是() => Entity形式的延迟引用函数(lazy arrow function),用于规避循环 import 导致的死循环。第二个参数互为“反向引用”:@ManyToOne的第二参数写的是反向属性名(user.photos),@OneToMany的第二参数写的是子实体上对应的多对一属性(photo.user)。

从源码看装饰器的职责

从 ManyToOne 装饰器源码 可见,装饰器本体并不做任何数据库操作,它只负责把一段RelationMetadataArgs记录注册进全局的 metadata storage:

getMetadataArgsStorage().relations.push({ target: object.constructor, propertyName: propertyName, relationType: "many-to-one", isLazy: isLazy, type: typeFunctionOrTarget, inverseSideProperty: inverseSideProperty, options: options, } as RelationMetadataArgs)

其中关键的两点:

  1. relationType: "many-to-one"/"one-to-many"——元数据构建器靠这个字段区分关联类型,决定外键该建在哪张表、JOIN 方向如何。参见 OneToMany 装饰器源码。
  2. 装饰器还会自动探测懒加载:如果属性的design:type反射元数据是Promise,会自动把isLazy置为true,无需显式传lazy: true

谁建外键?两张实体的关键差异

理解“外键落在哪一侧”是多对一 / 一对多建模的核心。文档给出的规则非常清晰,务必牢记:

  • 可以省略@JoinColumn:多对一 / 一对多关系不需要显式@JoinColumn也会自动生成外键列(这一点与@OneToOne不同,后者拥有方必须写)。
  • @OneToMany不能脱离@ManyToOne独立存在:因为外键一定建立在“多”方的表里,必须有@ManyToOne来承载它;反向则不需要——你完全可以只定义@ManyToOne,在对应实体上不写@OneToMany。如果只关心“这张照片属于谁”,单独写@ManyToOne即可。
  • 哪里写了@ManyToOne,哪里就产生“relation id”(外键列)

对应本示例,@ManyToOne写在Photo上,所以外键userId落在photo表,而不是user表。

生成的表结构

同步(synchronize: true或执行schema:sync)后生成的两张表如下:

+-------------+--------------+----------------------------+ | photo | +-------------+--------------+----------------------------+ | id | int | PRIMARY KEY AUTO_INCREMENT | | url | varchar(255) | | | userId | int | FOREIGN KEY | +-------------+--------------+----------------------------+ +-------------+--------------+----------------------------+ | user | +-------------+--------------+----------------------------+ | id | int | PRIMARY KEY AUTO_INCREMENT | | name | varchar(255) | | +-------------+--------------+----------------------------+

注意photo表上由 TypeORM 自动生成的userId列:列名 = 关系属性名(user)+ 被引用主键列名(id),即userId。数据库层面它是一条指向user.idFOREIGN KEY约束。

保存关联数据:两种“由谁主导”的写法

方式一:先保存子实体,再从父侧挂接

先分别保存两张Photo,再创建User,把照片数组赋给user.photos,最后保存User

const photo1 = new Photo() photo1.url = "me.jpg" await dataSource.manager.save(photo1) const photo2 = new Photo() photo2.url = "me-and-bears.jpg" await dataSource.manager.save(photo2) const user = new User() user.name = "John" user.photos = [photo1, photo2] await dataSource.manager.save(user)

这段代码背后执行了两步操作:先INSERT两张照片(此时它们还没有外键值),再INSERT用户,随后 TypeORM 用UPDATEuserId写回两张照片。共产生 4 条 SQL(2 次 INSERT + 1 次 INSERT + 1 次 UPDATE)。

方式二:先保存父实体,从子侧挂接

反过来,也可以先保存User,再把同一user实例赋给每张Photo.user后分别保存:

const user = new User() user.name = "Leo" await dataSource.manager.save(user) const photo1 = new Photo() photo1.url = "me.jpg" photo1.user = user await dataSource.manager.save(photo1) const photo2 = new Photo() photo2.url = "me-and-bears.jpg" photo2.user = user await dataSource.manager.save(photo2)

由于先保存了用户、拿到了它的主键,照片的INSERT语句里就可以直接带上外键值,无需多余的 UPDATE,SQL 数量更少。

两种方式业务语义等价。哪种更优取决于应用场景:从子侧主导(方式二)SQL 更干净,且无需关系处于持久化状态即可写入外键;从父侧主导(方式一)在一次性批量建立树形/一对多结构时更直观。

方式三:配合 cascades 一次save搞定

对子侧多对一关系开启级联后,可以只保存一次。例如在User.photos上配置:

@OneToMany(() => Photo, (photo) => photo.user, { cascade: true, }) photos: Photo[]

然后在Photo上的@ManyToOne同时开启级联:

@ManyToOne(() => User, (user) => user.photos, { cascade: true, }) user: User

此时再执行方式一的保存流程,TypeORM 会自动按拓扑顺序先插入User、再插入两张Photo一次save调用即可完成全部持久化,不需要手动逐个save子实体。级联语义详见 relations 总览文档的 Cascades 章节,其中还特别提醒:

级联虽然方便,但“能力越大责任越大”。它可能把不该入库的对象悄悄写进数据库,也可能带来安全与 bug 隐患。精细控制时更推荐使用cascade: ["insert"]cascade: ["update"]这类数组形式,或干脆关闭级联、显式管理。

级联可取值在 RelationOptions 源码 中定义:

cascade?: boolean | ("insert" | "update" | "remove" | "soft-remove" | "recover")[]
  • cascade: true:等价于开启以上全部级联操作;
  • cascade: ["insert", "update"]:只在新对象插入与已存在对象更新时自动同步;
  • 默认false:不级联,需显式保存每一侧实体。

另外注意cascade: ["remove"]只对已加载到内存的关系集合生效:删除父实体前需先通过relations加载子实体,否则子行不会被级联删除。参考 relations 总览文档中的 remove 说明。

方式四:relation id 直接赋值

有些场景你不希望加载整个关联实体,只关心外键值本身。TypeORM 会为@ManyToOne属性生成一个对应的 “relation id” 属性,命名规则为关系属性名 + "Id"(首字母大小写取决于属性声明)。沿用本例,Photo上会存在userId属性。可直接赋值保存:

const photo = new Photo() photo.url = "me.jpg" photo.userId = 42 // 直接写入外键,不需要加载整个 User await dataSource.manager.save(photo)

在加载时若只想拿到外键值而不想 join 关联表,也可以在findselect中直接选取 relation id 列,或在查询结果上访问photo.userId。这一技巧在 Relations FAQ 的 “How to use relation id without joining relation?” 小节有专门阐述,常用于避免大对象加载、提升查询性能。

加载关联数据:find options、QueryBuilder 与 eager

由于普通属性不设置任何选项时,多对一/一对多关系默认不会自动加载,需要显式指定。

方式一:find*+relations

FindOptionsrelations对象键对应关系属性名,置为true表示加载:

// 从"一"方加载其下的"多"方 const userRepository = dataSource.getRepository(User) const users = await userRepository.find({ relations: { photos: true, }, }) // 从"多"方反向加载其所属的"一"方 const photoRepository = dataSource.getRepository(Photo) const photos = await photoRepository.find({ relations: { user: true, }, })

第一条查询会生成LEFT JOIN把每张照片挂到user.photos数组;第二条查询把每个photo.user填充为单个User对象。返回的users[0].photos类型是Photo[]photos[0].user类型是User——两端均可遍历读取,任意一个方向的加载都会把两侧关联对象正确反填。

同样地,findOnefindOneBy等查找方法都支持relations,若要同时按条件过滤子表,可结合where: { photos: { … } }或直接改用下面的 QueryBuilder。

方式二:QueryBuilder+leftJoinAndSelect

QueryBuilder适合动态拼接、分页、组合 where 条件等复杂查询:

// 一次查询,加载用户及其所有照片 const users = await dataSource .getRepository(User) .createQueryBuilder("user") .leftJoinAndSelect("user.photos", "photo") .getMany() // 反向:加载照片时把所属用户一并带出 const photos = await dataSource .getRepository(Photo) .createQueryBuilder("photo") .leftJoinAndSelect("photo.user", "user") .getMany()

关键点:

  • leftJoinAndSelect("user.photos", "photo")第一个参数是目标实体的关系路径(从当前查询主体出发),第二个参数是该关系的别名,后续whereorderByselect里都引用此别名;
  • 若仅想过滤而不取关联数据,可改用innerJoin/leftJoin(不带AndSelect),避免多余字段进入结果;
  • 想要“只加载那些至少有一张照片的用户”,用innerJoinAndSelect替代leftJoinAndSelect即可;
  • 若在@ManyToOne上配置了nullable: false,加载该关系时 TypeORM 会改用INNER JOIN而不是LEFT JOIN——因为数据库已保证关联实体必然存在。见 relations 总览文档的 nullable 说明。

方式三:eager 自动加载(附 QueryBuilder 限制)

若在关系上开启 eager 加载,那么只要使用find*系列方法加载拥有方实体,该关系就永远会被自动加载,无需任何relations指定:

@ManyToOne(() => User, (user) => user.photos, { eager: true, // 每次 find Photo 都会自动带出 user }) user: User

需要特别警惕两条边界:

  1. eager 只能开在关系的一端——不能同时在@ManyToOne与反向的@OneToMany上都设eager: true,否则会因双向自动加载造成循环加载。最佳实践是只把eager开在真正“高概率被访问”的一侧(通常是把@ManyToOne设为 eager,加载单个子实体时即获知所属父实体)。
  2. QueryBuilder会忽略 eager 配置——一旦改用createQueryBuilder(),eager 关系不会被自动加载,必须显式写leftJoinAndSelect。这与上面@ManyToOne装饰器源码中把关系元数据写入 storage 的机制一致:eager 是find*执行阶段读元数据后统一 join 的;而 QueryBuilder 的 SQL 完全由用户手写 join 决定。完整的 eager 示例可参见 relations 总览文档 及仓库中的 basic-eager-relations 测试。

小结对比:find*+relations与 QueryBuilder+leftJoinAndSelect都能精确控制加载;eager 牺牲控制换取省心,但注意它只对find*生效,QueryBuilder 必须手动 join。

更多配置:@ManyToOne各选项速查

@ManyToOne/@OneToMany的第三个参数是一个RelationOptions对象。除cascadeeagernullable外,常用选项还有(完整定义见 RelationOptions 源码):

选项可选值(默认值)说明
onDelete"RESTRICT" \| "CASCADE" \| "SET NULL"(默认RESTRICT被引用的父行删除时外键如何动作。CASCADE会让数据库连带删除该外键行;SET NULL需配合nullable: true
onUpdate"CASCADE" \| "SET NULL" \| "RESTRICT" \| "NO ACTION"父行主键更新时的联动行为
nullableboolean(默认true外键列是否可空。置false后生成非空外键列,同时影响加载 JOIN 策略(变 INNER JOIN)
createForeignKeyConstraintsboolean(默认true是否真正创建数据库级外键约束。仅对多对一和拥有侧一对一有效,设为false时只建列不建约束(适合已有约束或跨库场景)
lazyboolean(默认false懒加载:访问时返回Promise。属性类型声明为Promise时可自动识别
eagerboolean(默认false如上节所述
persistenceboolean(默认true关闭后可避免每次save对关系做额外查询;此时只能从反向或 RelationQueryBuilder 修改关系
orphanedRowAction"nullify" \| "delete" \| "soft-delete" \| "disable"(默认nullify父实体在级联保存时“丢弃”了数据库中仍存在的子行时如何处理:nullify置空外键、delete物理删除、soft-delete逻辑删除、disable保持不动

此外,外键约束可以被设置成可延迟校验(deferrable),该选项主要面向 PostgreSQL、better-sqlite3、SAP HANA 等支持DEFERRABLE约束的驱动。

自定义外键:@JoinColumn与复合外键

@JoinColumn在多对一关系中是可选的,但当你需要自定义外键列名引用非主键列时,就需要显式写上它。

命名自定义

@ManyToOne((type) => Category, (category) => category.posts) @JoinColumn({ name: "cat_id" }) category: Category

数据库列名将由默认的categoryId改为cat_id

引用其他列

@ManyToOne((type) => Category) @JoinColumn({ referencedColumnName: "name" }) category: Category

此时外键引用Category.name而非Category.id,生成的列名为categoryName(属性名 + 被引用列名)。这对“以自然键关联”的业务场景很有用,但注意被引用列必须具有唯一性以保证引用语义正确。

复合外键(composite join columns)

当事先约定用多个列共同标识关联目标时,@JoinColumn接受数组。注意:复合连接列默认不引用主键,你必须为每一列显式给出referencedColumnName

@ManyToOne((type) => Category) @JoinColumn([ { name: "category_id", referencedColumnName: "id" }, { name: "locale_id", referencedColumnName: "locale_id" }, ]) category: Category

对应的实体声明:@ManyToOne(() => Category, (category) => category.posts)Category需要以idlocale_id组成复合主键。仓库中有大量此类测试佐证,例如 multiple-primary-keys-many-to-one 测试,覆盖了多主键目标实体上的多对一关联与反向一对多关联。

兼容性提示:使用复合@JoinColumn/@JoinTable时,TypeORM 会自动按被引用实体的主键顺序对连接列排序,以保证 MySQL、MSSQL、SAP HANA 等要求外键按主键索引顺序引用主键列的数据库能够正常建表。该行为同样作用于多对多关系。

自引用结构:树形建模的延伸应用

多对一/一对多的经典变体是自引用关系(self-referencing),常用于类别树、评论回复树等层级数据。在 Relations FAQ 中有专门示例:让Category同时声明parentCategory@ManyToOne,指向自身)与childCategories@OneToMany,同样指向自身):

@Entity() export class Category { @PrimaryGeneratedColumn() id: number @Column() title: string @ManyToOne((type) => Category, (category) => category.childCategories) parentCategory: Category @OneToMany((type) => Category, (category) => category.parentCategory) childCategories: Category[] }

外键parentCategoryId落在本表,形成“邻接表”(adjacency list)模式。若要查询整棵子树,可再配合TreeRepository@Tree系列装饰器(materialized path / nested set / closure table),那是另一个话题,此处不展开。

常见陷阱与最佳实践清单

结合文档与源码,整理出多对一/一对多建模中最容易踩的坑:

  1. 忘记配对即报错@OneToMany的反向实体如果没有对应的@ManyToOne并正确指回photo.user,TypeORM 构建元数据时会抛出错误。反向引用必须双向成对(除非只写@ManyToOne单侧)。
  2. 在“一”方写@JoinColumn:多对一/一对多的外键永远跟随@ManyToOne的宿主。不要在@OneToMany一侧试图放置连接列,这不会生效——连接列控制权只在“多”方(此点与@OneToOne需要显式指定拥有方不同)。
  3. 懒加载与类型声明:若属性类型写成Promise<User>,TypeORM 反射到的design:typePromise,会自动启用懒加载;访问await photo.user才触发查询。请确保属性类型与实际使用方式一致,避免意外把普通关联变成懒加载(或相反)。
  4. eager 与 N+1:eager 虽然省心,但会在find*时无条件多 JOIN。大批量列表查询建议使用 QueryBuilder 按需 join;反过来在 QueryBuilder 中忘了leftJoinAndSelect则 eager 不会生效,行为不一致极易混淆。
  5. 删除孤儿行:父实体级联保存时移除了原来挂接的子实体,若外键列非空且orphanedRowActionnullify,TypeORM 会改为直接删除该子行,避免空值冲突。设置外键nullable: false前务必先想清楚此语义。

小结

本指南把 TypeORM 的多对一/一对多关系拆解为五步可执行的心智模型:@ManyToOne声明“多”侧(外键所在侧)→ 用@OneToMany声明“一”侧(成对反向引用)→ 按需配cascade/eager/nullable/onDelete→ 保存时选父侧主导或子侧主导 → 加载时在 find relations 与 QueryBuilder join 之间取舍。需要自定义列名、引用非主键或复合外键时,把@JoinColumn显式写出即可。文中全部行为都可在 TypeORM 源码与测试中交叉验证——相关 装饰器实现、关系选项接口 以及大量 relations 功能测试 是继续深入研读的最佳入口。

【免费下载链接】typeormTypeScript & JavaScript ORM for Node.js — supports PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, Oracle, and more.项目地址: https://gitcode.com/GitHub_Trending/ty/typeorm

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询