TypeORM 关系查询构建器(RelationQueryBuilder)实战指南:高效读写实体关系
【免费下载链接】typeormTypeScript & JavaScript ORM for Node.js — supports PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, Oracle, and more.项目地址: https://gitcode.com/GitHub_Trending/ty/typeorm
RelationQueryBuilder 是 TypeORM 中用于专门操作实体关系(relations)的查询构建器:无需加载整个实体对象即可在数据库层面完成关系的绑定、解绑与赋值,也能按需加载关联实体。本文围绕官方指南 5-relational-query-builder.md 展开,并深入到 RelationQueryBuilder 源码、RelationUpdater、RelationRemover 与测试用例,讲透 add / remove / set / loadMany / loadOne 等核心操作及其底层 SQL 行为,帮助你写出最小开销、可上生产的关联操作代码。
为什么需要专门的关系查询构建器
常规修改一对多或多对多关系的做法是:先查出目标实体并带上关联数据,然后在内存数组里 push 或 splice,最后调用save整体写回。以「给 id 为 1 的 Post 追加一个 Category」为例,等价写法是:
const postRepository = dataSource.manager.getRepository(Post) const post = await postRepository.findOne({ where: { id: 1, }, relations: { categories: true, }, }) post.categories.push(category) await postRepository.save(post)这段代码存在两个明显问题:
- 操作数多、开销大:一次 find(含关联加载)加上一次完整
save,会触发大量额外查询与变更检测; - 数据规模不可控:若某个 post 下已有上万条 category,为了追加一条你必须先把这一万条全部 load 进内存再保存,几乎无法在生产环境使用。
RelationQueryBuilder正是为解决这类场景而存在:它直接在数据库中执行最小的关系变更语句,并且完全不需要先加载任何一方实体的完整数据。同样的需求用关系查询构建器只需一行:
await dataSource .createQueryBuilder() .relation(Post, "categories") .of(post) .add(category)代码要表达的语义是:针对Post实体的categories这个多对多关系,找到实体post,把category追加(bind)进去。相比庞大的save调用,它只执行极少的必要操作就在数据库层面完成了实体间的绑定(RelationQueryBuilder.ts)。
关系查询构建器在整个查询构建器体系中的位置
在 TypeORM 中,RelationQueryBuilder继承自统一的抽象基类QueryBuilder,同族还有我们熟悉的 Select / Insert / Update / Delete 各查询构建器。它的入口是基类上的relation()方法:既支持传入实体目标 + 属性路径(relation(Post, "categories")),也支持在已经关联了某个实体别名的构建器上直接传属性路径(relation("categories"))。从源码看,QueryBuilder.relation() 内部会将expressionMap.queryType置为"relation"、记录relationPropertyPath,并经由构建器注册表返回一个RelationQueryBuilder实例。
构建器在链式调用后真正干活的是三类底层执行器(RelationQueryBuilder.ts 分别委托给它们):
RelationUpdater(RelationUpdater.ts)——支撑set与add两个操作;RelationRemover(RelationRemover.ts)——支撑remove操作;RelationLoader(RelationLoader.ts)——支撑loadOne/loadMany的关联加载。
为关系追加实体:add
add用于在many-to-many和one-to-many关系上新增绑定。第一个参数of()指定「谁的关系要被修改」,第二个参数传给add的是「要被绑进来的值」。两者都可以是完整实体、实体 id,或实体 id map(复合主键场景),甚至可以是数组。
await dataSource .createQueryBuilder() .relation(Post, "categories") .of(post) // 也可以直接写 .of(post.id) 甚至 .of(1) .add(category) // 也可以直接写 .add(category.id) 甚至 .add(3).of(1).add(3)这样用「纯 id 绑定」是完全合法的,因为绑定过程只关心主键值,不需要对象本身:
await dataSource.createQueryBuilder().relation(Post, "categories").of(1).add(3)底层行为因关系类型而异(RelationUpdater.update()):
- many-to-many(owner 侧):
RelationUpdater读取关系对应的 junction 表元数据(relation.junctionEntityMetadata),把of方与 value 方的主键值组装成连接表记录,随后执行一条批量INSERT ... INTO <junction_table>;除 Oracle / SAP 因驱动限制改为逐条插入外,其余数据库均为单次批量插入。 - one-to-many(inverse 侧):本质是把「子表外键列」指向父实体主键,因此执行的是
UPDATE <inverse_entity_table> SET <join_column> = :parentId WHERE id IN (...)。
值得注意的是:源码对add和set都做了关系类型守卫——若对many-to-one或one-to-one调用add,会抛出 TypeORMError 并提示改用它对应的.set()(RelationQueryBuilder.add())。
从关系中移除实体:remove
移除与添加的调用方式完全对称。它会解绑指定关系,但不会删除实体本身:
// 从给定 post 上移除 category await dataSource .createQueryBuilder() .relation(Post, "categories") .of(post) // 传 post id 同样可行 .remove(category) // 传 category id 同样可行remove同样只适用于many-to-many与one-to-many;对many-to-one/one-to-one调用会抛错并提示应使用.set(null)(RelationQueryBuilder.remove())。底层行为(RelationRemover.remove()):
- many-to-many:从 junction 表执行
DELETE,WHERE 条件按 owner 列 + inverse 列的笛卡尔组合精确拼出(即只删掉「这一对」的绑定,不影响其它行); - one-to-many:对被移除的子实体执行
UPDATE,将指向父实体的外键列置为NULL,而不是删除子实体行。
替换关系的单一目标:set 与 set(null)
many-to-many、one-to-many面向「集合」用 add / remove;而one-to-one与many-to-one的关联是「单个对象」,应当用set来赋值:
// 设置某个 post 的 category await dataSource .createQueryBuilder() .relation(Post, "categories") .of(post) // 传 post id 同样可行 .set(category) // 传 category id 同样可行想解除关系(置空)只需把null交给set:
// 解除某个 post 与 category 的关联 await dataSource .createQueryBuilder() .relation(Post, "categories") .of(post) // 传 post id 同样可行 .set(null)在源码层面对应三种不同形态(RelationUpdater.update()):
- many-to-one / one-to-one(owner 侧):直接把外键列值 UPDATE 到父表记录上——
UPDATE <entity> SET <join_column> = :value WHERE id IN (:of); - one-to-one(非 owner)/ one-to-many 且 value 为 null:走「清空子表指向」的逻辑,把 inverse 侧 join column 批量置 NULL;
- one-to-one(非 owner)/ one-to-many 且 value 非 null:更新子表,将 inverse join column 指向
.of()指定的父实体。
源码还内置了复合 join column 的校验:当关系含多个 join column 时,若传入 value 不是对象或键数量不足,会抛出错误并提示应使用.set({ firstName: "...", lastName: "..." })这类 id map(RelationQueryBuilder.set())。此外set/add前都会检查.of()是否已调用,未调用会直接报错:「Entity whose relation needs to be set is not set」。
复合主键与多列关联:以 id map 传参
当实体使用复合主键时,不能只传单一 id,必须把主键值组织成键值映射传给.of()与add/remove/set。官方文档给出的完整示例:
await dataSource .createQueryBuilder() .relation(Post, "categories") .of({ firstPostId: 1, secondPostId: 3 }) .add({ firstCategoryId: 2, secondCategoryId: 4 })同理,loadMany/loadOne在复合主键下也有约束:若.of()只传单个裸值而目标实体含多个主键列,RelationQueryBuilder 会抛出「Cannot load entity because only one primary key was specified…」错误(RelationQueryBuilder.loadMany())。反过来,如果实体只有一个主键列,传入裸 id 也会在加载前自动通过primaryColumns[0].createValueMap(of)包装成合法的 value map。
按需加载关联数据:loadMany 与 loadOne
关系查询构建器不仅能「写」关系,还能在不需要全量 find 的情况下「读」出某条记录下的关联实体。官方文档的场景:Post有many-to-many的categories和many-to-one的user,先查主体,再分别加载两类关联:
const post = await dataSource.manager.findOneBy(Post, { id: 1, }) post.categories = await dataSource .createQueryBuilder() .relation(Post, "categories") .of(post) // 传 post id 同样可行 .loadMany() post.author = await dataSource .createQueryBuilder() .relation(Post, "user") .of(post) // 传 post id 同样可行 .loadOne()语义约定非常直观:
loadMany()针对集合型关系(many-to-many、one-to-many、一对一的 inverse 侧),返回关联实体数组(RelationQueryBuilder.loadMany());loadOne()针对单个对象型关系(many-to-one、one-to-one 的 owner 侧),其实现本质是取loadMany()结果的第一项(RelationQueryBuilder.loadOne())。
加载时,构建器委托给dataSource.relationLoader(即 RelationLoader)。RelationLoader 会按关系类型分派:many-to-one/one-to-one owner 走「根据父记录 JOIN 查出目标」的路径,one-to-many/one-to-one 非 owner 走反向查询路径,而 many-to-many(含 owner 与非 owner 两侧)则经由 junction 表拼接出目标实体数据。
一次性做多个操作:addAndRemove
实际业务中常常是「这次请求既想删掉旧关联、又要挂上新关联」。RelationQueryBuilder 为此提供了便捷的组合方法addAndRemove(added, removed),内部依次调用remove(removed)再add(added)(RelationQueryBuilder.addAndRemove()),两个参数同样都支持实体 / id / id map / 数组。
数组批量支持
add、remove以及addAndRemove中的两个参数都支持传数组,意味着可以用一条链式调用完成多次绑定/解绑。源码对空数组做了短路保护:Array.isArray(value) && value.length === 0时直接返回,不产生任何 SQL(见 RelationQueryBuilder.add() 与 remove 对应逻辑)。
底层实现:从 RelationUpdater / RelationRemover 看真正执行的 SQL
抛开 API 层,看两个执行器能帮你更准确地预判每条调用会落成什么样的 SQL。
add与set最终都汇聚到 RelationUpdater.update(),其分支判断顺序非常清晰:
.of()目标 | 关系类型 | 实际执行 |
|---|---|---|
| 父实体 | many-to-many | 对 junction 表批量INSERT(组合 of 与 value 的笛卡尔积;Oracle / SAP 逐条插) |
| 父实体 | one-to-many | UPDATE子表外键列 = 父主键,WHERE id IN (value) |
| 子实体(作为 relation 目标时) | one-to-one 非 owner / one-to-many | 批量将 inverse join column 置为.of()或置NULL |
remove全部落在 RelationRemover.remove():many-to-many 直接对 junction 表 DELETE(按 of 与 value 的每一组合逐一拼 AND 条件,再用 OR 连接);one-to-many 则是对子表做 UPDATE 把外键置 NULL。注意一个细微差别:one-to-many 的 remove 并不会删子实体本身,只是解绑(外键置 NULL)——如果你的外键列声明了NOT NULL,那么 one-to-many 的 remove 将无法执行成功,这点在建模时务必留意。
测试用例佐证
仓库在 test/functional/query-builder/relational/ 目录下为整套关系查询构建器提供了完备的功能测试,可按关系类型对照:
with-many/query-builder-relational-add-remove-many-to-many.test.ts:验证多对多下 add / remove 绑定生效,且不影响同表其它记录的关联;with-many/query-builder-relational-add-remove-many-to-many-inverse.test.ts:验证从非 owner 一侧操作多对多;with-many/query-builder-relational-add-remove-one-to-many.test.ts:一对多场景的绑定与解绑;with-many/query-builder-relational-load-many.test.ts:验证loadMany返回的关联数组与预期一致;with-one/query-builder-relational-set-many-to-one.test.ts、with-one/query-builder-relational-set-one-to-one.test.ts、with-one/query-builder-relational-set-one-to-one-inverse.test.ts:验证 set 在 many-to-one / one-to-one(含 inverse 侧)下的赋值行为;with-one/query-builder-relational-load-one.test.ts:验证loadOne的单对象加载。
这些测试跑在多种数据库连接之上,且断言风格统一:先save若干种子实体,随后调用.relation(...).of(...).add/remove(...),最后通过findOneOrFail带relations重新加载并校验关联是否精确变更(例如 query-builder-relational-add-remove-many-to-many.test.ts),可直接作为你理解正确用法与预期结果的参照。
使用建议与注意事项小结
- API 选择速查:many-to-many / one-to-many 用
add/remove;many-to-one / one-to-one 用set(置空传null)。选错 API 时源码会抛出明确报错,可按提示快速纠正。 - 能传 id 就不传实体:绑定与解绑只依赖主键,直接用 id 可省掉一次实体加载,进一步提升性能。
- 复合主键必须用 id map,单值传入在加载与多列 join column 场景下都会被源码拒绝并给出提示。
- 它只改关系,不改实体本身:add / remove 不会插入或删除实体记录(one-to-many 的 remove 只会置 NULL 外键),实体级增删请走 repository 的
save/remove。 - 高基数关系(如上万条关联)是它的主场:关系查询构建器的批量 INSERT / 精确 DELETE 天然规避了「先全量加载再保存」的巨额成本,让集合型关系在高并发、大数据量下依然可控。
若想了解关系模型如何定义(join column、junction 表等),可进一步阅读关系查询构建器之外的相关文档与源码;本文聚焦的写操作入口统一从dataSource.createQueryBuilder().relation(...)发起,与数据源初始化方式无关,任何已建立的 DataSource 均可直接使用。
【免费下载链接】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),仅供参考