很多 Odoo 开发者会把下面这句代码读成:
partner = self.env['res.partner'].browse(42)
去数据库里查 id=42 的 partner。
但这其实不是 browse() 的真实语义。
从 /home/ubuntu/odoo-temp/odoo/orm/models.py 里的实现看,browse() 做的事情非常克制:
- 把传入的 id 规范成 tuple
- 构造一个当前模型、当前环境下的 recordset
- 直接返回
它并没有在 browse() 这一刻就验证数据库里是否真有这条记录。
这就是很多“看起来诡异”的 ORM 行为的根源。
一、browse 的本质:包装 id,不是立即验真
源码里的 browse() 非常短,几乎没有额外逻辑。它接受:
- 单个 id
- 一组 id
- 空值
然后返回一个 recordset。
这说明 browse() 的职责更像:
在当前 env 中声明“我现在要以这些 id 作为一组记录来继续操作”。
而不是:
立刻帮我确认这些记录是否都存在。
所以你可以 browse(999999),代码本身并不会因为数据库里没有这个 id 就立刻报错。
二、为什么会出现“幽灵 recordset”
所谓“幽灵 recordset”,就是:
- Python 层已经有一个 recordset 对象
- 但它指向的某些 id 在数据库里并不存在,或者已经被删了
这不是 bug,而是 ORM 语义的一部分。
最典型的几种情况:
1)手工 browse 一个不存在的 id
rec = self.env['x.model'].browse(999999)
rec 这个对象是存在的,但底层记录不一定存在。
2)你先拿到记录,后来它被删了
比如:
- 你先保存了
rec - 另一个链路删掉了对应行
- 你手里的 recordset 还在
这时你再继续操作它,很多行为会和“真实存在记录”不一样。
3)new 记录和 NewId
在 create 前、onchange 中,Odoo 还会构造带 NewId 的临时记录。这些对象也不是数据库里的真实行,但在 ORM 里又必须被当成“当前逻辑中的记录”处理。
三、exists() 才是“这批记录现在还真在吗”的过滤器
在 models.py 里,exists() 做的事情才接近很多人对 browse 的想象。
它会:
- 把
self._ids里的NewId和真实 id 分开 - 对真实 id 生成 SQL 查询,检查哪些 id 仍然存在
- 把“确实存在的真实 id”与
NewId合并 - 返回存在子集组成的新 recordset
这里有两点非常关键。
1)exists() 会保留 NewId
源码注释明确说:
By convention, new records are returned as existing.
也就是说,ORM 认为:
- 数据库里还没插入的新记录
- 在当前逻辑上下文里依然算“存在”
这不是数据库意义上的存在,而是 ORM 生命周期意义上的存在。
2)exists() 返回的是子集 recordset,不是布尔值
很多人会写:
if rec.exists():
...
这当然可以,因为空 recordset 在布尔上下文里会是假。
但更准确地理解应该是:
exists()不是在回答 yes/no- 它是在把当前 recordset 过滤成“仍可继续操作的那部分”
这就是为什么它尤其适合批量 recordset,而不只是单条记录判断。
四、为什么这件事对开发很重要
场景 1:不要把 browse 当成权限和存在性校验
如果外部传来一个 id,你直接:
record = self.env['x.model'].browse(record_id)
这不等于你已经安全拿到了真实对象。
你可能还需要进一步:
record.exists()- 权限检查
- 业务状态检查
否则你只是在拿一个“声明式引用”。
场景 2:删除后旧 recordset 不是自动消失
很多人会下意识觉得,记录删掉后,之前的 Python 对象就应该“失效”。
但 ORM recordset 不是实时代理,它只是包了一组 id 和 env。删库不会自动让你手里的变量变成 None。
所以删除后继续复用旧 recordset 时,要有“这可能已经是幽灵引用”的意识。
场景 3:批量处理时,exists() 可以顺手清洗集合
例如:
records = (a + b + c).exists()
这类写法的价值,不只是“判断存在”,而是把后续流程统一建立在“这批记录确实还活着”之上。
五、最常见的误解
误解 1:browse(id) 等于 search([('id', '=', id)])
不等于。
search() 会真正查数据库并按 domain 过滤;browse() 只是把 id 包装成 recordset。
误解 2:if record: 就说明数据库里有这条记录
也不一定。
recordset 的真假和它是否为空有关,而不是数据库里对应行此刻一定存在。
误解 3:删除后旧对象就自然无效了
也不对。旧 recordset 变量还在,只是它指向的 id 可能已经不存在了。
六、一个更稳的心智模型
理解 browse(),最好换成这样一句话:
browse()是“按 id 建立 ORM 引用”,不是“按 id 做存在性查询”。
理解 exists(),最好换成:
exists()是“把当前引用集合收缩成仍然有效的子集”。
有了这个区分,很多 API 行为就容易理解了:
- 为什么
browse()很轻 - 为什么不存在 id 也能先得到 recordset
- 为什么
exists()对批量集合很重要 - 为什么 NewId 会被当成存在
结语
在 Odoo ORM 里:
browse()负责构造引用exists()负责过滤现实
把这两者混成一个概念,后面就容易在删除、onchange、批量处理、外部传参这些场景里踩坑。
所以更准确的说法不是“browse 查记录”,而是:
browse 先给你一个 recordset 壳子;至于这壳子里包的 id 现在是否真存在,要看
exists()这一层。
DISCUSSION
评论区