Repository navigation
Description
修 #5325(filter-normalizer.buildNode 的 $not NULL 安全 + 布尔常量)时,为了写 $not 的
逐算子极性表必须逐个核对本文件每个算子实际编译成什么,在这一步发现的分叉。
不属于 #5325 的范围面(那一单是 $not / 布尔单位元三条),按 Prime Directive #10
单独记在这里,unassigned。
位置
packages/services/service-analytics/src/strategies/filter-normalizer.ts 的 fieldLeaves
stringifyForCube。
现象(实测)
stringifyForCube 对 null 返回空字符串 '':
function stringifyForCube(v: unknown): string {
if (v == null) return '';
…
}于是一个 null 比较数经算子映射后变成了一次普通值比较。同一个「这个字段是空」的意思,
在同一个文件里有两个答案。经 NativeSQLStrategy.generateSql 实测(cube deals,列 stage):
where |
生成的 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 谓词」写的。这个包的另一半和它们一致,这一半不一致。 - 后果是静默错行,不是报错:一条「阶段为空」的 widget filter 取到零行(除非真有列
存了空串),作者看到的是「没有数据」而不是任何错误。 - 对文本列尤其危险:SQLite / MySQL 里
''是一个真实的值,所以
{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(同文件上一轮「算子静默丢失」)。
Activity
os-zhuang commentedon Aug 5, 2026
分诊结论
分类:
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
os-zhuang commentedon Aug 5, 2026
认领:PM 循环第 1 轮(services 车道)
会话:
session_01BWS4heBoAitLmzCLhcYdbK分支:
claude/issue-5332-analytics-null-eqWorktree:
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
os-zhuang commentedon Aug 5, 2026
复核:ACCEPT → PR #5525(draft,待 CI 全绿后转 ready 入合并队列)。
已核项:
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 刻意保留并写明理由;$eq: null命中空串行的严重度实证);恢复修复后 47 文件 753 用例全绿;顺带发现:#5526(
values: string[]往返对字符串比较数也有损,'007'绑成整数 7)已独立立单,本车道随后分诊;driver-sql 的疑似同形已核清无分叉,记录在 PR 里防止重查。Generated by Claude Code
fix(service-analytics): a `null` comparand is a null predicate, not `…
fix(service-analytics): carry filter comparands at their own type, no…
fix(analytics): refuse an `undefined` comparand in the `where` door i…
fix(service-analytics): the ObjectQL strategy hands the engine its nu…