fix(analytics): where 门的 undefined 比较数改为拒收,不再把整个键丢掉(#6386) - #6445
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…re-gate-undefined-key
This was referenced Aug 7, 2026
Open
hotlong
marked this pull request as ready for review
August 7, 2026 19:58
hotlong
enabled auto-merge
August 7, 2026 19:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6386
严格按分诊与派发令的范围实施:只动
strategies/filter-normalizer.ts与其测试。同批 #6387 走本包另一扇门read-scope-sql.ts,本 PR 一个字都没碰那个文件。实测(先验前提,再实现)
按正文七行表逐行复测。做法:在
origin/main@5faa23ca3上直接调normalizeAnalyticsFilterTree({ where })。#6386 的前提完全成立,七行与正文表逐字一致,无一处需要更正:where{d: undefined}null{stage:'won', d: undefined}stage equals 'won'd这一合取项静默消失{$not: {d: undefined}}NOT (d set),即d IS NULL{d: {$eq: undefined}}d equals [null]$eq: null的notSet{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.ts→applyReadScope),不经过这里。所以调用方拿到的仍然是他有权看到的行,只是比他过滤的多。丢的是答案——分析口径、报表合计、聚合值全错且无痕——不是权限边界。定级不借用 #6050 的 P0。而分诊的往上加重是本单最有力的一条,也是改动的全部理由:
buildNode首行做的,正是同一个函数往下几十行的注释明令禁止的事——第三行还要更绕一层:#5146 的空值安全改写先把叶子拆成
{d: {$null: false}} AND {d: undefined},入口闸丢掉后一半,只剩前一半被取反 —— 一条谓词从被丢弃的叶子里长了出来。改动(生产代码只有两处)
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 的规矩往这个方向读:为一个横竖都被拒的形状再造一句措辞,只会把作者引去修错地方。fieldLeaves首行加assertDefinedComparands(key, raw),扫四个比较数位置:直接位、裸数组成员位、算子比较数位、列表算子的成员位(含$between两个界)。信封沿用本模块唯一那一个:
INVALID_FILTER/ 400。与read-scope-sql用 500 的理由正好相反——那边编的是平台自己的产物,这边收的确实是调用方输入。四个位置共用一条措辞,只有path与被建议修复的字段名不同,并有一条断言把这一点钉死。闸放在
nullSafeNegationOperand哪一侧 —— 第三行单独验证闸落在
fieldLeaves,即改写的下游。这正是派发令点名的陷阱:闸在改写下游,靠的是改写不吞叶子,否则拿到的是被改过形状的树而闸还照样放行。实测而非照抄——#6390 在孪生模块上的思路可用,理由不可用:那边极性表一律拼=== null,本模块是$eq/$ne拼=== null而$null/$exists按恒等读,两张表拼法本就不同。本模块实测到的是:改写的三种处置都按引用带着作者的
spec走——requireValue推{k: {$null: false}}, {k: spec},allowNull推{$or: [{k: {$null: true}}, {k: spec}]},none写out[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()的undefined→null那条臂从这扇门已不可达(它的四个调用点——$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 比较数」拒掉等于替它侧面拍板,而且会把一个旗标误标成比较数。反向验证(方向先预判,再跑;两个独立档位)
本次修复有两半,各覆盖不同子集,所以档位有两个,预判分开写在测试文件头里:
if (raw === undefined) continue;装回去。 预判:三行字段位红,四行算子/列表位保持绿(fieldLeaves的闸照样抓得住)。实测 13 红 / 1330 绿,红的正是预判集合:①②③ + 两条组合子分支行 + 两条字段位的
$not改写路径 + 组合子拒收行 + 被替换的not-null-safe用例 + envelope 表新增那行的两条。四行算子/列表位、$between、$nin、$contains、裸数组成员位、allowNull的$ne路径——一条没红。assertDefinedComparands调用。 预判:所有拒收行红;origin/main:入口行仍是删掉的,{d: undefined}会编成equals [null]而不是掉成null。实测 25 红 / 1318 绿,组合子那几行保持绿(它们来自既有分支,不是本闸),
null对照组 13 行保持绿。只有 A+B 一起才复现旧读法——这正是两半互相独立、且都是承重件的证据。
"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.ts「undefined… normalised to null at the leaf」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.ts、native-sql-strategy.ts、analytics-service.ts),全在本包内,本包 67 个测试文件全绿即包内扫描;再往下按包跑了rest与runtime。仓内where: {…: undefined}形状的夹具全文搜过:除本 PR 新增的以外,只在packages/objectql(engine 门,不经本编译器)有三处,与本门无关。门禁(全部实跑)
pnpm --filter @objectstack/service-analytics testTest Files 67 passed (67) / Tests 1343 passed (1343)(基线 66 / 1300,本 PR +1 文件 +43 用例)tsc --noEmit -p packages/services/service-analyticspnpm check:type-check-debtOK — 34 ledger entr(ies) re-measured in 193.8s, 2029 raw tsc error(s) total, none above its recorded number.pnpm --filter @objectstack/rest testTest Files 64 passed (64) / Tests 881 passed (881)pnpm --filter @objectstack/runtime testTest Files 110 passed (110) / Tests 1603 passed (1603)pnpm check:nul-bytesOK (scanned 6067 tracked text file(s))+ 改动文件控制字节自扫描 cleanpnpm 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-contractpnpm check:empty-changeset✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).eslint(5 个改动文件)check:type-check-debt必须先pnpm build(71/71 successful)再判,否则约 25 个从未碰过的包报+N,是 AGENTS.md §9 的陈旧产物陷阱。决定性测量:触达性 —— 未证实触达,维持保守定级
派发令的必答项:逐个审 dashboard / report 的编译路径,看有没有生产调用方按
{ owner_id: ctx.user?.id }这种形状拼where。结论是没找到,但这次是有方法、有具体否定结论的「没找到」,而不是「没审」:{current_user_id}占位符,由resolveFilterTokens(packages/core/src/utils/filter-tokens.ts)解析;请求没有用户时它抛UnresolvedFilterTokenError(FILTER_TOKEN_UNRESOLVED/ 400),并且对任何解析成undefined的结果再抛一次。它自己的 TSDoc 写的就是本单这件事:"Never silently resolves tonull/undefined, which on most drivers degrades toIS NULLand would quietly hand back rows the filter was written to exclude."dashboard.zod.ts的filter→runtimeFilter、report.zod.ts的runtimeFilter、dataset 的filter与 measurefilter),静态即 JSON,键值为undefined不可能存在。执行期dataset-executor.ts只是把这些编译好的过滤器combineFilters后交给service.query,自身不拼字段。POST /analytics/query、POST /analytics/dataset/query)收 JSON body,undefined过不来——与正文一致。dataset-executor.ts(见 2),以及cloud的packages/service-ai/src/tools/visualize-data.tool.ts:272ctx.analytics.query(query, …)——它的where来自 LLM 工具调用参数(args.where),同样是 JSON 解析产物。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)
where门:字段约束里$算子与非$键混写时,非$兄弟键被静默丢掉(方向是加宽;与值无关,不是 #6386 的undefined) #6444 ——fieldLeaves里,字段约束对象同时含$算子与非$键时({d: {$eq: 1, nested: 'x'}}),非$的兄弟键被静默丢弃:opKeys.length > 0的分支只遍历$键就return,嵌套关系那条路走不到。与undefined无关(任何值都丢),方向同样是加宽,故不在本单范围内;最可能的触发是漏写$的算子拼法({amount: {gte: 10, $lte: 20}}→ 下界静默没了),而同一个包的另一扇门read-scope-sql拒了这个形状。本 PR 把这个测量钉在测试里(a non-$ SIBLING of an operator is still dropped — a different defect, not this one),免得被后来人误读成已覆盖。Generated by Claude Code