read_group 机制

Odoo 的 read_group 为什么默认只分第一层:lazy、__domain 与 __context 的真实协作方式

列表分组看起来像一次把多层都算完,其实 Odoo 的 read_group 默认 lazy=True,只先做第一层聚合,再把剩余分组条件放进 __context。本文结合 odoo/orm/models.py 讲清 lazy 模式、__domain、__context、时间分组格式化以及报表开发最常踩的坑。

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

结论先行

如果你一直觉得 Odoo 的列表分组“像是一次把所有层级都算好了”,那其实是前端给你的错觉。

read_group() 默认是 lazy=True,在 /home/ubuntu/odoo-temp/odoo/orm/models.py 里,第一句核心逻辑就是:

lazy_groupby = groupby[:1] if lazy else groupby

也就是说:

  • lazy=True:这次只真正按第一层分组
  • 剩余层级不会立刻进 SQL 聚合
  • 而是放进每行结果的 __context
  • 当前分组对应的筛选条件则写进 __domain

所以用户在界面里“展开下一层”,本质上不是把之前 SQL 的隐藏结果拿出来,而是 带着 __domain + __context 再发一轮新的 read_group


先看官方签名,lazy 的语义写得很直白

源码里的 read_group() 文档本身就写了:

  • lazy=True 时,只按第一个 groupby 做分组
  • 剩余 groupby 放在 __context
  • 返回值里还会包含 __domain

这三个东西是绑在一起理解的:

  1. 当前层显示什么组
  2. 点击某组后要继续搜什么数据
  3. 下一层应该再按什么维度继续分

第一层:annotated_groupby 只处理当前层

read_group() 会先把 groupby 标准化。对于 lazy 模式,只处理 lazy_groupby,也就是第一层。

比如你传:

groupby=['stage_id', 'user_id', 'date_deadline:month']

默认 lazy 模式下,本次真正进聚合的只有:

['stage_id']

后两层不会马上做。

这就是为什么很多人拿 read_group() 结果做二次开发时,会误以为:

“奇怪,我明明传了三层 groupby,怎么结果里像只有一层?”

因为默认设计就是这样。


第二层:__context 不是装饰字段,而是“下一轮分组说明书”

源码里这段非常关键:

if len(lazy_groupby) < len(groupby):
    row['__context'] = {'group_by': groupby[len(lazy_groupby):]}

翻译一下:

  • 如果这次只处理了第一层
  • 那就把剩下没做的分组维度塞进 __context

比如当前按 stage_id 分完后,某一行会拿到:

{'group_by': ['user_id', 'date_deadline:month']}

所以前端在展开“某个阶段”时,实际上是拿:

  • 这一行的 __domain
  • 这一行的 __context['group_by']

重新发起下一次分组请求。

这也是为什么 __context 不是给开发者“看看而已”,它是 lazy 分组能层层展开的关键控制信息。


第三层:__domain 才是“点开这一组后到底查什么”的核心

read_group() 里,所有行先会拿到基础 domain;随后 _read_group_format_result() 会按当前分组值补上额外条件:

row['__domain'] &= Domain(additional_domain)

比如当前层按 stage_id 分组,某行对应“Qualified”,那它最后的 __domain 会变成近似:

原始 domain AND [('stage_id', '=', qualified_id)]

如果是日期分组,补进去的不是简单等于,而是一个范围:

[
    '&',
    ('date_field', '>=', range_start),
    ('date_field', '<', range_end),
]

所以真正的展开逻辑是:

  • __domain:锁定当前组对应的数据子集
  • __context:告诉下一轮还要继续按什么维度分

这俩缺一不可。


第四层:为什么日期分组显示的是标签,domain 却是范围

这也是 read_group() 很容易让新手困惑的一点。

_read_group_format_result() 里,如果是 date/datetime 分组,Odoo 会做三件事:

  1. 把原始值转成更适合显示的标签
  2. __range 里保留 from/to
  3. __domain 里写入时间范围条件

比如月分组,界面显示可能是:

March 2026

但真正用于展开该组的条件是:

('date_field', '>=', '2026-03-01 00:00:00')
('date_field', '<', '2026-04-01 00:00:00')

这套设计的好处是:

  • UI 可读
  • 筛选语义稳定
  • 不会因为格式化标签而丢失边界信息

第五层:为什么 eager 模式并不是“总是更好”

很多开发者第一次知道 lazy=False 后,会本能觉得:

“那我直接 eager,不就一次性拿全了?”

理论上是,但不一定合适。

因为 lazy=False 代表:

  • 所有 groupby 一次进聚合
  • 返回结果更扁平
  • 前端那种层层展开的交互语义会弱很多
  • 某些空组填充逻辑默认也主要围绕 lazy 模式实现

源码里甚至直接写了:

现在 read_group 只在 lazy 模式下做填充结果。

所以你做后台报表导出、固定透视统计时,lazy=False 可能合理;但你想复用 Odoo 原生列表分组的交互心智,lazy 模式通常才是正路。


实战里最常踩的 4 个坑

1)把返回结果当成“所有层级都已算完”

错。默认只算第一层。

2)自己重写结果时把 __domain 丢了

一旦 __domain 没了,前端展开组时就不知道要筛哪一批数据。

3)自己拼 next context,却忘了沿用剩余 group_by

这样用户点开第一层后,第二层就会断掉。

4)日期分组只拿显示标签,不拿 __range 或 __domain

这会让后续钻取或导出结果失去精确边界。


一个够用的理解模型

可以把 read_group(lazy=True) 想成下面这套分工:

  • SQL 本轮只干一层
  • __domain 负责“往下钻时的数据范围”
  • __context 负责“往下钻时还要按什么继续分”
  • __range 负责时间组的真实边界

一旦这样理解,列表分组、图表点击钻取、统计导出之间的关系就会清晰很多。


总结

Odoo 的 read_group() 默认 lazy,不是偷懒,而是为了把“分组展示”和“继续钻取”拆成一套可组合协议:

  • 第一层当前聚合
  • __domain 锁定当前桶
  • __context 指向下一层
  • __range 兜住时间边界

所以当你做报表、列表分组扩展、图表点击联动时,真正该关注的不是“为什么它没一次全算完”,而是:

我有没有正确理解它是在用多次、分层、可钻取的方式组织聚合结果。

DISCUSSION

评论区

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