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

@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 16:02
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit f56ebea Aug 5, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5332-analytics-null-eq branch August 5, 2026 16:35
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…t as string[] (objectstack-ai#5526) (objectstack-ai#5634)

The analytics filter normalizer flattened every comparand into `values:
string[]` and had its consumers GUESS the type back out. An all-strings
encoding has no escape, so author strings collided with the tokens the
encoder wrote for other types: `{code: {$eq: 'null'}}` bound real NULL
(UNKNOWN for every row), `'true'` bound 1 / true, and `'007'` / `'1.50'`
bound 7 / 1.5 until objectstack-ai#5528 narrowed that half as an explicit stopgap.

`NormalizedFilterNode`'s leaf `values` is now `unknown[]`. The author's
value travels through the tree untouched and nothing decodes it.
`stringifyForCube`, `recoverNumber`, `coerceFilterValueForSql` and
`coerceFilterValueForObjectQL` are deleted. Conversion survives only
where a boundary demands it:

- `toSqlBindValue` — one-way, and it inspects no string: it converts only
  the JS types a driver cannot bind (boolean -> 1/0, Date -> ISO,
  object -> JSON).
- the LIKE family, whose comparand `filter.zod.ts` declares a `string`,
  so `like-pattern.ts` and `convertFilter` stringify at the emitter — the
  same `String(value)` `driver-sql`'s `applyLike` applies, keeping one
  `$contains` meaning one thing on both faces.

The ObjectQL engine path now converts nothing at all: the engine compares
against the stored runtime type and receives the author's own value.

Two readings changed as a consequence, both toward fail-closed:
`{$contains: null}` was `LIKE '%%'` (matching every non-NULL row) and is
now `LIKE '%null%'`, which is what driver-sql has always compiled;
`{$gt: null}` was `> ''` (a real comparison against the empty string) and
now binds NULL, so the predicate is UNKNOWN. `timeDimensions[].dateRange`
bounds forward at the `string` type the spec declares them with, instead
of being re-read as epoch-ms numbers by a lenient consumer.

The null-predicate semantics of objectstack-ai#5332 / objectstack-ai#5525 and the LIKE escaping
contract of objectstack-ai#5567 are untouched, both pinned by row-set tests.

objectstack-ai#5528's test asset is carried forward whole:
filter-value-canonical-number.test.ts becomes
filter-value-type-fidelity.test.ts, with every case upgraded from "what
does the decoder return" to the end-to-end leaf/SQL-bind/engine-bind
question, plus decoy rows storing the text 'null' and 'true' beside a real
NULL. Reverse-verified: re-inserting the encoder turns those pins red.

Fixes objectstack-ai#5526


Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK

Co-authored-by: Claude <noreply@anthropic.com>
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