fix(objectql,driver-mongodb)!: findOne must say which record it wants, and executes every option it declares (#4419) - #4459
Merged
Conversation
…, and executes every option it declares (#4419) findOne reads a single row, which makes its predicate the only thing between the caller and an arbitrary record. When the predicate is missing the result is not `null` — it is the object's FIRST ROW: a real, plausible-looking record with nothing to do with the request, which the `if (!row)` check every call site already has cannot catch, and which then propagates into whatever is computed next. A throw would have been caught in development; a `null` would have been caught by the null-check. A valid-looking wrong record defeats both. #4419 reported this against the `filter` key, which #4346 (fold on every entry point) and #4400 (unknown keys throw) already closed. This is what those left standing. BREAKING: findOne refuses a query that selects nothing in particular. findOne(o) / findOne(o, {}) / findOne(o, { where: {} }) -> findOne(o, { where: … }) the record matching this -> findOne(o, { search: 'Acme' }) the record this search finds -> findOne(o, { orderBy: [{ field, order }] }) the FIRST in this order -> find(o, { limit: 1 }) any row will do, said aloud The error names all four. `find` and `count` are unchanged — returning or counting every row is an honest answer; only findOne's implicit "just one of them" turns a missing predicate into a confidently wrong record. The guard reads the CALLER's predicate, before RLS/sharing middleware injects its own: a tenant filter narrows which rows are visible, it does not make "whichever comes first" something the caller asked for. Two silent drops that produced the same wrong record are fixed with it: - findOne({ search }) now applies the search. The ADR-0061 `search` -> cross-field $contains expansion lived inline in find() and nowhere else, while find and findOne are checked against the SAME legal-key set — so `search` passed the gate, rode onto the AST, and reached a driver. No driver reads ast.search. The read ran with no predicate at all and limit: 1 did the rest. The expansion is now one method both call. - MongoDBDriver.findOne now applies orderBy, fields and offset. It translated query.where and dropped the rest, so findOne({ orderBy }) did not return the newest record — it returned whichever document the scan reached first. This one matters beyond Mongo: the guard above tells an unpredicated caller to reach for orderBy, and an escape hatch one backend ignores is not an escape hatch. No ordering is IMPOSED when the caller supplies none — both drivers keep that carve-out (#4363), and SqlDriver's comment citing Mongo as already in agreement is corrected, since it cited the dropped parameter as agreement. And a gate so the class does not come back: a drift pin walks ENGINE_OPTION_KEY_SETS.findOne and requires each declared key to have an observable effect — on the AST the driver receives, on the driver options, or in an explicit "not executed, and here is why" entry (only `limit`, which the contract's limit: 1 overrides). `search` sat declared-but-unexecuted through two rounds of hardening because nothing asked that question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy3zSSDGcJybydLSwGypFS
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 4 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…se table
CI caught three failures in the end-to-end Mongo suite this PR added — all in
the test, none in the product. The four behavioural assertions (orderBy asc /
desc, fields, offset) passed against a real mongod, so the driver fix itself is
confirmed; the three spy-based ones could never have worked:
const collection = (driver as any).getCollection('account');
vi.spyOn(collection, 'findOne'); // -> "called 0 times"
`getCollection` is `this.db.collection(name)`, which builds a FRESH Collection
per call, so the spy never saw the one the driver used. The server-free suite
passed only because it replaces `db` with a fake whose `collection()` returns
the same object.
They are replaced rather than repaired, because the local environment cannot
download a mongod and the first version shipped expectations nothing had run.
The cases now live in one table, `mongodb-findone-cases.ts`, read by both
halves:
- `mongodb-findone-options.test.ts` (no server, always runs) asserts the
`FindOptions` the driver emits — including the cases whose expectation is
that NO sort was imposed (#4363), which is not observable in rows — and then
executes the same cases against a controlled in-process collection so the
expected ids are falsifiable without a binary download. That second pass is
deliberately narrow: it checks the case table, never whether the driver is
correct.
- `mongodb-findone-query.test.ts` runs the same table against a real mongod and
asserts rows only, answering the one question the other cannot.
`b` and `d` now tie on `rank`, which is the only place the appended `id`
tie-breaker is observable in the rows at all — so the sort spec is proven end
to end without a spy.
Reverse-verified both ways locally: a wrong expected id in the table fails the
options suite (1 red), and reverting the driver fix fails it 12 red.
Also updates the `findOne` section of the data-engine contract doc, which still
described it as returning "the first record matching a query" — the reading
that makes an empty query look legal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uy3zSSDGcJybydLSwGypFS
This was referenced Aug 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4419.
先说结论:issue 的四条建议,三条已经在
main上了对照 issue 观察的
16.1.0dist,当前是17.0.0-rc.1:rejectUnknownEngineOptionsfilter→where统一折叠foldEngineOptionAliases,六个入口同一张槽表createContext文档注释where,skills/与content/docs/同步findOnefail-safe核对时又挖出两处仍然活着的同类缺陷——都不是
filter这个键。三者合起来才是 #4419 的完整收口。1.
findOne空谓词守卫(破坏性,issue 建议 3)findOne只读一行,所以谓词是调用方和任意一条记录之间唯一的东西。谓词缺失时结果不是null,而是对象的第一行:一条真实、看起来合理、和请求毫无关系的记录,调用方现成的
if (!row)抓不住它,它接着流进后面所有计算。issue 报告的下游后果——行项目从目录第一个产品取单价、
"这单是不是已经关闭了?"对着一条无关记录求值而随后的写入却正确指向了目标 id——都源于这一条。
抛异常会在开发期被抓住;
null会被空值检查抓住。一条看起来有效的错记录同时绕过了两者。FROM → TO:
findOne(o)/findOne(o, {})/findOne(o, { where: {} })findOne(o, { where: … })findOne(o, { search: 'Acme' })findOne(o, { orderBy: [{ field, order }] })find(o, { limit: 1 })错误文案把四个出口都列出来。
find/count不动:返回或统计全表是诚实的答案,只有
findOne隐含的"随便给一条"会把缺失的谓词变成一条自信的错记录。守卫读的是调用方自己的谓词,跑在 RLS / sharing 中间件注入之前:租户过滤器缩小的是可见行,
它不会把"随便哪一行排在前面"变成调用方要的东西。判定口径与 #3896(空 criteria 分享不了任何记录)
一致:缺失、
null、{}。非普通对象的where(表达式树)交给 driver——守卫只关掉"确定匹配全表"这一个洞,不关它证明不了的东西。
2.
findOne静默丢search这是 issue 标题本身,只是键名不同。ADR-0061 的
search→ 跨字段$contains展开只写在find()里,而
find和findOne被同一张合法键表校验——所以search在门口合法通过、原样挂上 AST、到达 driver。没有任何 driver 读
ast.search(展开是引擎的职责)。于是查询在完全无谓词下执行,limit: 1完成剩下的事:findOne({ search: 'Acme' })此前返回表里第一行。展开提成
expandSearchOnAst,find和findOne各调一次。3.
MongoDBDriver.findOne丢orderBy/fields/offset它只翻译
query.where,其余全丢——所以findOne({ orderBy })返回的不是最新的记录,而是集合扫描先碰到的那份文档。同一文件里的
find和_findStream一直都处理这三个。这条必须和 (1) 一起修:守卫让无谓词的调用去用
orderBy,一个出口如果在某个 driver 上被丢,它就不是出口。调用方没给排序时仍然不强加排序(#4363 的规则,两侧一致);
sql-driver里那条把 Mongo 当作"已经一致"的注释也改了——它引用的正是被丢的那个参数。
4. 契约漂移闸门(防复发)
遍历
ENGINE_OPTION_KEY_SETS.findOne,要求每个声明的键都有可观测效果:落到 driver 收到的 AST 上、落到 driver options 上,或写进一张显式的"不执行 + 原因"表(目前只有
limit,被契约上的limit: 1覆盖)。search熬过了 #4346 和 #4400 两轮加固还活着,就是因为从来没人问过这个问题。验证
反向验证(这是关键,不是走过场):临时撤掉引擎改动 → 新测试 25 个中红 18 个(含漂移 pin);
撤掉 Mongo 改动 → 9 个中红 4 个。
objectql 1531 测试全绿;全仓
pnpm test132/132 turbo task 绿。objectql / driver-mongodb / driver-sql typecheck 干净;eslint 无输出。
packages/spec8 个生成物闸门全过(本 PR 只加了contracts/data-engine.ts的 TSDoc,不影响生成物)。issue 原始 repro 对着构建产物跑通(
persistence: false,issue 里强调过这点):packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts(真实 mongod 的端到端套件)在我的环境里跳过了——拉不到 mongod 二进制,和仓库里既有的 3 个 Mongo 套件行为一致。不要当作通过。
所以我另写了
mongodb-findone-options.test.ts:不依赖服务器,直接断言交给 Mongo 的FindOptions。这样做不是退让——bug 恰恰在那个 options 对象里才可读:一个三文档的集合按插入序稳定返回,
行级断言对一个完全不排序的 driver 也会通过。这一套跑了并且通过了;端到端那套等 CI 上 mongod 可达时覆盖。
变更文件
packages/objectql/src/engine.tsexpandSearchOnAst提取 +requireFindOnePredicate+findOneTSDocpackages/plugins/driver-mongodb/src/mongodb-driver.tsbuildFindOptions提取;buildSortSpec加singleRowLookuppackages/plugins/driver-sql/src/sql-driver.tspackages/spec/src/contracts/data-engine.tsIDataEngine.findOne契约 TSDocpackages/objectql/src/engine-findone-contract.test.tspackages/plugins/driver-mongodb/src/mongodb-findone-{options,query}.test.tspackages/objectql/src/engine.test.tswhere(守卫在仓内的全部影响面)content/docs/releases/v17.mdx、skills/objectstack-data/references/data-hooks.md、.changeset/Generated by Claude Code