伪记录机制

Odoo onchange 里的 new() 不是数据库记录:伪记录、缓存与 _convert_to_write 边界讲透

从 Odoo ORM 源码讲清 new() 产生的伪记录到底是什么,为什么它能触发 onchange、计算字段和 x2many 交互,却并没有真正写入数据库,以及 _convert_to_write 在保存前扮演什么角色。

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

先说结论

很多人第一次调 Odoo onchange,会下意识以为:

“我在表单里改了字段,既然 Python 里能看到 record,说明数据库里已经有这条记录或已经写进去了。”

这其实是错的。

Odoo 的 onchange 场景里,经常操作的并不是数据库记录,而是 new() 创建出来的伪记录(in-memory record)。它的特点是:

  • 挂在当前 env
  • 有 recordset 的大部分行为
  • 能走字段转换、缓存、onchange、依赖字段计算
  • 并没有真正写库

直到最后保存时,系统才会把这份缓存状态转成 create() / write() 能接受的值,而这里最关键的桥接点之一,就是 _convert_to_write()

所以最实用的理解方式是:

  • new() = 在内存里搭一张“草稿表单”
  • onchange = 在这张草稿表单上做动态联动
  • _convert_to_write() = 把草稿翻译成真正可落库的 payload
  • create() / 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.
    """

它做的事情大概是:

  1. 生成一个 NewId(...)
  2. browse() 出一条“看起来像 record 的对象”
  3. _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 在表单里经常同时存在三种东西:

  1. 数据库里原本已有的行
  2. 当前表单里新加但还没保存的行
  3. 因 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

评论区

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