fix(service-analytics): 分析查询里的 null 比较数是 null 谓词,不是 = '' (#5332) - #5525
Conversation
…= ''` (#5332) `{stage: null}` compiled to `stage IS NULL` while `{stage: {$eq: null}}` — the same predicate — compiled to `stage = $1` binding the empty STRING, because the operator spelling fell through to `MONGO_TO_CUBE_OP` and `stringifyForCube(null)` returned `''`. One meaning, two answers, inside one file. The failure was silent: an "is empty" widget drew zero rows with no error to read, and on a text column — where `''` is a value rows genuinely store — the `$ne` direction excluded exactly the rows "is not empty" was asked to keep. `$eq: null` is not a near-synonym of `$null: true`: driver-mongodb's translator rewrites the latter into the former, so they are one predicate in the contract, and `read-scope-sql.ts`, `driver-sql`, `driver-memory` and `formula` all compile them alike. `fieldLeaves` now emits the same `notSet` / `set` leaves for all three spellings, so both strategies, the engine filter and the display-SQL echo follow with no new cases. The #5146 guard table moves in the SAME commit because it describes this file's emitter: left alone it would have wrapped `stage IS NOT NULL AND stage IS NULL` and negated that always-false conjunction to EVERY row for `{$not: {stage: {$eq: null}}}`. `nullValueSatisfiesOperator` and `operatorIsNullTotal` now carry the `value === null` arms their `read-scope-sql` counterparts have. Scoped to the two spellings `filter.zod.ts` gives a null MEANING. `stringifyForCube`'s `v == null` arm is untouched — it still serves comparand positions no ruling covers (`$gt: null`, `$in: [null]`) — and `{$eq: ''}` stays a value comparison, since reading `''` as null is the same defect sign-flipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 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:
|
范围外发现(Prime Directive #10)本单核对期间在同文件发现、未在本 PR 修,已单独立单 unassigned:
与本 PR 不重叠:本 PR 把 顺带核销一条不是缺陷的怀疑,免得下一位重复走: Generated by Claude Code Generated by Claude Code |
Fixes #5332
前提核对(先于实现)
在
origin/main(基线308c70951)上逐条核对了 issue 的前提,全部成立:fieldLeaves的算子循环里$eq/$ne走MONGO_TO_CUBE_OP统一映射,对null没有特判;stringifyForCube的第一行仍是if (v == null) return '';;nullValueSatisfiesOperator/operatorIsNullTotal里没有read-scope-sql的两条value === nullarm,而
filter-normalizer.ts的 TSDoc 自己写明「''比较数是另一个缺陷,单独记录、本单不裁定」——本单就是那条记录。近日落在同文件的 #5325(PR #5335)、#5334(PR #5355)、#5322、#5352 都没有触及这条路径。
为什么这不需要裁决:契约早就定了
$eq: null和$null: true不是「两种相近写法需要挑一个」——driver-mongodb的 translator直接把后者改写成前者(
mongodb-filter.ts的$nullarm:$null: true→{$eq: null},$null: false→{$ne: null}),所以在契约层面它们本来就是同一个谓词。同包的read-scope-sql.ts(compileOperator)、driver-sql、driver-memory(value != target的松散相等)、formula四家全都这么编译;packages/spec/src/data/filter.test.ts也明确接受{$eq: null}。本模块是一个包里唯一唱反调的那一半,所以这是对齐,不是新决策。
改了什么
1. emitter(
fieldLeaves)——算子循环里先判null比较数:$eq: null→leaf('notSet', [])、$ne: null→leaf('set', []),与上面raw === null分支、旁边的$null/$exists完全同形。三个 consumer 早就都实现了
notSet/set(native-sql-strategy.buildFilterClause、objectql-strategy.convertFilter/buildFilterClauseSql),所以不需要任何新 case。严格
=== null(与read-scope-sql/driver-sql一致):JSON 写不出undefined。2. 守卫表,同一个 commit 里一起改——#5146 的极性表描述的是本文件自己的 emitter。
$eq: null还是值比较时,表把它归到值比较是对的;emitter 一变,表不跟着变就会给{$not: {stage: {$eq: null}}}套出stage IS NOT NULL AND stage IS NULL这个恒假合取再取反 ——「阶段非空」返回全表。现在
nullValueSatisfiesOperator拿到$eq/$ne的value === nullarm、operatorIsNullTotal判定这两个为 null-total → guard'none',与read-scope-sql的两条 arm 同形。read-scope-sql.ts本身无需改动——它两条 arm 早就在(#5297),本单是把 normalizer 补齐到它。3. 刻意不改的:
stringifyForCube的v == null → ''保留(issue 的建议之一)。它现在只服务「没有裁定过的比较数位置」(
$gt: null、$in: [null]),那里''是占位而不是答案 ——values是string[],凡是意思是 null 的叶子都以空values的notSet/set表达,根本不进这个函数。同时钉住反方向:
{stage: {$eq: ''}}仍然是值比较(绑定''),把''读成 null 是同一个缺陷反着犯。反向验证:先声明预期方向,再跑
预期是标准红(新钉子断言
IS NULL/ 新行集,旧代码给= ''/ 旧行集)。把源码改动git stash掉、只留新测试跑了一遍,7 条全红,并且逐条复现了 issue 的实测表:最后一条是 issue 严重度论证的实测版:文本列上
''是真值,所以旧行为里「阶段为空」取到了唯一那个不为空的行,「阶段非空」则把空串行排掉了。
恢复源码后:
47 files / 753 tests全绿。测试
复用
filter-normalizer-not-null-safe.test.ts的 sql.js + 四行 fixture(与driver-sql/formula/read-scope-sql的同名文件逐行同构),不新建重复 harness:$eq: null→3,4,$ne: null→1,2,并断言四种写法行行相等;normalizeAnalyticsFilterTree直出notSet/set且values: [];$not行集:{$not: {stage: {$eq: null}}}→1,2、{$ne: null}→3,4。这正是 issue 说「service-analytics 的第二个 SQL 编译器
filter-normalizer.buildNode仍带着 #5297 的三条分叉:$not非 NULL-safe、{$not:{}}不加 WHERE、$or的{}析取项被丢 #5325 刻意没钉、以免钉死错答案」的那两条;id 集不是推的,是read-scope-not-null-safe.test.ts:346、driver-sql/sql-driver-not-null-safe.test.ts:216、formula/matches-filter-not-null-safe.test.ts:128在同一 fixture 上已有的答案;{stage: null}/{stage: {$ne: null}}(旧值{stage: ''}),回显 SQL 出
IS NULL/IS NOT NULL且绑定为空;''行的独立小表(共享 fixture 是与driver-sql逐行同构的,不能动),证明$eq: null不再命中空串行、$ne: null不再排掉空串行;{$eq: ''}仍绑'',{$eq: 'won'}/{$ne: 'won'}行为不变。命令与结果:
pnpm --filter @objectstack/service-analytics test(worktree 内,锁下串行)→Test Files 47 passed (47)/Tests 753 passed (753)pnpm --filter @objectstack/service-analytics build→ exit 0typecheckscript(在check-type-check-coverage.mjs里是errors: 3的 DEBT 条目),所以直接跑
npx tsc --noEmit -p tsconfig.json:改动前后都是 7 个错误,且 7 条全在我没碰的文件里(
analytics-service.test.ts、measure-source-field-gate.test.ts、objectql-timedimension-projection.test.ts)。台账写 3 的漂移是既有的,不是本 PR 引入——按范围纪律没有在本 PR 里动它。
eslint --no-inline-config(两个改动文件)→ exit 0node scripts/check-nul-bytes.mjs→ OK;另对三个改动文件做了越出该 gate 扫描面的自查(
grep -naP全部控制字节)→ 无命中。消费半径扫描
normalizeAnalyticsFilterTree/collectFilterLeaves的调用者只有native-sql-strategy.ts与objectql-strategy.ts(两者已实现notSet/set)。packages/rest的analytics-filter-refusal-envelope.test.ts只钉拒绝集,而本 PR 没有增删任何拒绝;它的 stub matcher 对
{stage: null}走bucket[key] === value分支,不受影响。examples(showcase / CRM)与 content 里没有
"$eq": null/"$ne": null形状的 widget filter。范围
按派发要求没有碰
objectql-strategy.ts(#5333 下一轮),也没有碰read-scope-sql.ts(它已经是正确的那一半,本单是把 normalizer 对齐到它)。
Generated by Claude Code