Skip to content

fix(objectql,driver-mongodb)!: findOne must say which record it wants, and executes every option it declares (#4419) - #4459

Merged
os-zhuang merged 4 commits into
mainfrom
claude/objectql-predicate-keys-0ne06r
Aug 1, 2026
Merged

fix(objectql,driver-mongodb)!: findOne must say which record it wants, and executes every option it declares (#4419)#4459
os-zhuang merged 4 commits into
mainfrom
claude/objectql-predicate-keys-0ne06r

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4419.

先说结论:issue 的四条建议,三条已经在 main 上了

对照 issue 观察的 16.1.0 dist,当前是 17.0.0-rc.1

issue 建议 现状
(1) 拒绝未知查询键 #4400 rejectUnknownEngineOptions
(2) filterwhere 统一折叠 #4346 foldEngineOptionAliases,六个入口同一张槽表
(4) 修正 createContext 文档注释 ✅ 已是 whereskills/content/docs/ 同步
(3) 让 findOne fail-safe ❌ 本 PR

核对时又挖出两处仍然活着的同类缺陷——都不是 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() 里,
findfindOne 被同一张合法键表校验——所以 search 在门口合法通过、原样挂上 AST、到达 driver。
没有任何 driver 读 ast.search(展开是引擎的职责)。于是查询在完全无谓词下执行,limit: 1 完成剩下的事:
findOne({ search: 'Acme' }) 此前返回表里第一行。

展开提成 expandSearchOnAstfindfindOne 各调一次。

3. MongoDBDriver.findOneorderBy / 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 test 132/132 turbo task 绿。

  • objectql / driver-mongodb / driver-sql typecheck 干净;eslint 无输出。

  • packages/spec 8 个生成物闸门全过(本 PR 只加了 contracts/data-engine.ts 的 TSDoc,不影响生成物)。

  • issue 原始 repro 对着构建产物跑通persistence: false,issue 里强调过这点):

    findOne({where:{id:two.id}})    -> Two
    findOne({filter:{id:two.id}})   -> Two          (#4346)
    count({filter:{rev:100}})       -> 1            (#4346)
    findOne({search:'Two'})         -> Two          (本 PR,此前是 One)
    findOne({})                     -> THROWS       (本 PR,此前是 One)
    findOne({orderBy:[name desc]})  -> Two
    find({limit:1})                 -> [One]
    

⚠️ 一处必须说明的验证缺口

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.ts expandSearchOnAst 提取 + requireFindOnePredicate + findOne TSDoc
packages/plugins/driver-mongodb/src/mongodb-driver.ts buildFindOptions 提取;buildSortSpecsingleRowLookup
packages/plugins/driver-sql/src/sql-driver.ts 仅修正那条已失真的 Mongo 对照注释
packages/spec/src/contracts/data-engine.ts IDataEngine.findOne 契约 TSDoc
packages/objectql/src/engine-findone-contract.test.ts 新建:守卫 + search + 漂移 pin(25 例)
packages/plugins/driver-mongodb/src/mongodb-findone-{options,query}.test.ts 新建:无服务器断言 + 端到端
packages/objectql/src/engine.test.ts 两处补 where(守卫在仓内的全部影响面)
content/docs/releases/v17.mdxskills/objectstack-data/references/data-hooks.md.changeset/ 文档与破坏性变更集(含 FROM → TO)

Generated by Claude Code

…, 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
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 1, 2026 9:02am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/objectql, @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec.

112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-mongodb, @objectstack/driver-sql)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

claude added 3 commits August 1, 2026 08:23
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ObjectQL silently drops unsupported predicate keys; findOne then returns the first row

2 participants