Skip to content

fix(analytics): where 门的 undefined 比较数改为拒收,不再把整个键丢掉(#6386) - #6445

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-6386-where-gate-undefined-key
Aug 7, 2026
Merged

fix(analytics): where 门的 undefined 比较数改为拒收,不再把整个键丢掉(#6386)#6445
hotlong merged 2 commits into
mainfrom
claude/issue-6386-where-gate-undefined-key

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6386

严格按分诊与派发令的范围实施:只动 strategies/filter-normalizer.ts 与其测试。同批 #6387 走本包另一扇门 read-scope-sql.ts,本 PR 一个字都没碰那个文件。

实测(先验前提,再实现)

按正文七行表逐行复测。做法:在 origin/main @ 5faa23ca3 上直接调 normalizeAnalyticsFilterTree({ where })#6386 的前提完全成立,七行与正文表逐字一致,无一处需要更正:

where 归一化结果 读法
{d: undefined} null 整条 where 丢弃 → 查询无过滤器运行
{stage:'won', d: undefined} 只剩 stage equals 'won' d 这一合取项静默消失
{$not: {d: undefined}} NOT (d set),即 d IS NULL 作者没写过的谓词
{d: {$eq: undefined}} d equals [null] 值比较,不是 $eq: nullnotSet
{d: {$gt: undefined}} d gt [null] 同上
{d: {$in: [undefined]}} d in [null] 同上
{d: {$ne: undefined}} d notSet OR d notEquals [null] 同上

同一轮还量到表外的九格,其中两格比表内任何一行都更响:

表外 where 归一化结果
{$or: [{d: undefined}, {stage: 1}]} null —— 被丢弃的分支成了 TRUE,吸收掉整个析取#5325),连活着的 stage 分支一起没了 ⇒ 每一行
{$and: undefined} / {$or: undefined} / {$not: undefined} null —— 组合子整个静默丢弃
{d: [1, undefined]} d in [1, null](裸数组即隐式 $in,成员位)
{d: {$between: [undefined, 5]}} d gte [null] AND d lte [5]
{d: {$nin: [undefined]}} / {$contains: undefined} 值比较
{profile: {verified: undefined}} profile.verified equals [null](嵌套关系的直接比较数位)

定级:静默错结果,不是权限绕过

采信分诊的往下修正,并在 PR 与错误消息里都写清楚:本门加宽的是调用方自己写的 where;read scope 走另一扇门(read-scope-sql.tsapplyReadScope),不经过这里。所以调用方拿到的仍然是他有权看到的行,只是比他过滤的多。丢的是答案——分析口径、报表合计、聚合值全错且无痕——不是权限边界。定级不借用 #6050 的 P0。

而分诊的往上加重是本单最有力的一条,也是改动的全部理由:buildNode 首行做的,正是同一个函数往下几十行的注释明令禁止的事——

NEVER drop: a missing predicate does not narrow the query, it WIDENS it … That failure mode is #3650's

第三行还要更绕一层:#5146 的空值安全改写先把叶子拆成 {d: {$null: false}} AND {d: undefined},入口闸丢掉后一半,只剩前一半被取反 —— 一条谓词从被丢弃的叶子里长了出来

改动(生产代码只有两处)

  1. 删掉 buildNode 的入口行 if (raw === undefined) continue;。这不产生四条新拒收:每种键各自落到本来就有更贴切说法的分支上——字段键 → 新闸;$and/$or → 「requires an array of filter objects, got undefined」;$not → 「requires a filter object」;其它 $ 顶层键 → 「Unsupported top-level filter operator」。{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 的规矩往这个方向读:为一个横竖都被拒的形状再造一句措辞,只会把作者引去修错地方。
  2. fieldLeaves 首行加 assertDefinedComparands(key, raw),扫四个比较数位置:直接位、裸数组成员位、算子比较数位、列表算子的成员位(含 $between 两个界)。

信封沿用本模块唯一那一个:INVALID_FILTER / 400。read-scope-sql 用 500 的理由正好相反——那边编的是平台自己的产物,这边收的确实是调用方输入。四个位置共用一条措辞,只有 path 与被建议修复的字段名不同,并有一条断言把这一点钉死。

⚠️ 措辞比孪生实现多说一件事:那边四格败法相同,本门同一个输入有两种败法(字段位是「丢键」,算子/列表位是「跟 null 比」),所以消息把两种都写出来,作者才知道自己那把钥匙原本会怎么坏。

闸放在 nullSafeNegationOperand 哪一侧 —— 第三行单独验证

闸落在 fieldLeaves,即改写的下游。这正是派发令点名的陷阱:闸在改写下游,靠的是改写不吞叶子,否则拿到的是被改过形状的树而闸还照样放行。实测而非照抄——#6390 在孪生模块上的思路可用,理由不可用:那边极性表一律拼 === null,本模块是 $eq/$ne=== null$null/$exists恒等读,两张表拼法本就不同。

本模块实测到的是:改写的三种处置都按引用带着作者的 spec 走——requireValue{k: {$null: false}}, {k: spec}allowNull{$or: [{k: {$null: true}}, {k: spec}]}noneout[k] = spec——所以 undefined 一定到得了闸,一定抛错。测试按能承载被扫比较数的改写路径各钉一例(requireValue 直接位 / requireValue 算子位 / allowNull$ne 极性 / 嵌套关系递归 / negation 里的列表成员),并如实记下没有第三种处置可写的原因:none 要求每个算子都 operatorIsNullTotal,而这对被扫算子的 undefined 比较数一律为假,所以能带着 undefined 走到 none 的只有本闸故意不扫$null/$exists 旗标。

⛔ 红线:#5526 / #5332 一字未动(可核验)

派发令写死的红线。filter-normalizer.ts 的 diff 里删除的代码行只有一行(就是那句 continue;);comparand() 的函数体与判「空值谓词 vs 值比较」的 wrapper[opKey] === null 分支在 diff 中根本不出现,即逐字节未动。本 PR 拒的是输入,不重开这两次裁决中的任何一次。

一处如实记录、不顺手处置:闸装上后,comparand()undefinednull 那条臂从这扇门已不可达(它的四个调用点——$between 两界、算子值及其数组成员、裸数组、隐式 =——全在闸后)。故意留着不删:删掉一条刚死的臂,是把一次语义变更伪装成清扫;它还值不值得留是 #5526 自己的判断,不是本单的搭车项。TSDoc 里记了这件事,测试用「枚举每个调用点都被拒」把这个可达性判断变成可复核的,而不是一句断言。

null 对照组:13 行逐字节未动

这是本次改动唯一会造成伤害的方向——两者在本模块每张极性表里只隔一个 ===。对照组:{d: null} / {$eq: null} / {$ne: null} / {$gt: null} / {$in: [null]} / {$nin: [null]} / {$between: [null, 5]} / {$contains: null}%null%#5526)/ {$null: true} / {$exists: false} / {$not: {d: null}} / {$not: {d: {$ne: null}}} / {d: [1, null]}。改动前后的探针输出 diff 为空,13 行全绿;下面两个反向档位里也全程没红过一行。

$null / $exists 同样故意不扫:它们的比较数是声明的布尔旗标,不是比较数位。⚠️ 本模块按恒等读该旗标(孪生模块按真值性),所以 {$null: undefined} 在这里降成 set。那是布尔的问题(#5347 / #5369 的家族,在孪生门上是 #6387),当成「undefined 比较数」拒掉等于替它侧面拍板,而且会把一个旗标误标成比较数。

反向验证(方向先预判,再跑;两个独立档位)

本次修复有两半,各覆盖不同子集,所以档位有两个,预判分开写在测试文件头里:

  • 档位 A —— 只把 if (raw === undefined) continue; 装回去。 预判:三行字段位红,四行算子/列表位保持绿(fieldLeaves 的闸照样抓得住)。
    实测 13 红 / 1330 绿,红的正是预判集合:①②③ + 两条组合子分支行 + 两条字段位的 $not 改写路径 + 组合子拒收行 + 被替换的 not-null-safe 用例 + envelope 表新增那行的两条。四行算子/列表位、$between$nin$contains、裸数组成员位、allowNull$ne 路径——一条没红
  • 档位 B —— 只摘掉 assertDefinedComparands 调用。 预判:所有拒收行红;⚠️ B 单独并不能还原 origin/main:入口行仍是删掉的,{d: undefined} 会编成 equals [null] 而不是掉成 null
    实测 25 红 / 1318 绿,组合子那几行保持绿(它们来自既有分支,不是本闸),null 对照组 13 行保持绿

只有 A+B 一起才复现旧读法——这正是两半互相独立、且都是承重件的证据。

⚠️ B 档位捞出了我自己一条会「空着绿」的断言,已当场补掉:措辞唯一性那条按集合大小判断,而当没有任何东西抛错时,14 行全部映射成同一个 "undefined" 字符串,集合大小为 1,于是它在 B 档位下照样绿——正是「断言绿是因为什么都没产出」的那个陷阱。现在它先逐行断言确实拒收,再比措辞。

夹具分诊(三种处置,逐个判而非批量重拼)

全量跑出三条红,各是不同的处置:

夹具 处置 说明
filter-normalizer-not-null-safe.test.ts「an undefined value is still skipped, as it always was」 整条替换 它把「丢键」当保证钉死了。原断言写 ids({stage: undefined, owner: 'u1'}) === ['1','3']——两键的拼法恰好掩盖了缺陷:owner: 'u1' 活着,行集看上去仍被过滤。改成在同一个 seam(经 NativeSQLStrategy 端到端执行,#5297 的教训所在的那一层)断言拒收,并补一行「把不可知的键省掉就仍能拿到原来的行集」。
filter-value-type-fidelity.test.tsundefined … normalised to null at the leaf」 重新裁定 它读的是 #5526 的归一化经由这扇门的可观测结果;结果变了(改为拒收),但 comparand() 本身没动。用例里把这个区别写死,并顺带断言邻座的 {$eq: null} 仍降成 notSet#5332)——那一行正是「拒收没有扩宽成 == null」的证据。
filter-refusal-envelope.test.ts 的账本 补声明 该文件那条 covers every refusing site in the module 是全量声明,模块多一格它就不再成立。新增一行并把计数 9 → 10;同时给行加 addedAfter5352 字段,把 #5352历史记录(哪两格是当年漏记的)与账本的另一项职责(这就是模块全部拒收位)分开,否则新行悄悄混进 issueBullet: false 集合等于改写历史。

消费半径

按规则消费半径扫,不按改动包扫。normalizeAnalyticsFilterTree / lowerAnalyticsWhere 的调用方共 3 个文件(objectql-strategy.tsnative-sql-strategy.tsanalytics-service.ts),全在本包内,本包 67 个测试文件全绿即包内扫描;再往下按包跑了 restruntime。仓内 where: {…: undefined} 形状的夹具全文搜过:除本 PR 新增的以外,只在 packages/objectql(engine 门,不经本编译器)有三处,与本门无关。

门禁(全部实跑)

结果
pnpm --filter @objectstack/service-analytics test Test Files 67 passed (67) / Tests 1343 passed (1343)(基线 66 / 1300,本 PR +1 文件 +43 用例)
tsc --noEmit -p packages/services/service-analytics 10 errors —— 与账本记录的 10 一致,改动的 5 个文件类型错误
pnpm check:type-check-debt OK — 34 ledger entr(ies) re-measured in 193.8s, 2029 raw tsc error(s) total, none above its recorded number.
下游 pnpm --filter @objectstack/rest test Test Files 64 passed (64) / Tests 881 passed (881)
下游 pnpm --filter @objectstack/runtime test Test Files 110 passed (110) / Tests 1603 passed (1603)
pnpm check:nul-bytes OK (scanned 6067 tracked text file(s)) + 改动文件控制字节自扫描 clean
pnpm check:error-code-casing ✓ no lowercase error codes in 3163 scanned file(s) (ADR-0112).
pnpm check:route-envelope / check:query-options-erasure / check:engine-double-contract 全绿,baseline 未动
pnpm check:empty-changeset ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
eslint(5 个改动文件) exit 0

⚠️ 记一笔免得下一个人重诊断:新 worktree 里 check:type-check-debt 必须先 pnpm build(71/71 successful)再判,否则约 25 个从未碰过的包报 +N,是 AGENTS.md §9 的陈旧产物陷阱。

决定性测量:触达性 —— 未证实触达,维持保守定级

派发令的必答项:逐个审 dashboard / report 的编译路径,看有没有生产调用方按 { owner_id: ctx.user?.id } 这种形状拼 where结论是没找到,但这次是有方法、有具体否定结论的「没找到」,而不是「没审」:

  1. 平台自己的「按当前用户过滤」机制早就是 fail-closed,而且根本不是这个形状。 dataset / widget / report 的过滤器里写的是 {current_user_id} 占位符,由 resolveFilterTokenspackages/core/src/utils/filter-tokens.ts)解析;请求没有用户时它 UnresolvedFilterTokenErrorFILTER_TOKEN_UNRESOLVED / 400),并且对任何解析成 undefined 的结果再抛一次。它自己的 TSDoc 写的就是本单这件事:"Never silently resolves to null/undefined, which on most drivers degrades to IS NULL and would quietly hand back rows the filter was written to exclude."
  2. dataset / dashboard widget / report 的过滤器都是库存 metadatadashboard.zod.tsfilterruntimeFilterreport.zod.tsruntimeFilter、dataset 的 filter 与 measure filter),静态即 JSON,键值为 undefined 不可能存在。执行期 dataset-executor.ts 只是把这些编译好的过滤器 combineFilters 后交给 service.query,自身不拼字段。
  3. 两扇 REST 门POST /analytics/queryPOST /analytics/dataset/query)收 JSON body,undefined 过不来——与正文一致。
  4. 进程内调用方:两个 monorepo 里只有两处。dataset-executor.ts(见 2),以及 cloudpackages/service-ai/src/tools/visualize-data.tool.ts:272 ctx.analytics.query(query, …)——它的 where 来自 LLM 工具调用参数(args.where),同样是 JSON 解析产物。
  5. 形状全文搜packages/ / examples/ / apps/(objectstack)与 cloud 全仓搜 where/filter/runtimeFilter 字面量里带可选链的写法,零命中;examples 里的 runtimeFilter 全是常量({ done: false }{ is_completed: false })。

所以严重度不上调,正文的「未证实触达」成立。⚠️ 但这条否定结论是有前提的:它取决于「今天没人在进程内这样拼」,而不是「这样拼不出来」——IAnalyticsService.query 是公开契约,任何插件写一个 { owner_id: user?.id } 都能重现,而在此之前没有任何东西会告诉他。本 PR 正是把那一刻从静默变成一句带修法的 400。

顺带发现(PD #10,已另立单、未指派、未扩大本 PR 的 diff)


Generated by Claude Code

…nstead of reading it seven ways (#6386)

`buildNode`'s first line was `if (raw === undefined) continue;` — the entry gate
doing the one thing the rest of the module forbids: dropping a key without a
trace, which does not narrow a query, it WIDENS it. Measured on origin/main
(5faa23c), one shape had seven readings; the first three widen:

  {d: undefined}              -> null                      (the WHOLE where dropped)
  {stage:'won', d: undefined} -> stage equals 'won'         (the conjunct vanished)
  {$not: {d: undefined}}      -> NOT (d set) = d IS NULL    (an invented predicate)
  {d: {$eq: undefined}}       -> d equals [null]            (value comparison)
  {d: {$gt: undefined}}       -> d gt [null]
  {d: {$in: [undefined]}}     -> d in [null]
  {d: {$ne: undefined}}       -> d notSet OR d notEquals [null]

Row three is the strangest: #5146's null-safe rewrite splits the leaf into
`{d: {$null: false}} AND {d: undefined}`, the entry gate dropped the second
half, and the surviving guard was negated — so a predicate grew out of a
discarded leaf.

#6050 ruling B (undefined in a comparand position is refused) is pushed down to
this door, in this module's existing envelope: INVALID_FILTER / 400, because
unlike `read-scope-sql`'s platform-compiled artifact this door receives caller
input. `assertDefinedComparands` sweeps the four comparand positions in
`fieldLeaves` — the only leaf producer, so one gate covers all three consumers of
the tree — and deleting the entry line lets each combinator reach the refusal it
already had. The direction is silently wrong RESULTS, not a permission bypass:
read scope is compiled by the other door and never passes through here.

`comparand()` (#5526) and the strict `wrapper[opKey] === null` branch (#5332) are
byte-identical: this refuses an INPUT and reopens neither. The normalisation is
now unreachable from this door, which is recorded rather than acted on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
@vercel

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

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics.

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

  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/index.mdx (via @objectstack/service-analytics)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/packages.mdx (via @objectstack/service-analytics)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
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/l tests tooling

Projects

None yet

2 participants