先说结论
很多人第一次调 Odoo onchange,会下意识以为:
“我在表单里改了字段,既然 Python 里能看到 record,说明数据库里已经有这条记录或已经写进去了。”
这其实是错的。
Odoo 的 onchange 场景里,经常操作的并不是数据库记录,而是 new() 创建出来的伪记录(in-memory record)。它的特点是:
- 挂在当前
env上 - 有 recordset 的大部分行为
- 能走字段转换、缓存、onchange、依赖字段计算
- 但并没有真正写库
直到最后保存时,系统才会把这份缓存状态转成 create() / write() 能接受的值,而这里最关键的桥接点之一,就是 _convert_to_write()。
所以最实用的理解方式是:
new()= 在内存里搭一张“草稿表单”- onchange = 在这张草稿表单上做动态联动
_convert_to_write()= 把草稿翻译成真正可落库的 payloadcreate()/write()= 最终入库
一、源码已经写明:new() 创建的是“只存在于内存中的记录”
在 /home/ubuntu/odoo-temp/odoo/orm/models.py 里,new() 的注释说得很直接:
def new(self, values=None, origin=None, ref=None):
""" Return a new record instance ...
The record is *not* created in database, it only exists in memory.
"""
它做的事情大概是:
- 生成一个
NewId(...) browse()出一条“看起来像 record 的对象”- 用
_update_cache(values, validate=False)把值灌进缓存
也就是说,这个对象不是空壳;它有缓存、有字段值、有关系字段行为,很多 ORM 代码对它照样能跑。
但它没有真实数据库主键,也没有真正落库。
二、为什么 new() 记录也能像正常 record 一样跑很多逻辑
因为 Odoo 很多字段访问和联动,本来就不是“每次都直接查库”,而是通过 record cache 运作。
new() 做的核心动作是:
record = self.browse((NewId(origin, ref),))
record._update_cache(values, validate=False)
一旦缓存里已经有这些值,后续很多逻辑就可以像处理普通 record 一样处理它:
- 访问字段
- 触发
@api.onchange - 做依赖字段重算
- 操作 many2one / one2many / many2many
- 读取
_origin
所以表单里你会感觉“这条记录明明已经活了”。
确实活了,但只是在内存里活着。
三、_origin 是什么,为什么它对编辑已有记录特别重要
源码里 _origin 的语义也很关键:
@property
def _origin(self):
if all(self._ids):
return self
ids = tuple(OriginIds(self._ids))
...
它表示:
- 如果当前 record 已经是真实记录,
_origin就是自己 - 如果当前 record 是 new record,但背后对应某条已有记录,则
_origin指向那条原始记录
这就是为什么在表单编辑老数据时:
- 当前表单里的对象可能是“草稿态新 record”
- 但你仍然能通过
_origin取到数据库里的原记录
实战里常见用途是:
- 在 onchange 中比较“原始值”和“当前草稿值”
- 判断当前行是新加的还是从旧记录编辑出来的
- 处理 x2many 行时区分已有行与临时行
这也是很多开发者第一次接触 _origin 时会恍然大悟的点:
表单编辑不是直接在数据库记录上原地改,而是围绕“原始记录 + 草稿缓存”展开的。
四、onchange 为什么能联动,却没有真正写库
源码里 onchange() 的核心思想不是“执行 write”,而是:
- 把当前表单值当成输入
- 跑相关 onchange 方法
- 把返回值或字段赋值结果写回缓存
- 把差异结果回给前端
_apply_onchange_methods() 里也能看出来:
for method in self._onchange_methods.get(field_name, ()):
res = method(self)
...
if res.get('value'):
self[key] = val
注意这里是 self[key] = val,也就是通过字段 setter 和缓存机制改当前 record 的值,而不是直接 write()。
所以 onchange 的本质不是“把数据库改了”,而是:
“把草稿表单重新算一遍,并把新的草稿状态回传给客户端。”
这也是为什么:
- onchange 能提示 warning
- 能调整一些字段显示值
- 能加删临时 x2many 行
- 但如果你不点保存,这些变化不会真正写到数据库
五、_convert_to_write() 干的到底是什么
这一步经常被忽略,但它其实是表单草稿到数据库 payload 的关键转换层。
源码里实现很简单:
def _convert_to_write(self, values):
result = {}
for name, value in values.items():
if name in fields:
field = fields[name]
value = field.convert_to_write(value, self)
if not isinstance(value, NewId):
result[name] = value
return result
翻成人话就是:
- 把缓存格式的值
- 转成
write()/create()能接受的格式 - 同时把还没法真正落库的
NewId之类临时标识清洗掉
这就是为什么 _convert_to_write() 非常适合出现在:
- Form 保存前
- 需要把 onchange 草稿值序列化时
- 想把内存态 x2many / many2one 值转成命令列表或 ID 时
它不是在“写库”,而是在“准备一份可写库的数据”。
六、为什么 x2many 在 onchange 场景尤其容易让人迷糊
因为 x2many 在表单里经常同时存在三种东西:
- 数据库里原本已有的行
- 当前表单里新加但还没保存的行
- 因 onchange 动态生成/删除的临时行
而 Odoo 又允许你在这些临时行上继续:
- 改字段
- 做联动
- 比较
_origin - 最后再序列化成 command list
所以你在调试时会看到一些很像“真记录”的对象,但里面夹着 NewId。
这正是 Odoo 表单能做出很复杂交互体验的基础:
前端不是每改一下就写一次库,而是维护一棵草稿态 record graph,最后一次性提交。
七、为什么“在 onchange 里 write/create”通常是坏味道
因为 onchange 的设计前提就是“草稿态推演”。
如果你在 onchange 里直接:
self.write(...)self.env['x'].create(...)- 或对别的业务对象做有副作用操作
就会出现很多问题:
1)用户还没保存,数据库已经变了
这最违反直觉。
2)用户改来改去,onchange 可能被触发很多次
你会制造重复写入、重复创建、重复副作用。
3)表单取消后,副作用不会自动回滚到“没发生过”
因为这些操作并不是“草稿缓存的一部分”。
因此更推荐的原则是:
- onchange 只做草稿态联动、提示、建议值计算
- 真正落库的业务副作用放到
create()/write()/ button action
八、源码和测试告诉我们的一个重要事实:表单模拟的是“客户端行为”,不是裸 ORM 行为
odoo/tests/form.py 的设计目标,就是在服务端测试里尽量模拟客户端表单流程:
- 创建模式先跑默认值
- 改字段时跑 onchange
- 正确处理 x2many
- 保存时再提交
这意味着如果你只在 shell 里用几行 create() / write() 重现问题,常常复现不出真实前端 bug。因为你跳过了:
- new record
- onchange graph
- fields_spec
- 表单上下文
- 序列化/回写逻辑
很多“界面里会炸,脚本里不炸”的问题,根因就在这里。
九、开发时最值得记住的边界
new() 适合什么
- 表单草稿
- onchange 推演
- 在内存里试算字段组合
- 组装还没落库的临时 record graph
new() 不适合什么
- 作为“已经持久化”的依据
- 拿来判断数据库中一定存在该记录
- 在里面做不可逆副作用
_convert_to_write() 适合什么
- 把缓存值转成真正可写 payload
- 在提交前做最后一步格式统一
_convert_to_write() 不等于什么
- 不等于写库
- 不等于校验通过
- 不等于业务已经正式生效
十、一个非常实用的心智模型
把 Odoo 表单编辑分成三层最容易理解:
第一层:原始记录
数据库里真正存在的 record。
第二层:草稿记录
new() 产生的内存态 record,可能带 _origin 指向原始记录。
第三层:提交 payload
通过 _convert_to_write() 等机制,把草稿变成 create() / write() 的参数。
理解这三层后,很多诡异现象都会变得很正常:
- 为什么没保存也能看到联动结果
- 为什么
_origin能看到旧值 - 为什么 x2many 里会有临时行
- 为什么取消表单后数据库没变
结语
Odoo 的 onchange 之所以强大,不是因为它在偷偷直接改库,而是因为它在 ORM 里认真维护了一套“草稿态记录 + 缓存 + 序列化提交”的机制。
new() 让草稿记录像真记录一样工作,_origin 让编辑旧数据时还能回看原始状态,_convert_to_write() 则负责在真正落库前把这份草稿翻译成数据库能接受的语言。
一旦你把这三者连起来理解,onchange 调试会清爽很多,很多“为什么界面这样、数据库却不是那样”的困惑也就自然解开了。
DISCUSSION
评论区