Sparse 字段

Odoo 为什么需要 sparse 字段:base_sparse_field 如何把可变配置收进一列并保持 ORM 可用

结合 base_sparse_field 源码,讲清 Odoo 为什么不总是为每个动态配置都新建数据库列,以及 sparse 字段怎样借助 serialized 字段完成读写、清空与反射。

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

很多人第一次看到 sparse='data' 这种写法,会觉得它像个小技巧:

  • 字段还是正常字段;
  • 只是换了个地方存;
  • 对业务开发没什么本质影响。

但从 /home/ubuntu/odoo-temp/addons/base_sparse_field 的实现看,Odoo 真正想解决的问题其实很现实:

有些字段“偶尔才会有值”,如果每个都建成真实列,表会越来越宽;但如果完全绕开 ORM,又会失去字段声明、视图、默认值和权限体系的好处。

sparse field 就是 Odoo 给这类“低命中率字段”做的折中方案。

一、它解决的不是“存不下”,而是“宽表越来越难维护”

源码文档直接写得很明确:

  • sparse 字段“很小概率非空”;
  • 多个这类字段可以被序列化进一个共同的存储位置;
  • 这个公共位置就是一个 serialized 字段。

翻成人话就是:

  • 常用字段,照样给数据库独立列;
  • 很少被填、但又想保留字段体验的配置项,塞进一个 JSON 风格的大包里。

这特别适合:

  • 大量可选设置;
  • 模块扩展里偶发出现的补充字段;
  • 管理后台“有就显示、没有长期为空”的参数。

二、真正的关键不在 Serialized,而在 Field 被补了一层行为

fields.py 里最重要的动作不是新建类,而是直接给 fields.Field 做 monkey patch。

_get_attrs() 干了什么

只要字段声明了 sparse

  • 默认 store=False
  • 默认 copy=False
  • 自动挂上 compute = _compute_sparse
  • 如果不是只读,再挂 inverse = _inverse_sparse

这意味着 sparse 字段在 ORM 看起来像普通字段,但底层其实变成了:

“读的时候从公共字典里拿,写的时候再回填进去。”

所以它不是额外的数据库列,而是“借住”在另一个序列化字段里。

三、读流程:_compute_sparse() 只是把字典里的值映射回来

读的时候,Odoo 对每条记录做的事很直白:

  1. 先取 record[self.sparse],也就是那块公共存储;
  2. 再用当前字段名去字典里取值;
  3. 如果是关系字段,再补一层 .exists(),把已失效关系清理掉。

这个设计很关键,因为它说明 sparse 字段并没有绕过字段类型系统。

即便值最终存成 JSON 风格数据,回到 record 上时仍然要重新变成:

  • Boolean
  • Integer
  • Float
  • Char
  • Selection
  • Many2one

也正因为这样,业务代码访问 record.booleanrecord.partner 时,手感和普通字段基本一样。

四、写流程:_inverse_sparse() 不是简单覆盖,而是“有值写入、无值删除”

写入时更有意思。

_inverse_sparse() 会先把字段值转成可读写格式,再更新那份字典:

  • 有值:写入 values[self.name]
  • 没值:直接把 key 从字典里删掉

这说明 sparse 字段设计不是“存 False”,而是:

尽量只保留真正有意义的键。

所以你把字段清空之后,底层不是把 JSON 改成 "my_field": false,而是把这个键整个移除。

这也是它为什么能“压缩”——不是压缩算法意义上的压缩,而是避免大量空值占位

五、Serialized 字段本身只做两件事:转缓存、转记录

Serialized 类看起来很薄:

  • convert_to_cache():把 dict 转成 JSON 字符串
  • convert_to_record():把字符串再 json.loads() 回 dict

另外它还有两个非常容易被忽略的点:

1)数据库列类型就是 text

也就是说,Odoo 并没有为这个老模块专门依赖 JSONB 之类数据库能力,而是优先用通用文本列承载。

2)prefetch = False

这说明框架作者很清楚:

  • serialized 包可能很大;
  • 不应该像普通字段那样默认预取;
  • 否则你只是看一个字段,却顺手把整包都拉进缓存,得不偿失。

六、为什么 Odoo 不允许你后期改“存储系统”或重命名 sparse 字段

models.pyir.model.fields.write() 的限制非常硬:

  • 不能把 sparse 字段改挂到别的 serialization_field_id
  • 不能随便重命名已经存在的 sparse 字段

原因其实不复杂。

因为底层公共存储是字典,key 就是字段名本身。

一旦你:

  • 改字段名;
  • 或把它从 data 挪到别的 serialized 字段;

那以前已经写进 JSON 的 key 就会和当前字段定义脱节。

结果不是“自动迁移”,而是老数据直接失联

所以框架直接禁止这种高风险修改,而不是假装可以平滑演进。

七、_reflect_fields() 才是 sparse 字段能被系统“重新认识”的关键

很多人以为 sparse 字段只影响读写。

实际上,_reflect_fields() 还会把 serialization_field_id 反射回 ir.model.fields

这一步的意义是:

  • 数据库里不仅保存了“有这个字段”;
  • 还保存了“这个字段归哪块 serialized 存储托管”。

后面 _instanciate_attrs() 再把这个信息还原成 attrs['sparse'] = serialization_record.name

也就是说,Odoo 并不是只在 Python 声明层知道 sparse; 它还把这种关系反射进模型元数据里,保证重启、更新、重新装载后还能恢复。

八、测试用例其实把设计意图暴露得很彻底

test_sparse_fields.py 很值得看,因为它几乎把作者的预期行为都写明了:

  • 新建记录时,data 为空;
  • 每写一个 sparse 字段,data 就多一个键;
  • 清空字段时,data 里的对应键会被移除;
  • Many2one 也能正确 round-trip;
  • ir.model.fields 上能反查出 serialization_field_id.name == 'data'

这说明 sparse 机制最核心的承诺只有两个:

  1. 对开发者来说,字段仍像普通字段;
  2. 对存储层来说,只保留真正被用到的键。

九、实战里什么时候适合用,什么时候最好别用

适合用

  • 可选配置很多,但单条记录只会填极少一部分;
  • 你需要字段声明、视图展示、ORM 访问体验;
  • 你接受“这个字段主要是配置位,不是高频筛选列”。

不适合用

  • 你要频繁做 SQL 级筛选、聚合、索引优化;
  • 字段是核心业务字段,经常参与 domain / group by;
  • 你以后很可能改字段名或迁移存储结构。

因为 sparse 的优先级是:

保留 ORM 体验 + 减少宽表压力,而不是提供最强的数据库查询能力。

总结

base_sparse_field 本质上不是“教你把字段塞进 JSON”。

它真正做的是:

  • serialized 字段当公共容器;
  • compute/inverse 把 sparse 字段伪装成正常字段;
  • ir.model.fields 反射保证这套映射关系能长期存在;
  • 用禁止重命名/换存储位,避免历史数据悄悄失联。

如果只记一句,可以记这句:

sparse 字段不是“少一列”这么简单,而是 Odoo 在“字段声明体验”和“数据库宽表代价”之间做的一次工程化妥协。

DISCUSSION

评论区

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