Skip to content

fix(service-analytics): 分析查询里的 null 比较数是 null 谓词,不是 = '' (#5332) - #5525

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5332-analytics-null-eq
Aug 5, 2026
Merged

fix(service-analytics): 分析查询里的 null 比较数是 null 谓词,不是 = '' (#5332)#5525
os-zhuang merged 1 commit into
mainfrom
claude/issue-5332-analytics-null-eq

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5332

前提核对(先于实现)

origin/main(基线 308c70951)上逐条核对了 issue 的前提,全部成立:

  • fieldLeaves 的算子循环里 $eq / $neMONGO_TO_CUBE_OP 统一映射,对 null 没有特判;
  • stringifyForCube 的第一行仍是 if (v == null) return '';;
  • nullValueSatisfiesOperator / operatorIsNullTotal 里没有 read-scope-sql 的两条 value === null arm,
    filter-normalizer.ts 的 TSDoc 自己写明「'' 比较数是另一个缺陷,单独记录、本单不裁定」——本单就是那条记录。

近日落在同文件的 #5325(PR #5335)、#5334(PR #5355)、#5322#5352 都没有触及这条路径。

为什么这不需要裁决:契约早就定了

$eq: null$null: true 不是「两种相近写法需要挑一个」——driver-mongodb 的 translator
直接把后者改写成前者
(mongodb-filter.ts$null arm:$null: true{$eq: null},
$null: false{$ne: null}),所以在契约层面它们本来就是同一个谓词。同包的
read-scope-sql.ts(compileOperator)、driver-sqldriver-memory(value != target 的松散相等)、
formula 四家全都这么编译;packages/spec/src/data/filter.test.ts 也明确接受 {$eq: null}
本模块是一个包里唯一唱反调的那一半,所以这是对齐,不是新决策。

改了什么

1. emitter(fieldLeaves)——算子循环里先判 null 比较数:$eq: nullleaf('notSet', [])
$ne: nullleaf('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/$nevalue === null arm、
operatorIsNullTotal 判定这两个为 null-total → guard 'none',与 read-scope-sql 的两条 arm 同形。
read-scope-sql.ts 本身无需改动——它两条 arm 早就在(#5297),本单是把 normalizer 补齐到它。

3. 刻意不改的:stringifyForCubev == null → '' 保留(issue 的建议之一)。它现在只服务
「没有裁定过的比较数位置」($gt: null$in: [null]),那里 '' 是占位而不是答案 ——
valuesstring[],凡是意思是 null 的叶子都以空 valuesnotSet / set 表达,根本不进这个函数。
同时钉住反方向:{stage: {$eq: ''}} 仍然是值比较(绑定 ''),把 '' 读成 null 是同一个缺陷反着犯。

反向验证:先声明预期方向,再跑

预期是标准红(新钉子断言 IS NULL / 新行集,旧代码给 = '' / 旧行集)。把源码改动
git stash 掉、只留新测试跑了一遍,7 条全红,并且逐条复现了 issue 的实测表:

× the issue's measured table: all four spellings, SQL and bindings
  Expected: "WHERE stage IS NULL"
  Received: "SELECT id AS "id", COUNT(*) AS "total" FROM "deal" WHERE stage = $1 GROUP BY id"

× the row sets agree with the other three spellings
  AssertionError: expected [] to deeply equal [ '3', '4' ]        // $eq: null 取到零行

× [#5332] the OPERATOR spellings of a `null` comparand are untouched too
  AssertionError: expected [ '1', '2', '3', '4' ] to deeply equal [ '1', '2' ]
                                                    // {$not: {$eq: null}} 返回全表

× on a TEXT column the `$ne` direction stops excluding the `''` rows
  AssertionError: expected [ 'e2' ] to deeply equal [ 'e3' ]
                    // $eq: null 取到的是空串那行,而不是 null 那行

最后一条是 issue 严重度论证的实测版:文本列上 ''真值,所以旧行为里
「阶段为空」取到了唯一那个不为空的行,「阶段非空」则把空串行排掉了。

恢复源码后:47 files / 753 tests 全绿。

测试

复用 filter-normalizer-not-null-safe.test.ts 的 sql.js + 四行 fixture(与 driver-sql /
formula / read-scope-sql 的同名文件逐行同构),不新建重复 harness:

  • issue 的实测表:四种写法的 SQL 与绑定值逐条钉住;
  • 行集:$eq: null3,4,$ne: null1,2,并断言四种写法行行相等;
  • tree 形状:normalizeAnalyticsFilterTree 直出 notSet / setvalues: [];
  • $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:346driver-sql/sql-driver-not-null-safe.test.ts:216
    formula/matches-filter-not-null-safe.test.ts:128 在同一 fixture 上已有的答案;
  • 两条 objectql 路径:交给引擎的是 {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 0
  • 该包无 typecheck script(在 check-type-check-coverage.mjs 里是 errors: 3 的 DEBT 条目),
    所以直接跑 npx tsc --noEmit -p tsconfig.json:改动前后都是 7 个错误,且 7 条全在我没碰的
    文件里(analytics-service.test.tsmeasure-source-field-gate.test.ts
    objectql-timedimension-projection.test.ts)。台账写 3 的漂移是既有的,不是本 PR 引入
    ——按范围纪律没有在本 PR 里动它。
  • eslint --no-inline-config(两个改动文件)→ exit 0
  • node 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

…= ''` (#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
@vercel

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

Request Review

@github-actions

github-actions Bot commented Aug 5, 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 size/m labels Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

范围外发现(Prime Directive #10)

本单核对期间在同文件发现、未在本 PR 修,已单独立单 unassigned:

与本 PR 不重叠:本 PR 把 null 彻底移出了值数组(notSet / setvalues 为空),#5526 说的是留在值数组里的那些非 null 字符串#5526 也正是已关闭的 #5373 自己列在「未验证的部分」里的第三个症状(它测的是 driver-memory 那份私有实现,本条是 service-analytics 这一半的实测)。

顺带核销一条不是缺陷的怀疑,免得下一位重复走:driver-sqlapplyNormalizedComparison(SQLite datetime 列的归一化比较)看起来会把 $eq: null 编成 expr = ? 绑 NULL,与它自己的守卫表冲突 —— 读下去发现 binary() 第一行就是 if (value == null) return false;,原路交回 knex,由 knex 归一成 is null(已用 knex('t').where('c', null).toString() 实测为 where "c" is null)。无分叉,无需立单。


Generated by Claude Code


Generated by Claude Code

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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

分析查询的 {field: {$eq: null}} / {$ne: null} 编译成 col = '' / col != '',与同文件里 {field: null}IS NULL 自相矛盾

2 participants