singleton 边界

Odoo 的 Expected singleton 到底在提醒什么:ensure_one、字段读取与批量 recordset 的边界讲透

很多人把 Expected singleton 当成“报错提示”,其实它暴露的是 recordset 设计边界。本文结合 Odoo 官方 ORM 源码,讲清 ensure_one、字段读取为何常要求单条记录,以及批量写法该怎样改。

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

先说结论

Expected singleton 不是一个“烦人的通用报错”,它通常是在提醒你:

你正在用“单条记录”的思维,操作一个“多条记录”的 recordset。

ensure_one() 的职责也很简单:

显式声明:下面这段逻辑只允许在单条记录语境下运行。

这不是性能开关,也不是代码仪式感,而是 Odoo recordset 语义边界的一部分。


官方源码里的 ensure_one() 到底做了什么

/home/ubuntu/odoo-temp/odoo/orm/models.py 里,ensure_one() 的实现其实非常短:

  • 尝试把 self._ids 解包成一个 _id
  • 成功就返回 self
  • 失败就抛出 ValueError("Expected singleton: %s" % self)

源码注释还特别写了一句:

  • 当确实只有一条时,解包比 len(self) 更快

这说明 Odoo 不是临时想到才加这个方法,而是把“单记录断言”当成非常高频、非常基础的 ORM 操作。

所以 ensure_one() 的重点不是“做复杂校验”,而是:

  • 让方法入口显式声明单记录假设
  • 在假设被破坏时尽快失败
  • 用统一错误把问题暴露出来

为什么字段读取也常常会触发 singleton

很多人第一次碰到 Expected singleton,并不是自己手写了 ensure_one(),而是在访问字段时炸的,比如:

partners.name

如果 partners 里有多条记录,这类读取经常就会报错。

原因也能在官方源码里看到。

/home/ubuntu/odoo-temp/odoo/orm/fields.py 中,字段读取逻辑会先看 record._ids 的长度:

  • 如果是 1,按单条记录正常取值
  • 如果是 0,返回空值语义
  • 如果大于 1,就让 record.ensure_one() 来抛出标准异常

这背后其实是在表达一个很朴素的设计:

大多数标量字段读取,本来就没有“多条记录直接给我一个值”的自然语义。

比如三条 res.partner

  • partners.name 应该返回谁?
  • 第一个?
  • 拼起来?
  • 返回列表?

Odoo 不替你乱猜,而是强制你把意图写清楚。


为什么 recordset 能批量写,字段却常常不能批量直接读

这是很多新手最容易混的地方。

Odoo 的 recordset 设计里:

  • 很多方法天然支持批量,比如 write()unlink()mapped()filtered()
  • 很多业务逻辑天然只适合单条,比如“生成这张单据的 portal 链接”“根据这条记录算默认别名”

所以不是 Odoo 前后不一致,而是:

recordset 是批量容器,但不是所有操作都对批量容器有自然定义。

这也是为什么你会看到两种很常见的方法开头:

写法 A:显式单条

def action_do_something(self):
    self.ensure_one()

表示后面逻辑就是单条语义。

写法 B:循环逐条

def action_do_something(self):
    for record in self:
        ...

表示方法允许多条输入,但实际是逐条处理。


ensure_one() 真正解决的不是报错,而是“语义含糊”

很多人修 bug 的方式是:

  • 看到 Expected singleton
  • 立刻在外面加个 for rec in self
  • 让错误消失

这有时是对的,但也有时只是把问题藏起来。

因为你真正该先问的是:

1. 这段逻辑本来应该支持多条吗?

如果方法的业务意义就是“打开当前记录的页面”“生成当前对象的一个唯一值”,那它可能本来就应该 ensure_one()

2. 这里的输出到底应该是什么形状?

如果输入多条:

  • 是应该返回一个动作?
  • 还是每条各做一次副作用?
  • 还是应该聚合后返回一个结果?

如果这个问题没想清楚,只是盲目套循环,代码虽然不报错,但设计已经开始发散了。


最常见的几类误区

误区 1:把 self.id 当成永远安全

在单记录下,self.id 很顺手。

但如果方法可能由 tree view 多选触发,self 就可能是多条。此时:

  • self.id
  • self.name
  • self.partner_id.name

都可能隐藏着 singleton 假设。

误区 2:在 compute / onchange 里乱用 ensure_one()

如果你的 compute 方法本来应该批量跑:

for record in self:
    ...

却一上来 self.ensure_one(),你就把 Odoo 的批量重算能力人为打散了。

结果通常是:

  • 逻辑不够 ORM 友好
  • 性能变差
  • 某些批量场景直接出错

误区 3:把 ensure_one() 当作“修 bug 的胶布”

如果一个方法本来会被批量调用,你只是为了止血加一句 self.ensure_one(),那只是把“设计不支持批量”硬写进去了。

它能让错误更早爆,但不能自动让设计正确。


实战里怎样判断该不该 ensure_one()

我更建议按下面顺序判断。

适合 ensure_one() 的场景

  • 方法输出天然只对应一条记录
  • 返回值不是列表而是单个对象 / 单个动作 / 单个 URL
  • 逻辑依赖“当前记录”这个唯一语境
  • 被多选调用本来就应该视为错误

不适合一上来 ensure_one() 的场景

  • compute 方法
  • 批量校验 / 批量写入
  • 定时任务批量处理
  • 导入、同步、批处理入口
  • 明明可以一次性利用 recordset 优势的 ORM 逻辑

一句话:

如果业务本质是单条,就 assert;如果业务本质能批量,就设计成批量。


更稳的开发习惯

单条方法就明确写

def action_open_portal(self):
    self.ensure_one()

批量方法就按 recordset 设计

def _compute_total_amount(self):
    for record in self:
        ...

需要聚合时,用 mapped()、字典、分组,而不是把批量代码偷偷单条化

这会比“哪里炸了就补一个 ensure_one()”健康得多。


一句话收尾

Expected singleton 不是 Odoo 在刁难你,而是在提醒你:这里的 recordset 语义还没想清楚。ensure_one() 的价值,就是把单记录边界写明白。

DISCUSSION

评论区

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