ORM 复制

Odoo 复制记录时,copy 和 copy_data 到底谁该改:别再把“改值”和“落库动作”写混了

很多复制逻辑之所以越改越乱,不是因为 Odoo 的复制机制复杂,而是开发者没有分清 copy_data 是准备数据,copy 才是创建新记录。本文结合 ORM 源码讲透这条边界。

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

先说结论

很多 Odoo 开发在做“复制一条记录并顺手改点东西”时,第一反应是直接重写 copy()

这当然能做,但很多场景里,更准确的落点其实是 copy_data()

因为这两个方法的职责根本不一样:

  • copy_data():准备“将来要创建的新记录数据”
  • copy():真正调用 create,创建新记录,并复制翻译

一句话记忆:

copy_data 管“复制什么值”,copy 管“怎么真的复制出新对象”。

这条边界一旦搞清,很多重复命名、子表复制、后处理逻辑都会顺很多。


ORM 源码里,这个分层写得非常清楚

odoo/orm/models.py 里,Odoo 对复制流程做了三步拆分:

第一步:copy_data(default=None)

它会:

  • 读取原记录可复制字段
  • 组装成 vals_list
  • 把 one2many 递归变成 Command.create(...)
  • 把 many2many 变成 Command.set(ids)
  • 应用 default 覆盖
  • 返回一组“准备写入 create 的字典”

也就是说,到这一步为止,还没有真正创建新记录

第二步:copy(default=None)

它会:

  1. self.with_context(active_test=False).copy_data(default)
  2. self.create(vals_list)
  3. 最后 old_record.copy_translations(new_record, excluded=default or ())

所以 copy() 是“总协调器”,不是“值组装器”。


为什么很多复制改值逻辑更适合写在 copy_data()

因为你真正想改的,往往只是:

  • 新名字怎么起
  • 某些字段复制时要不要清空
  • 某些关系字段是否要跟着复制
  • 某些默认值是否要覆盖

这些都属于:

我要产出什么 vals 去 create

这就是 copy_data() 的语义地盘。

如果你只是想“复制时把 name 改成 copy 版”,却去重写整个 copy(),往往就是在更外层的地方做更内层的事。


Odoo 自己也经常这么干

例子 1:res.partner.copy_data()

odoo/addons/base/models/res_partner.py 里,Odoo 就是先走 super().copy_data(default=default),然后如果没显式指定 name,再把名字改成:

  • 原名字 (copy)

这个例子非常典型。

因为它要改的不是复制流程,而只是复制出来的新值。

例子 2:库存里的 copy_data() 也经常用来改命名

在 Odoo 官方很多模型里,你都能看到类似模式:

  • 先继承父类复制值
  • 再把名称、编码、序列信息做局部修正

这再次说明:

“复制时值该长什么样”这件事,天然就属于 copy_data()


copy_data() 到底帮你做了哪些脏活

源码里这段方法其实很值得读,因为它已经替你处理了很多复杂边界。

1. 它会尊重字段的 copy 属性

只有 field.copy=True 的字段才会进入复制集。

所以很多你以为“怎么没跟着复制”的字段,其实是字段定义上就不允许复制。

这点很重要,因为有些开发者会误判成 ORM 漏复制。

2. 它会黑名单掉魔法列和某些继承字段

比如:

  • id
  • 审计字段
  • parent_path
  • 某些 _inherits 带来的字段

这能避免把本该由新记录重新生成的内容硬拷过去。

3. 它会递归复制 one2many

源码里对 one2many 的做法是:

  • 先对子记录调用 copy_data()
  • 再包成 Command.create(...)

所以很多子表复制,本来框架已经帮你递归处理了。

4. 它不会“暴力深拷 many2many 对象”

many2many 默认只是复制链接,用 Command.set(ids)

这很合理,因为多数场景下:

  • 标签、分类、可选关系对象
  • 本来就不是要再复制一份“对象本体”

而只是让新记录继续指向同一批已存在对象。

5. 它还考虑了访问权限

源码里 many2many 复制时,会对目标记录做 _filtered_access('read')

意思是:

只复制当前能读到的链接,否则后续写入可能失败。

这又一次提醒你,复制不只是“值搬过去”,它仍然活在权限边界里。


copy() 适合管什么

如果你的逻辑不只是改 vals,而是涉及:

  • 创建后必须做额外动作
  • 需要在新记录生成后补建别的对象
  • 要根据新记录 ID 再继续处理
  • 要控制整段复制事务语义

这时候才更像 copy() 的职责。

因为这些动作都必须发生在“新对象已经存在之后”。

一个很实用的判断法是:

如果你只需要改“将被 create 的字典”

优先看 copy_data()

如果你需要拿到“已经创建出来的新 record”再继续做事

再考虑 copy()


很多人踩坑的地方:把复制后处理硬塞进 copy_data()

copy_data() 返回的是一堆字典,不是已存在的新记录。

所以你在这里做这些事通常就不对:

  • 给新记录发消息
  • 根据新记录 ID 建附件
  • 立刻写依赖新对象主键的子对象
  • 依赖 create side effect 的逻辑

因为那时候新记录还没出生。

所以别把“未来值准备”误当成“对象已经创建”。


另一个经常被忽略的点:copy() 还会复制翻译

很多人自己手写:

  • 读原字段
  • create 一条新记录

然后以为这就等于 Odoo 的复制了。

其实不完全一样。

models.py 里,copy() 在 create 完之后,还会调用:

  • copy_translations(new_record, excluded=default or ())

也就是说,Odoo 标准复制流程不仅复制基础值,还会递归处理:

  • 可翻译字段的翻译内容
  • one2many 子记录的翻译复制

所以如果你完全绕开标准复制链,常常会丢掉这些细节能力。


实战里最常见的 3 种写法

场景 1:只是想把名字改成“(copy)”

优先重写 copy_data()

场景 2:复制后要补建关联对象

通常重写 copy() 更自然。

场景 3:既要改默认值,又要创建后补动作

可以:

  • copy_data() 里处理“值长什么样”
  • copy() 里处理“创建后还要做什么”

别全部堆在一层。


一条很稳的设计原则

你可以把复制过程理解成两段流水线:

A. 准备订单清单

也就是 copy_data()

B. 按清单真的下单并收货

也就是 copy() -> create() -> copy_translations()

如果你只是想改订单清单,却跑去改收货流程,代码就会变笨重。


总结

copy_data()copy() 最大的区别,不在于谁更高级,而在于它们处理的是复制链上的不同层次:

  • copy_data():决定复制值怎么组装
  • copy():决定如何真正创建新记录,并补齐翻译与后续流程

所以以后遇到“复制时顺手改几个字段”的需求,先别急着重写 copy()

先问一句:

我到底是在改‘新记录的数据’,还是在改‘新记录创建之后的动作’?

大多数时候,答案会直接把你带到正确的方法上。

DISCUSSION

评论区

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