Skip to content

{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240

Description

@os-zhuang

修 #5134(driver-sql 的空 $and/$or/$not 单位元)时按纪律核对「编译成空」的全部成因,发现这一处不属于本单范围的分叉。按 Prime Directive #10 单独记在这里,unassigned。

现象

{ a: {} } —— 一个字段,后面跟零个操作符。FilterConditionSchema 的非递归半边是 z.record(z.string(), z.unknown()),所以这个形状是声明合法的。同一个 filter,同仓四条路径给三个答案:

路径 代码位置 对 { a: {} } 的答案
driver-sql,顶层 sql-driver.ts applyFilters 的 plain-map 分支 → assertCompilableComparand 抛 INVALID_FILTER / 400(#5041 加的闸门)
driver-sql,组合子内($or/$and/$not 的分支里) sql-driver.ts applyFilterCondition 的 typeof value === 'object' 分支 遍历零个操作符 → 不产出任何 SQL → 实际语义 TRUE
formula matches-filter.ts evalField:if (keys.length === 0 ¦¦ …) return false FALSE(fail closed,显式写死)
driver-memory memory-matcher.ts checkCondition:落到 JSON.stringify(value) === JSON.stringify(condition) FALSE(结构相等,顺带为假)

driver-sql 自己内部就不自洽:同一个 { a: {} },写在顶层被响亮拒收,包进一层 $or 就变成静默的 TRUE。

为什么这是个 bug 而不是洁癖

在 $or 里,一个「什么都不产出」的分支会被 knex 连同分组一起丢掉,于是

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

编译成 (b = 2) —— 而按 {} 是 TRUE 析取项的算法(#5134 刚刚为空节点确立的),它应当匹配全部行;按 formula/driver-memory 的 FALSE 判定,它应当只匹配 b = 2。两种读法结论不同,而当前 SQL 的结果是碰巧和后者一致、却是经由「丢弃子句」而非任何一种语义得到的 —— 和 #5134 修掉的那个成因完全同源。

为什么 #5134 没顺手修

因为两个候选答案各有依据、且互相矛盾,这是语义定调不是实现细节:

#5134 的实现刻意不碰它:归约函数把带字段键的节点一律判为 'clause'(而非 'true'),所以这次的单位元修复既没有把 { a: {} } 悄悄升格为「匹配所有行」,也没有替它做任何裁决 —— 行为与修复前逐字节相同。代码里留了注释指向本单。

建议

三选一,需维护者/spec 车道拍板;拍板后四个后端一起对齐,并把该 case 补进 FILTER_LOGIC_CASES(见 #5239,那一单已经在扩这张表)。个人倾向 拒收:它让 AI 生成的 metadata 在编写期就炸,而不是在某个后端上安静地多返回或少返回几行 —— 与 #5041 已经在 driver-sql 顶层建立的先例一致,只是把同一条闸门补到组合子内部。若嫌收紧契约代价大,次选 FALSE(跟随两个参照实现,且方向 fail-closed)。

未验证的部分:我没有找到今天真的会产出 { field: {} } 的 producer(UI 筛选器里「选了字段还没选操作符」是最可能的来源,未实测)。严重度请 PM 按 triage 定,不代表我判断它低。

关联:#5134(同函数、同「编译成空」家族,已修空组合子)、#5041(顶层那道闸门的由来)、#5239(一致性表扩条)、#3774。

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    分诊(engine/services 车道 PM,会话 session_01Pbu27iNUfQCHeuS551Rqo7):挂 needs-user-decision 上交 —— TRUE / FALSE / 拒收三个候选各有成文依据且互相矛盾,拒收还意味着收紧 spec 声明合法的形状,属公开契约定调,PM 不代答。正文两轴分析已完整,PM 复核后同意其推荐序:拒收 > FALSE,不取 TRUE —— 拒收让 AI 写出的「选了字段没选操作符」形状在编写期响亮爆炸(与 #5041 顶层闸门同一先例,只是补进组合子内),FALSE 至少跟随两个参照实现且 fail-closed;TRUE 会把一个几乎必然是笔误的形状静默升格为全表。补一条 triage 输入:UI 筛选器「半填状态」是最可能的 producer(#5159/#5114 的 filter-builder 一族正在 spec 车道手里,拍板时可顺带核实)。拍板后四后端对齐 + case 进 FILTER_LOGIC_CASES 可与 #5239 同批执行。


    Generated by Claude Code

  2. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    维护者拍板:取 拒收

    { field: {} }(零个操作符的字段约束)在四个后端一律响亮拒收(INVALID_FILTER),不取 TRUE、不取 FALSE。needs-user-decision 摘除。

    依据:它让 AI 生成的元数据在编写期就炸,而不是在某个后端上安静地多返回或少返回几行 —— 与 #5041 已在 driver-sql 顶层建立的先例一致,本次只是把同一道闸门补进组合子内部,顺带消灭 driver-sql 自身「顶层拒收、包进 $or 就静默 TRUE」的内部不自洽。

    排期:串行等 #5146

    本单与 #5146(engine 半边,NULL-safe $not)改的是同一个文件的同一个函数(sql-driver.ts 的 applyFilterCondition),按同文件严格串行的纪律,本单待 #5146 的 PR 合入后下轮派发,不并行。保持 pm:queue。

    给下一次派发的已定范围(避免重查):


    Generated by Claude Code

  3. self-assigned this
    on Aug 4, 2026
  4. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    认领 + 派发(engine/services 车道 PM,会话 session_01Pbu27iNUfQCHeuS551Rqo7)

    分支 claude/issue-5240-empty-field-spec-reject,专用 worktree /home/user/objectstack-issue-5240,域 domain:engine。
    文件面:packages/plugins/driver-sql/src/sql-driver.ts、packages/plugins/driver-memory/src/memory-matcher.ts、packages/formula/src/matches-filter.ts + 三包各自测试 + changeset。

    串行前置已解除:#5146 的 PR #5296 已合入 origin/main(5aae79096,fix(driver-sql): $not 取反前先把操作数编译成全域谓词),applyFilterCondition 的同文件同函数占用解除,本单可派。

    同日 churn 警示 —— sql-driver.ts 今天被改过两次,开工必须基于最新 origin/main 并逐条重核 issue 正文引用的现场:

    已定范围(维护者已拍板取「拒收」,不要重新论证 TRUE/FALSE):

    ⛔ 不碰 packages/spec/** —— 收窄 FilterConditionSchema 与 FILTER_LOGIC_CASES 归 spec 车道(建议与 #5239、#5146 spec 半边同批)。engine 半边先落,契约收窄随后。
    ⛔ 不碰 packages/services/service-analytics/** —— 那是 #5297 的在飞文件面(它的 read-scope-sql.ts 是独立的 RLS 降解器,不属于本单的「四后端」)。
    ⛔ 不碰 content/docs/releases/**。


    Generated by Claude Code

  5. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    实现完成 —— draft PR #5327(会话 session_01Pbu27iNUfQCHeuS551Rqo7,分支 claude/issue-5240-empty-field-spec-reject)

    按拍板取拒收。四个后端(driver-sql / driver-memory / formula / driver-sqlite-wasm)对 { field: {} } 一律抛 INVALID_FILTER / 400,消息指名位置(如 filter.$or[0].stage)。

    现场核对:正文那张四路径表有一行需要修正。 driver-memory 那行指的是 memory-matcher.ts,而它不是驱动的实时路径 —— InMemoryDriver.find 只 import 了 getValueByPath,过滤实际交给 mingo,后者把 { a: {} } 读作「字段深等于空文档」(实测:特意播的 a: {} 那行被选中)。所以那条路径给的不是 FALSE,而是另一个 filter,只在正常数据上看着像 FALSE。两个过滤面都补了闸门,否则用户真正走的那条仍然静默。其余三行逐条实测成立。

    闸门位置与派发单有一处偏差(已在 PR 正文说明):接在归约的校验遍历里(与 assertFilterNode 并列),而不是归约之后的编译分支 —— 因为 { $or: [ { a: {} }, {} ] } 会被 {} 这个 TRUE 析取项在编译前短路掉,编译分支永远见不到它,拒不拒收就取决于兄弟节点。归约的判定一行未改(字段键仍是 'clause')。#5296 的 nullGuardForFieldSpec 空 spec 分支与注释已随本单删除。

    RLS check 连带已如实写进 changeset 与 PR 正文:{ $or: [ { a: {} }, {…} ] } 与 { $not: { a: {} } } 这两类策略,原本 FALSE 被吸收 → 写入放行,现在抛出 → 写入失败。即有原本能成功、现在会失败的写入。

    验证:driver-sql 786 / driver-memory 320 / formula 357 / sqlite-wasm 237 / objectql 1882 / plugin-security 731 / service-analytics 555 / service-storage 283 / plugin-sharing 347 全绿,typecheck + eslint 干净;反向验证(stash 源码)四个后端的新用例全部失败,原文在 PR 正文。已 merge 最新 main(04fab5eaf)后重跑一轮。

    spec 半边未动:实现现在比已声明契约更严,收窄 FilterConditionSchema 与补 FILTER_LOGIC_CASES 仍归 spec 车道(#5239 / #5146 同批)。

    范围外新发现,已另开 #5324(unassigned):driver-memory 的实时路径根本不支持 $not —— 原样透传给 mingo,而 MongoDB 没有文档级 $not,任何位置的 $not 都抛无 code/status 的 MingoError(500 形状);CEL !expr 降下来的 RLS scope 在该驱动上直接报错。至今没测出来是因为 FILTER_LOGIC_CASES 对该后端只经参考匹配器跑,没穿过真驱动。


    Generated by Claude Code

  6. 18 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions