fix(driver-sql,driver-memory,formula)!: { field: {} } 四个后端一律拒收 —— 零个操作符的字段约束不再有三个答案 (#5240) - #5327
Conversation
… 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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…ty-field-spec-reject
PM 复核(会话
|
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
Fixes #5240
按维护者拍板取拒收(不重新论证 TRUE / FALSE)。四个后端对
{ field: {} }一律抛INVALID_FILTER/ 400,消息里指名出事位置(如filter.$or[0].stage)。1. 现场核对(STALE-PREMISE 自检)
worktree 基于
origin/main=26e1029f5,已含5aae79096(PR #5296 / #5146)。issue 正文那张四路径对照表逐条实测结果:
driver-sql顶层 plain mapINVALID_FILTER(#5041)Operator "=" on field "stage" requires a single comparable value…driver-sql组合子内{$not:{stage:{}}}→ 返回全部 4 行;{$or:[{stage:{}},{owner:'u2'}]}→['2'](子句被丢)formulakeys.length === 0显式 FALSEdriver-memorycheckCondition落到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)
$and/$or/$not按布尔单位元编译,$or: []不再返回全表 (#5134) #5243(SqlDriver.applyFilterCondition 丢弃编译成空的 $and/$or 子过滤器,而不是套用布尔单位元 —— 与同仓 matchesFilterCondition / driver-memory 相反 #5134):reduceFilterKey把带字段键的节点判为'clause'—— ✅ 现场确认,本 PR 没有改这个判定。加的是一条 refusal,不是 verdict 重分类;活下来的形状编译结果
逐字节不变。
$not取反前先把操作数编译成全域谓词,NULL 行不再被静默排除 (#5146) #5296($not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146):nullGuardForFieldSpec对entries.length === 0返回'none',注释写着「would RULE on
{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 from here」—— ✅ 现场确认,该分支与注释已随本单删除,并把新事实写进注释(空 spec 现在根本到不了这个函数)。
$not内的{ field: {} }确认走拒收,没有被 NULL-safe 改写吞掉(有具名测试)。
2. 闸门的位置(与派发单的一处偏差,已如实记录)
派发单建议「接在归约之后的编译分支里」。实作把它接在归约的校验遍历里
(
reduceFilterKey的字段分支,与assertFilterNode/assertFilterNodeList并列),理由是编译分支会漏:
{}是 TRUE 析取项(#5134 的单位元),整个$or归约成'true',applyFilterCondition在verdict === '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-sqlemptyFieldConstraintError+isEmptyFieldConstraint;接在reduceFilterKey(组合子/整树)与applyFilters的 plain-map 循环(顶层)。顶层原本报 #5041 的通用「cannot be bound as a SQL parameter」,现在与组合子内同一条消息 —— 一个条件一种措辞。nullGuardForFieldSpec的空 spec 分支删除。sql-driver.tsdriver-memoryfilter-refusal.ts(把既有的unsupportedFilterError收进来,避免同包两份信封),两个过滤面共用:normalizeFilterCondition(实时 mingo 路径,mingo 之前)与memory-matcher.match(参考匹配器,求值之前)memory-driver.ts/memory-matcher.ts/filter-refusal.tsformulaassertFilterShape在matchesFilterCondition入口走一遍整树;evalField的keys.length === 0保留为兜底(函数要保持全域),但不再是本后端对该形状的答案matches-filter.tsdriver-sqlite-wasmSqliteWasmDriver extends SqlDriver),但不假设「继承了就没问题」 —— 单独一份 pin 套件验证 refusal 穿过它自定义的 sql.js 方言后code/status仍完好四家同一个
INVALID_FILTER/ 400。4.⚠️ 连带:RLS
check的可观察行为变更formula的matchesFilterCondition是plugin-security对 insert/update 后像执行行级
check的路径(security-plugin.ts:1538)。改为抛出后落在 #4775「求不出值 = 该次操作失败」的既定姿态上。这不只是「拒绝得更响」,有一类结果直接翻转:
check策略{ a: {} }{ $or: [ { a: {} }, { owner: '{userId}' } ] }{ $not: { a: {} } }!false→ 写入放行后两行是原本能成功、现在会失败的写入。 这是拍板的目的而非副作用,changeset 与
上面的表都如实写了,没有轻描淡写。三条都有具名测试钉住。
同一路径的另一个消费者
explain-engine.ts:523(/explain的记录级归因)也会因此抛出而不是给出裁决 —— 一条坏策略在诊断面上同样响亮失败。这与拍板方向一致,故未加
try/catch 吞掉;若维护者希望 explain 降级为「策略不可求值」的裁决而非报错,请示下,
我另开一单(未擅自扩范围到
plugin-security)。5. 测试
@objectstack/driver-sqlsql-driver-empty-field-constraint.test.ts21 条@objectstack/driver-memory@objectstack/formula@objectstack/driver-sqlite-wasm@objectstack/objectql@objectstack/plugin-security@objectstack/service-analytics@objectstack/service-storage@objectstack/plugin-sharingtypecheck(四包)eslint --no-inline-config(全部改动文件)非空形状逐字符不变 —— 具名断言了几条普通 filter 的 SQL 文本:
以及 #5134 的布尔单位元原样保留(
{}这个空节点与{ field: {} }是两个形状)。反向验证(把改动 stash 掉,新用例必须失败)
注意第一轮 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 半边同批)。测试里对这些形状的
ascast 都带注释指明了这一状态,不是在假装契约已经收了。
7. 范围外
packages/spec/**—— 未改(见上)。packages/services/service-analytics/**—— 未改(read-scope-sql 的$not有两处与 SQL 驱动分叉:非 NULL-safe(#5146 后的最后一个异类),且{ $not: {} }编译成空 → RLS 整表放行 #5297 在飞;其read-scope-sql.ts是独立的 RLS 降解器,不属于本单的「四后端」)。其测试作为连带面跑过,555 passed。
content/docs/releases/**—— 未改;变更记录只走.changeset/。driver-mongodb是第五个后端,不在拍板的「四后端」内,未改(其单位元归约由 [spec] FILTER_LOGIC_CASES 补空组合子的布尔单位元四条 —— 需与 driver-mongodb 的单位元归约同时落地 #5239 跟)。新开 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