Repository navigation
分析查询的 {field: {$eq: null}} / {$ne: null} 编译成 col = '' / col != '',与同文件里 {field: null} 的 IS NULL 自相矛盾 #5332
Description
Activity
分诊结论
分类:
pm:queue(具体缺陷,位置明确,前提未过期)
领域:domain:services(packages/services/service-analytics)验证:已
git fetch origin main并用git show origin/main:packages/services/service-analytics/src/strategies/filter-normalizer.ts核对当前代码——fieldLeaves的算子循环里,$eq/$ne等走MONGO_TO_CUBE_OP映射表统一处理(packages/services/service-analytics/src/strategies/filter-normalizer.ts:378附近的const cubeOp = MONGO_TO_CUBE_OP[opKey]; … leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);),对null值没有特判;只有raw === null(裸{field: null})、以及显式的$null/$exists才走notSet/set分支。stringifyForCube对v == null返回''(:333 附近)。因此{stage: {$eq: null}}确实编译成leaf('equals', [''])→stage = $1/[''],与 issue 描述完全一致,前提未被近期合入的 #5325(PR #5335)、#5334(PR #5355)等修复覆盖——那两单的范围面在 TSDoc 里明确写明排除了这个「比较数为 null」的分支。截至origin/main最近三次涉及本文件的提交(#5322/#5352/#5081 系列)均未触及此路径,缺陷仍然存在。去重:搜索了 objectstack/objectui/cloud 三仓的 open issue 与 PR,未发现针对本 issue 的在飞 PR 或影子认领。同批相关的 #5288(
getObjectDatasource探针点错库)与 #5333(/analytics/sql回显丢失$startsWith/$endsWith)经核对是不同缺陷:#5288 是 datasource 解析层面,#5333 是objectql-strategy.ts回显 SQL 的另一个映射表缺口(buildFilterClauseSql/SCALAR_SQL_OPS),均与本 issue 的fieldLeaves/stringifyForCubenull 比较数问题不重叠,不构成重复。已关闭的 #5373(driver-memory 侧同形状但更差的版本)也是不同包,issue 正文自己已经标注了关系,不重复处理。跨领域:改动集中在
service-analytics包内(filter-normalizer.ts本体 + 建议里提到的read-scope-sql.ts同步项也在同一个包),不判定为跨领域,无需拆分。本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
认领:PM 循环第 1 轮(services 车道)
会话:session_01BWS4heBoAitLmzCLhcYdbK
分支:claude/issue-5332-analytics-null-eq
Worktree:objectstack-issue-5332
域:domain:services
文件面:packages/services/service-analytics/src/strategies/filter-normalizer.ts、packages/services/service-analytics/src/strategies/read-scope-sql.ts(两条value === nullarm 同步)、同包测试、.changeset/*.md注:同包的 #5333(objectql-strategy.ts 回显缺
$startsWith/$endsWith)按同包串行规则排下一轮,本单不碰objectql-strategy.ts。
Generated by Claude Code
复核:ACCEPT → PR #5525(draft,待 CI 全绿后转 ready 入合并队列)。
已核项:
- 前提逐条对 origin/main(基线
308c70951)核验成立,且当天同文件的 fix(service-analytics)!: 作者的where也 NULL-safe ——$not下推守卫、{$not:{}}为零行、{}析取项吸收$or(#5325) #5335/fix(service-analytics):where为 FilterArray 时下沉而不是静默丢弃(第五道门,#5334) #5355 系列确未覆盖此路径; - 修法正确性有契约论证兜底:
driver-mongodb的 translator 把$null: true直接改写成{$eq: null},四家后端(read-scope-sql / driver-sql / driver-memory / formula)同法编译 —— 本模块是一个包里唯一唱反调的那一半,故这是对齐、不是新决策,无需升级; $not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146 守卫表与 emitter 同 commit 移动,并有专门用例钉住(否则{$not: {$eq: null}}会对「非空」返回全表 —— 这正是 issue 说 service-analytics 的第二个 SQL 编译器filter-normalizer.buildNode仍带着 #5297 的三条分叉:$not非 NULL-safe、{$not:{}}不加 WHERE、$or的{}析取项被丢 #5325 刻意不钉、以免钉死错答案的那两条 id,现按三个兄弟测试文件在同 fixture 上的实测行集补钉);- 范围守住:未碰
objectql-strategy.ts(/analytics/sql回显的 SQL 丢掉$startsWith/$endsWith谓词:回显比实际执行的查询更宽,无法复现结果 #5333 下一轮)、read-scope-sql.ts经查已是正确一半无需改(比申报面更窄);stringifyForCube的v == nullarm 刻意保留并写明理由; - 反向验证:仅留新测试、源码退回 main 形状 → 7 条红逐条复现 issue 实测表(含文本列上
$eq: null命中空串行的严重度实证);恢复修复后 47 文件 753 用例全绿; - tsc 直跑 7 个错误改前改后一致且全在未触碰文件 —— 台账漂移(3→7)是既有问题,按范围纪律未动,合理;
- 消费半径扫描完整(两个 strategy 消费者、rest 拒绝集不受影响、examples/content 无该形状 widget)。
顺带发现:#5526(
values: string[]往返对字符串比较数也有损,'007'绑成整数 7)已独立立单,本车道随后分诊;driver-sql 的疑似同形已核清无分叉,记录在 PR 里防止重查。
Generated by Claude Code
- 前提逐条对 origin/main(基线
- added a commit that references this issue
on Aug 8, 2026 - added a commit that references this issue
on Oct 7, 2026
修 #5325(
filter-normalizer.buildNode的$notNULL 安全 + 布尔常量)时,为了写$not的逐算子极性表必须逐个核对本文件每个算子实际编译成什么,在这一步发现的分叉。
不属于 #5325 的范围面(那一单是
$not/ 布尔单位元三条),按 Prime Directive #10单独记在这里,unassigned。
位置
packages/services/service-analytics/src/strategies/filter-normalizer.ts的fieldLeavesstringifyForCube。现象(实测)
stringifyForCube对null返回空字符串'':于是一个
null比较数经算子映射后变成了一次普通值比较。同一个「这个字段是空」的意思,在同一个文件里有两个答案。经
NativeSQLStrategy.generateSql实测(cubedeals,列stage):where{ stage: null }stage IS NULL[]{ stage: { $eq: null } }stage = $1['']stage IS NULL{ stage: { $ne: null } }stage != $1['']stage IS NOT NULL{ stage: { $null: true } }stage IS NULL[]两条 objectql 路径同样:
renderFilterNodeSql回显stage = $1/[''],filterNodeToCondition交给引擎的是{stage: ''}(经coerceFilterValueForObjectQL('')→''),即拿空串去和存储里的null比 —— 永不匹配。为什么是 bug
{field: null}走fieldLeaves的raw === null分支 →notSet(
IS NULL),而{field: {$eq: null}}走算子映射 →= ''。两种写法在filter.zod.ts里是同一个意思。read-scope-sql.ts的compileOperator(同包)对$eq: null明确给
IS NULL、$ne: null给IS NOT NULL;driver-sql的nullValueSatisfiesOperator也是按「
$eq: null就是 null 谓词」写的。这个包的另一半和它们一致,这一半不一致。存了空串),作者看到的是「没有数据」而不是任何错误。
''是一个真实的值,所以{stage: {$ne: null}}会把stage = ''的行排掉,而它本意是「非空」。与 #5325 的关系(为什么那一单没顺手改)
#5325 的守卫表必须「跟随本文件的 emitter」而不是跟随
read-scope-sql的 —— 这是sql-driver.ts早就写明的不变量(每个 guard 匹配自己的 emitter)。所以那一单里$eq/$ne的 null 比较数被当作普通值比较来定极性,并在 TSDoc 里注明「
''比较数本身是另一个缺陷,单独记录、本单不裁定」。本单就是那条记录。改法一旦落地,#5325 的
nullValueSatisfiesOperator/operatorIsNullTotal需要同步补回read-scope-sql里那两条value === null的 arm($eq: null/$ne: null变成 null-total→ guard
'none'),两处一起改才自洽。filter-normalizer-not-null-safe.test.ts里没有钉住
{$not: {stage: {$eq: null}}}的行集,正是为了不把当前这个错误答案钉死。建议
fieldLeaves在算子循环里先判null比较数:$eq: null→leaf('notSet', [])、$ne: null→leaf('set', []),与raw === null分支和read-scope-sql对齐。stringifyForCube的v == null → ''保持不动(它还服务于别的调用点),或改成让null无法进入值数组。严重度请 PM 按 triage 定 —— 我只测了它编译成什么和取到什么行,没有统计现网有多少
{$eq: null}形状的 widget。关联:#5325(同函数、同一轮核对里发现)、#5297 /
read-scope-sql.ts(同包里正确的那一半)、#5146 / #5296(
$not的 NULL 语义)、#4128(同文件上一轮「算子静默丢失」)。