结论先行
如果你一直觉得 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
这三个东西是绑在一起理解的:
- 当前层显示什么组
- 点击某组后要继续搜什么数据
- 下一层应该再按什么维度继续分
第一层: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 会做三件事:
- 把原始值转成更适合显示的标签
- 在
__range里保留 from/to - 在
__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
评论区