Skip to content

fix(driver-sql,driver-memory,formula)!: { field: {} } 四个后端一律拒收 —— 零个操作符的字段约束不再有三个答案 (#5240) - #5327

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5240-empty-field-spec-reject
Aug 4, 2026
Merged

fix(driver-sql,driver-memory,formula)!: { field: {} } 四个后端一律拒收 —— 零个操作符的字段约束不再有三个答案 (#5240)#5327
os-zhuang merged 2 commits into
mainfrom
claude/issue-5240-empty-field-spec-reject

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5240

按维护者拍板取拒收(不重新论证 TRUE / FALSE)。四个后端对 { field: {} } 一律抛
INVALID_FILTER / 400,消息里指名出事位置(如 filter.$or[0].stage)。


1. 现场核对(STALE-PREMISE 自检)

worktree 基于 origin/main = 26e1029f5,已含 5aae79096(PR #5296 / #5146)。
issue 正文那张四路径对照表逐条实测结果:

路径 issue 的说法 实测(改前) 结论
driver-sql 顶层 plain map INVALID_FILTER(#5041) Operator "=" on field "stage" requires a single comparable value… 成立
driver-sql 组合子内 不产出 SQL → TRUE {$not:{stage:{}}} → 返回全部 4 行;{$or:[{stage:{}},{owner:'u2'}]}['2'](子句被丢) 成立
formula keys.length === 0 显式 FALSE ✅ 成立 成立
driver-memory checkCondition 落到 JSON.stringify → FALSE ⚠️ 半对 已修正,见下

修正一条:driver-memory 那行指的是 memory-matcher.ts,而它不是驱动的实时路径。
InMemoryDriver.find 只从该文件 import 了 getValueByPath,过滤实际由
convertToMongoQuery()mingo 完成。实测 mingo 把 { a: {} } 读作
a 深等于空文档」——我特意播了一行 a: {} 的数据,它被选中了。也就是说这条路径
给的不是 FALSE,而是另一个 filter,只是在正常数据上碰巧看起来像 FALSE。

因此 driver-memory 的两个过滤面(实时 mingo 路径 + 跨后端一致性套件所用的参考匹配器)
补了闸门 —— 只改派发单点名的 memory-matcher.ts 的话,用户真正走的那条路径
仍然静默,拍板要的「四后端一律拒收」不成立。

同日 churn 的两处重核(#5243 / #5296)

2. 闸门的位置(与派发单的一处偏差,已如实记录)

派发单建议「接在归约之后的编译分支里」。实作把它接在归约的校验遍历里
(reduceFilterKey 的字段分支,与 assertFilterNode / assertFilterNodeList 并列),
理由是编译分支会漏:

{ $or: [ { a: {} }, {} ] }

{} 是 TRUE 析取项(#5134 的单位元),整个 $or 归约成 'true',
applyFilterConditionverdict === 'true'直接 return,编译分支永远见不到
{ a: {} } —— 拒不拒收就取决于它的兄弟节点。这正是 reduceFilterNode 自己的注释
警告过的「gate conditional on evaluation order」。归约遍历是穷尽且不短路的,所以闸门放在
那里。归约的判定一行未改,只是多了一处 refusal;这条已在测试里具名钉住
(beside a sibling that would settle the node first)。

同样的理由,两个 JS 后端也改成「先走一遍整棵树校验,再求值」:它们的求值器会短路
(every/some,且节点遇到第一个 false 就 return),闸门若放在求值里,
同一条策略会因为被测记录的不同而时而拒收时而不拒收。求值逻辑本身逐字未动。

3. 四个后端各自的改法

改动 位置
driver-sql emptyFieldConstraintError + isEmptyFieldConstraint;接在 reduceFilterKey(组合子/整树)与 applyFilters 的 plain-map 循环(顶层)。顶层原本报 #5041 的通用「cannot be bound as a SQL parameter」,现在与组合子内同一条消息 —— 一个条件一种措辞。nullGuardForFieldSpec 的空 spec 分支删除。 sql-driver.ts
driver-memory 新增 filter-refusal.ts(把既有的 unsupportedFilterError 收进来,避免同包两份信封),两个过滤面共用:normalizeFilterCondition(实时 mingo 路径,mingo 之前)与 memory-matcher.match(参考匹配器,求值之前) memory-driver.ts / memory-matcher.ts / filter-refusal.ts
formula assertFilterShapematchesFilterCondition 入口走一遍整树;evalFieldkeys.length === 0 保留为兜底(函数要保持全域),但不再是本后端对该形状的答案 matches-filter.ts
driver-sqlite-wasm 无源码改动(SqliteWasmDriver extends SqlDriver),但不假设「继承了就没问题」 —— 单独一份 pin 套件验证 refusal 穿过它自定义的 sql.js 方言后 code/status 仍完好 仅测试

四家同一个 INVALID_FILTER / 400。

4. ⚠️ 连带:RLS check 的可观察行为变更

formulamatchesFilterConditionplugin-security 对 insert/update 后像执行
行级 check 的路径(security-plugin.ts:1538)。改为抛出后落在 #4775
「求不出值 = 该次操作失败」的既定姿态上。这不只是「拒绝得更响」,有一类结果直接翻转:

check 策略 改前 改后
{ a: {} } FALSE → 写入被拒(403 PermissionDenied) 抛出 → 该次写入失败(400 INVALID_FILTER)
{ $or: [ { a: {} }, { owner: '{userId}' } ] } FALSE 被另一析取项吸收 → 写入放行 抛出 → 该次写入失败
{ $not: { a: {} } } !false → 写入放行 抛出 → 该次写入失败

后两行是原本能成功、现在会失败的写入。 这是拍板的目的而非副作用,changeset 与
上面的表都如实写了,没有轻描淡写。三条都有具名测试钉住。

同一路径的另一个消费者 explain-engine.ts:523(/explain 的记录级归因)也会因此
抛出而不是给出裁决 —— 一条坏策略在诊断面上同样响亮失败。这与拍板方向一致,故未加
try/catch 吞掉;若维护者希望 explain 降级为「策略不可求值」的裁决而非报错,请示下,
我另开一单(未擅自扩范围到 plugin-security)。

5. 测试

套件 结果
@objectstack/driver-sql 786 passed, 44 skipped(65 files)—— 含新增 sql-driver-empty-field-constraint.test.ts 21 条
@objectstack/driver-memory 320 passed(12 files)—— 含新增 17 条(实时路径 + 参考匹配器各一组)
@objectstack/formula 357 passed(16 files)—— 含新增 17 条
@objectstack/driver-sqlite-wasm 237 passed(17 files)—— 含新增 5 条
@objectstack/objectql 1882 passed(117 files)
@objectstack/plugin-security 731 passed(34 files)
@objectstack/service-analytics 555 passed(42 files)
@objectstack/service-storage 283 passed(21 files)
@objectstack/plugin-sharing 347 passed(13 files)
typecheck(四包) 9 tasks successful
eslint --no-inline-config(全部改动文件) exit 0

非空形状逐字符不变 —— 具名断言了几条普通 filter 的 SQL 文本:

{ stage: 'won' }                       → select `id` from `deal` where `stage` = 'won'
{ amount: { $gt: 15 } }                → select `id` from `deal` where `amount` > 15
{ $or: [{stage:'won'},{owner:'u2'}] }  → ... where ((`stage` = 'won') or (`owner` = 'u2'))
{ $not: { stage: 'won' } }             → ... where not (((`stage` is not null) and (`stage` = 'won')))
{ stage: { $in: ['won','open'] } }     → ... where `stage` in ('won', 'open')

以及 #5134 的布尔单位元原样保留({} 这个空节点{ field: {} } 是两个形状)。

反向验证(把改动 stash 掉,新用例必须失败)

########## driver-sql ##########
 × top level → 400 INVALID_FILTER naming filter.stage
   AssertionError: expected 'Operator "=" on field "stage" require…' to contain 'filter.stage'
 × inside $or → 400 INVALID_FILTER naming filter.$or[0].stage
   Error: expected the driver to refuse this filter, but it resolved
 × wrapping the refused shape in a combinator no longer turns it into match-all
   AssertionError: promise resolved "[ '2' ]" instead of rejecting
 × and $not does not swallow it into the #5146 NULL-safe rewrite either
   AssertionError: promise resolved "[ '1', '2', '3' ]" instead of rejecting
      Tests  11 failed | 10 passed (21)

########## driver-sql · sql-driver-not-null-safe(#5146 的用例改到新事实)##########
 × a field constrained by zero operators is REFUSED, not rewritten (#5240)
   AssertionError: promise resolved "[ '1', '2', '3', '4' ]" instead of rejecting
      Tests  1 failed | 24 passed (25)

########## driver-memory ##########
 × top level → 400 INVALID_FILTER naming filter.stage
   AssertionError: expected undefined to be 'INVALID_FILTER'
 × the refusal replaces a filter that was silently something ELSE
   AssertionError: promise resolved "[]" instead of rejecting
 × the refusal does not depend on the RECORD being tested
   AssertionError: expected [Function] to throw an error
      Tests  12 failed | 5 passed (17)

########## formula ##########
 × a check that used to ALLOW (the false disjunct was absorbed) now fails
   AssertionError: expected [Function] to throw an error
      Tests  9 failed | 8 passed (17)

########## driver-sqlite-wasm ##########
      Tests  4 failed | 1 passed (5)

注意第一轮 stash 时 wasm 套件全绿 —— 因为它消费的是 @objectstack/driver-sql
构建产物,stash 源码碰不到它。于是用 stash 后的源码重新 build 了一次 driver-sql
再跑,才得到上面的 4 failed。这条记在这里,免得下一位据此误判「继承的那个后端不需要验」。

那两行 10 passed / 8 passed 也是证据的一部分:改前就通过的正是「非空形状逐字符不变」
那一组,说明闸门没有误伤。

6. 未收窄的契约(状态如实声明)

本 PR 让实现比已声明契约更严。 packages/spec 一行未改:FilterConditionSchema
的非递归半边今天仍是 z.record(z.string(), z.unknown()),即 { field: {} } 在 spec 层
依旧声明合法。收窄 schema 与把该 case 补进 FILTER_LOGIC_CASES 归 spec 车道
(建议与 #5239#5146 的 spec 半边同批)。测试里对这些形状的 as cast 都带注释指明了
这一状态,不是在假装契约已经收了。

7. 范围外

新开 issue #5324(实测本单前提时发现,unassigned,未在本 PR 修):
driver-memory 的实时查询路径根本不支持 $not —— normalizeFilterCondition 原样透传给
mingo,而 MongoDB 没有文档级 $not,于是 $not任何位置都抛无 code / 无 status
MingoError(500 形状,逃出 #4436 建立的信封)。CEL !expr 降下来的 RLS scope 在该
驱动上因此直接报错。至今没被测出来的原因:FILTER_LOGIC_CASES 对 driver-memory 只经由
参考匹配器跑,而 driver-sql / sqlite-wasm / mongodb 三家都是穿过真驱动跑的。
本 PR 的 driver-memory 套件里有一条用例把这个现状钉住(并注明由 #5324 负责改变它),
以免默默断言一个并不存在的行为。


Generated by Claude Code

… all four backends (#5240)

A field constrained by ZERO operators is a shape `FilterConditionSchema` still
declares legal, and one filter carrying it had three answers in this repo:

- driver-sql refused it at the top level (the #5041 comparand gate) but DROPPED
  it inside `$and`/`$or`/`$not`, where a predicate that emits nothing means
  "matches every row" — so `{ $or: [{ a: {} }, { b: 2 } ] }` compiled to
  `(b = 2)` by losing a clause, and the same `{ a: {} }` was a 400 at the top
  level and a silent match-all one combinator deep;
- driver-memory answered "matches nothing" incidentally, and did so through TWO
  independent paths that had never been compared: the live query path (mingo
  reads `{ a: {} }` as "deep-equals the empty document") and the reference
  matcher (`JSON.stringify` structural equality);
- formula answered `false` from an explicit fail-closed arm.

Ruled on #5240: refuse it everywhere, with one `INVALID_FILTER` / 400 envelope
and a message naming the position (`filter.$or[0].stage`). The shape is almost
always an authoring accident — a filter builder that recorded a field and never
its operator — and both silent readings answer it with a row count the author
never asked for.

The gate sits on the #5134 validation walk, beside `assertFilterNode`, not in
the emitter: the walk is exhaustive, while the emitter returns early whenever an
identity settles a node, so an emitter-side gate would let
`{ $or: [{ a: {} }, {} ] }` through and make the refusal depend on the shape's
siblings. The reduction's VERDICT is unchanged (a field key still contributes
`'clause'`), so every filter that compiled before compiles byte-identically.

`nullGuardForFieldSpec`'s `entries.length === 0` escape — added by #5146 so the
NULL-safe rewrite would not rule on #5240 from there — is removed with the
ambiguity it protected: the refusal now fires before that rewrite runs.

BREAKING: `matchesFilterCondition` is the RLS `check` evaluation path, so a
`check` policy carrying `{ field: {} }` now fails the operation (#4775 posture)
instead of evaluating to `false`. Where such a constraint sat under an `$or`
beside a satisfied branch, or under a `$not`, the old `false` was absorbed and
the write was ALLOWED; those writes now fail.

Implementation is stricter than the declared contract: narrowing
`FilterConditionSchema` and adding the case to `FILTER_LOGIC_CASES` is the spec
lane's half of #5240.

Fixes #5240
@vercel

vercel Bot commented Aug 4, 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 4, 2026 8:50pm

Request Review

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

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/formula, @objectstack/driver-memory, @objectstack/driver-sql.

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

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula)
  • content/docs/deployment/vercel.mdx (via @objectstack/driver-memory)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/permissions/authentication.mdx (via @objectstack/driver-memory)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/index.mdx (via @objectstack/driver-memory)
  • content/docs/plugins/packages.mdx (via @objectstack/formula, @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/releases/v15.mdx (via @objectstack/formula)
  • content/docs/releases/v16.mdx (via @objectstack/formula)

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.

Copy link
Copy Markdown
Contributor Author

PM 复核(会话 session_01Pbu27iNUfQCHeuS551Rqo7):接受,并对开放问题裁 A(否决窗口开放)

开放问题:formula 改抛出后,/explain 的记录级归因也会抛

取 A(维持现状,plugin-security 一行不改),理由不是「与拒收方向一致」这句口号,而是两点具体的:

  1. 抛出没有丢失信息**。异常指名了出事位置(filter.$or[0].stage),操作者从诊断面得到的仍然是「哪条策略的哪个键坏了」—— 与 /explain 本来要给的答案是同一件事,只是以异常而非裁决的形式呈现。B 要解决的是呈现形式,不是信息缺失。
  2. B 会在诊断路径上重新引入一处消费端容忍 —— 让 /explain 把「这条策略根本不可求值」降级成一种裁决展示,等于在唯一一个专门用来看清策略的地方,把坏策略渲染成一种正常状态。这正是本单要消灭的形状,换了个位置。

但 B 的动机是真的:一个诊断工具在它要诊断的东西坏掉时自己也坏掉,这值得单独判 —— 那是关于 /explain 契约的产品问题(它应该「求值策略」还是「报告策略的可求值性」),不该由一个 filter 闸门的 PR 顺手定。另开单,不阻塞本 PR。

否决窗口:维护者若认为 /explain 必须始终返回裁决,回一句我即刻转 B 并另派。

复核确认(对 GitHub 实况与本地 diff,非照抄报告)

范围状态

packages/spec/** 未动 —— 实现现在比已声明契约更严,契约收窄(FilterConditionSchemaz.record 半边 + FILTER_LOGIC_CASES)归 spec 车道,建议与 #5239#5146 spec 半边同批。PR 正文已如实写明这一状态,没有假装契约已收。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 21:05
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 0f17114 Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5240-empty-field-spec-reject branch August 4, 2026 21:18
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
Second relay merge: #5300 / #5304 / #5306 / #5308 / #5318 / #5326 / #5327.
Textually clean, but the os-regen driver defers generated artifacts rather than
text-merging them, so `json-schema.manifest.json` again came out holding this
branch's pre-merge side — this time still listing `ui/EmbedConfig` and
`ui/NotificationAction`, both retired by #5300. Reset the deferred artifacts to
`origin/main`, rebuilt from the merged tree, regenerated wholesale.

Post-regen assertions (a silent one-side drop is exactly what this catches):
api-surface delta vs `origin/main` is exactly this PR's four additions and ZERO
removals; manifest delta is one addition (`ui/ViewItemWire`) and zero removals;
every sibling retirement stays removed (`ui/EmbedConfig`, `ui/NotificationAction`,
`system/HttpServerConfig`, `ui/Animation`, `ui/ZIndex`) and every sibling
addition stays present (`FilterArray` ×7, `EmailProvider` ×2).
`check:authorable-surface` (+ its #5304 `.base.json` anchor) is green and the
anchor file is byte-identical to `origin/main` — not hand-edited.

`metadata-form-zod-reconciliation.test.ts` co-edited with #5280/#5318 and merged
SEMANTICALLY, not by taking a side: #5318 rewrote the docblock, imports, helpers
and test bodies, while this PR's only edit is `unwrap`'s `pipe` case, so the two
did not overlap textually — but they do interact, and in the direction that
matters. #5318's `isRetiredAt` / `authorableKeysOf` both route through
`unwrap`/`keysOf`, and `view`'s root is now a `z.preprocess` pipe. Measured both
ways: without this PR's #4488-style fix `unwrap(view root)` resolves to
`transform` and `keysOf` returns NULL, so #5318's brand-new tombstone assertions
would be VACUOUS on `view` (and the pre-existing key-bearing assertion would
fail outright); with it, 89 keys. Both PRs' assertions are live on every type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
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

2 participants