x2many 写入语义

Odoo x2many 命令为什么不是 append/remove 那么简单:Command、set/clear/link 的真实写入语义

基于 Odoo 19 ORM 源码,讲清 One2many 与 Many2many 的 Command 在 create/write 阶段到底会创建什么、解除什么、删除什么,帮你避免误删子行、重复建记录和关系覆盖。

Odoo 开发
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 4 阅读

先记一句最容易救命的话

在 Odoo 里,One2many / Many2many 接收的不是“一个普通列表”,而是一组带副作用的命令

同样是“把一条关系放进去”,下面三种写法的含义完全不同:

  • Command.create({...}):新建一条子记录再连上
  • Command.link(id):把已有记录连上
  • Command.set([ids]):把旧关系整体换成这批 id

很多线上事故,都是把这三件事当成了同一件事。


第一层:Command 本质上是一组三元指令

odoo/orm/commands.py 里把命令定义得很直白:

  • CREATE = 0
  • UPDATE = 1
  • DELETE = 2
  • UNLINK = 3
  • LINK = 4
  • CLEAR = 5
  • SET = 6

而且源码注释已经讲透了最关键的差别:

  • delete(id)删库里的记录,再断开关系
  • unlink(id)先断关系;如果是 One2many 且 inverse 字段是 ondelete='cascade',才会进一步删记录

所以当你只是想“把这一行从父单里拿掉”时,直接上 delete 很可能把真实业务数据一起删了。


第二层:One2many 和 Many2many 的 create 含义不一样

commands.py 里还有一个很容易被忽略的注释:

  • Many2manyCommand.create(values) 会创建 一条 comodel 记录,然后把当前 self 里的所有记录都连到它
  • One2many,会为 self 中的每条父记录各建一条子记录

这意味着同一段代码:

records.write({'tag_ids': [Command.create({'name': 'VIP'})]})

如果 tag_ids 是 Many2many,多条父记录会共享同一个新 tag。

但如果是 One2many:

records.write({'line_ids': [Command.create({'name': 'X'})]})

则每条父记录都会长出自己的那一行。

这就是为什么把 x2many 命令当成“统一 API”理解,经常会在批量操作时踩坑。


第三层:写入前,ORM 会先把“你以为是简写”的东西正规化

fields_relational.pywrite_batch() 里,Odoo 会先把输入统一整理:

  • 元组 / recordset / 普通 id 列表,最后都会被转成命令列表
  • False / None 会变成 Command.clear()
  • 一个普通 id 列表会被理解成 Command.set(ids)

也就是说,如果你写:

record.write({'tag_ids': [1, 2, 3]})

你以为自己是在“追加三个 tag”,但 ORM 理解成的是:

把当前关系整体替换为 [1, 2, 3]

这就是很多 Many2many 字段“怎么一保存旧关系全没了”的根源。


第四层:One2many 的 set / clear 很像“重新分配孩子”

One2many.write_real(),它的核心不是改中间表,而是围绕 inverse 字段做三件事:

  1. 要创建的子行先累计进 to_create
  2. 要删除的子行累计进 to_delete
  3. 要重新挂到哪个父记录上,放进 to_link

最重要的是:

  • link(id) 本质是把那条子记录的 inverse many2one 改成当前父记录
  • clear() / set() 不是“清一个数组”,而是可能触发 解除 inverse、重挂 inverse、甚至删除子记录

如果 inverse 字段带 ondelete='cascade'unlink 也可能演变成真删除。

所以 One2many 不是“父表上有个列表字段”。

它本质上是:

子表上有个 many2one,父表只是通过 inverse 去管理那一批孩子。


第五层:Many2many 的 set 才更像你以为的“换列表”

Many2many.write_real() 的思路不同。

它先算:

  • old_relation
  • new_relation

然后根据命令决定:

  • 增哪些关系
  • 去哪些关系
  • 哪些相关记录要真删

这里要注意两个边界:

1)unlink(id) 只拆关系,不删目标记录

这通常是你想要的“取消勾选标签”。

2)delete(id) 会试图直接删目标记录

如果那条记录还有别的地方在引用,删除未必成功;即使成功,也比你预期更激进。

所以对 Many2many 来说:

  • 日常维护关系:优先 link / unlink / set
  • 真要删除目标主数据:才考虑 delete

最容易误解的一点:set 不是“增量补齐”,而是“最终状态覆盖”

Command.set([1, 2, 3]) 的语义不是:

  • 把 1、2、3 加进来

而是:

  • 最终关系就应该只剩 1、2、3
  • 之前有但现在不在列表里的,要解除

所以如果你在继承 write() 时拿到前端传来的 ids,直接包装成 set(ids),你其实是在宣告:

客户端给我的就是完整真相。

一旦前端只传了“变更部分”,你就会把没传的关系误清掉。


实战里怎么选最稳

场景 1:只想追加已有记录

用:

[Command.link(id1), Command.link(id2)]

不要用 set(),除非你确定自己拿到的是完整列表。

场景 2:只想移除关系,不碰目标记录

用:

[Command.unlink(id)]

场景 3:要新建子行并挂到父记录

用:

[Command.create({'field': value})]

但要先判断字段是 One2many 还是 Many2many,因为批量写时创建数量不同。

场景 4:确实要把关系重置成某个最终集合

才用:

[Command.set(ids)]

一个简单的排错顺序

当你发现“写完 x2many 结果不对”时,按这个顺序查:

  1. 这个字段到底是 One2many 还是 Many2many
  2. 你传的是命令列表,还是被 ORM 自动解释成了 set
  3. 你想断关系,还是想删记录
  4. inverse 字段是否 ondelete='cascade'
  5. 批量写时 Command.create 会建一条还是多条

前 4 步搞清楚,绝大多数 x2many bug 都能定位。


结论

x2many 真正难的地方,不在语法,而在语义非常像列表操作,后果却是数据库级操作

你可以把它记成一句话:

在 Odoo 里,x2many 不是“塞个数组”,而是在声明一组对关系和记录本身的数据库动作。

一旦带着这个心智模型去写 Command,很多“怎么把旧数据写没了”的问题会立刻少一大半。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。