很多开发者在 Odoo 里写约束时,脑子里其实只有一个模糊印象:
- SQL 约束在数据库层拦
@api.constrains在 Python 层拦
但真正写复杂模型时,决定成败的往往不是“写哪一种约束”,而是:
它到底在 create / write 的哪个时刻触发?
这个问题如果想不清,很容易出现三种典型事故:
- 约束跑得太早,依赖字段还没到位
- 约束跑得太晚,错误已经穿透到后续副作用
- 非 stored / inversed / computed 字段的边界被混成一团
从 /home/ubuntu/odoo-temp/odoo/orm/models.py 的实现看,_validate_fields() 不是一个“统一在最后补跑一下”的钩子,而是穿插在不同写入阶段的多段校验点。
一、_validate_fields() 本身到底做什么
源码里这个方法本身其实很克制:
- 只遍历模型上登记好的 constraint methods
- 只有当约束声明的字段与本次
field_names有交集时才触发 - 如果某些字段在
excluded_names里,就先跳过 - 并且会在
sudo()的 recordset 上执行
也就是说,_validate_fields() 的职责不是“替你决定什么时候校验”,而是:
给当前这一批字段变化,挑出该跑的 Python constraints 并执行。
真正重要的是:是谁在什么时机调用它。
二、write() 不是一次校验,而是“两段校验 + inverse 夹层”
看 write() 的主链路,顺序大致是:
- 检查模型与字段访问权限
- 按
write_sequence写字段 - 调
modified(...)标记依赖重算 - 如果涉及层级关系,必要时处理
parent_path - 先校验非 inverse 字段
- 再执行 inverse
- 再校验 inverse 字段
- 最后再做
_check_company等额外检查
这里最关键的源码就是两次调用:
real_recs._validate_fields(vals, inverse_fields)real_recs._validate_fields(inverse_fields)
这说明 Odoo 对 write() 的理解很明确:
第一段:先校验普通写入字段
当字段值已经写进缓存 / 数据层,但 inverse 逻辑还没补齐时,先检查那些不依赖 inverse 完整结果的约束。
第二段:inverse 真正跑完后,再校验 inverse 相关字段
因为 inverse 字段的最终一致状态,往往要等 determine_inverse(...) 完成才成立。
这也是为什么有些约束你写在 @api.constrains 里,感觉“第一次 write 时字段明明有值,却像没准备好”:
- 不是约束失效
- 而是你依赖的那个值,也许属于 inverse 阶段才真正稳定的那部分
三、create() 也不是单段完成,而是 stored / inversed 分层进入
create() 的链路同样不是“组装 vals → insert → 最后一次性校验”。
源码里关键步骤是:
_prepare_create_values(...)补默认值、清理非法字段、处理 precompute- 按 stored / inversed / inherited / protected 分类字段
- 如果有
_inherits,先准备或创建父记录 - 进入
_create(data_list),先落 stored 字段 - 对需要 inverse 的字段执行
determine_inverse(...) - 对每条记录执行:
data['record']._validate_fields(data['inversed'], data['stored'])
更关键的是,在 _create() 内部,Odoo 还会对 stored 字段完成一轮:
records.modified(self._fields, create=True)- 必要时处理其他字段创建
- 然后:
records._validate_fields(name for data in data_list for name in data['stored'])
也就是说,create() 至少有两层校验语义:
1)stored 字段落库后的校验
这是“主记录已经建出来,stored 数据已经有了”的那一层。
2)inversed 字段完成反写后的校验
这是“有些值不是 insert 那一刻就最终稳定,而要靠 inverse 补全”的那一层。
所以 create 阶段写约束时,最容易错判的不是“它会不会触发”,而是“它触发时你依赖的字段是不是已经在正确阶段里完成了”。
四、stored compute 也会在计算完成后再补一次约束
很多人会漏掉第三个入口:stored compute 字段重算完成后也可能触发 _validate_fields()。
在 _compute_field_value() 里,源码明确写了:
- 如果这是 stored compute 字段
- 并且当前记录是真实数据库记录
- 那么会对该计算字段所在的计算组字段再跑一轮
_validate_fields()
这意味着什么?
意味着你的约束不只可能在 create / write 主流程里触发,还可能在:
- 依赖字段改动
- 框架安排 stored compute 重算
- 重算结果回写后
再被补跑一次。
这也是一些约束“看起来不是我直接写那个字段,却也被触发”的根本原因。
五、为什么这会直接影响你该把校验写在哪
1)如果你需要数据库永远不允许脏数据进入
优先考虑 SQL constraints。
因为 Python constraints 的触发点再多,本质仍然是 ORM 生命周期中的应用层校验。
2)如果你需要依赖 ORM 语义、跨字段逻辑、业务可读报错
用 @api.constrains,但要想清它依赖的字段是在:
- stored 阶段就齐了
- inverse 后才齐
- 还是 compute 后才齐
3)如果你的逻辑本质上是“写入前拒绝某种输入格式”
有时更合适的是:
- 字段 converter
create()/write()显式前置判断- inverse 方法内部自校验
而不是把所有东西都塞进 @api.constrains。
六、一个实战判断法:先问“最终值什么时候稳定”
写约束前,先问自己这三个问题:
- 这个字段在
vals里直接就有最终值吗? - 还是要等 inverse 之后才稳定?
- 还是它来自 stored compute,需要重算后才完整?
如果第三个问题才是答案,你把约束想成“create / write 当场立即判断”往往就会出错。
结语
_validate_fields() 最值得理解的不是函数体本身,而是它在 Odoo 里被安插的位置。
从源码看,它至少服务于三类窗口:
- stored 写入后的约束检查
- inverse 完成后的约束检查
- stored compute 回写后的约束检查
所以约束设计的核心从来不是“会不会触发”,而是:
当它触发时,你依赖的业务状态是否已经真的稳定。
把这个时间轴想清楚,Odoo 的约束设计就会稳很多。
DISCUSSION
评论区