先记一句最容易救命的话
在 Odoo 里,One2many / Many2many 接收的不是“一个普通列表”,而是一组带副作用的命令。
同样是“把一条关系放进去”,下面三种写法的含义完全不同:
Command.create({...}):新建一条子记录再连上Command.link(id):把已有记录连上Command.set([ids]):把旧关系整体换成这批 id
很多线上事故,都是把这三件事当成了同一件事。
第一层:Command 本质上是一组三元指令
odoo/orm/commands.py 里把命令定义得很直白:
CREATE = 0UPDATE = 1DELETE = 2UNLINK = 3LINK = 4CLEAR = 5SET = 6
而且源码注释已经讲透了最关键的差别:
delete 和 unlink 不是一回事
delete(id):删库里的记录,再断开关系unlink(id):先断关系;如果是 One2many 且 inverse 字段是ondelete='cascade',才会进一步删记录
所以当你只是想“把这一行从父单里拿掉”时,直接上 delete 很可能把真实业务数据一起删了。
第二层:One2many 和 Many2many 的 create 含义不一样
commands.py 里还有一个很容易被忽略的注释:
- 对 Many2many,
Command.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.py 的 write_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 字段做三件事:
- 要创建的子行先累计进
to_create - 要删除的子行累计进
to_delete - 要重新挂到哪个父记录上,放进
to_link
最重要的是:
link(id)本质是把那条子记录的 inverse many2one 改成当前父记录clear()/set()不是“清一个数组”,而是可能触发 解除 inverse、重挂 inverse、甚至删除子记录
如果 inverse 字段带 ondelete='cascade',unlink 也可能演变成真删除。
所以 One2many 不是“父表上有个列表字段”。
它本质上是:
子表上有个 many2one,父表只是通过 inverse 去管理那一批孩子。
第五层:Many2many 的 set 才更像你以为的“换列表”
Many2many.write_real() 的思路不同。
它先算:
old_relationnew_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 结果不对”时,按这个顺序查:
- 这个字段到底是 One2many 还是 Many2many
- 你传的是命令列表,还是被 ORM 自动解释成了
set - 你想断关系,还是想删记录
- inverse 字段是否
ondelete='cascade' - 批量写时
Command.create会建一条还是多条
前 4 步搞清楚,绝大多数 x2many bug 都能定位。
结论
x2many 真正难的地方,不在语法,而在语义非常像列表操作,后果却是数据库级操作。
你可以把它记成一句话:
在 Odoo 里,x2many 不是“塞个数组”,而是在声明一组对关系和记录本身的数据库动作。
一旦带着这个心智模型去写 Command,很多“怎么把旧数据写没了”的问题会立刻少一大半。
DISCUSSION
评论区