Skip to content

空分组桶的键两条路不一致:下推 SQL 给 null,内存兜底给 "(null)"(不限日期分桶) #3839

Description

@os-zhuang

同一个数据集,同一个查询,分组键为空的那个桶,下推 SQL 给 null,内存兜底给字符串 '(null)'engine.aggregate 按查询逐次在两条路之间挑一条,所以同一个仪表盘会因为走了哪条路而拿到不同类型的桶键。

实测(better-sqlite3,两行:一行有值、一行 at/stage 均为 NULL):

--- 日期分桶 groupBy (dateGranularity: 'month') ---
  SQL      : [{"key":null,     "type":"null",   "total":2}, {"key":"2026-01","type":"string","total":1}]
  in-memory: [{"key":"(null)", "type":"string", "total":2}, {"key":"2026-01","type":"string","total":1}]

--- 普通 groupBy (['stage']) ---
  SQL      : [{"key":null,     "type":"null",   "total":2}, {"key":"won","type":"string","total":1}]
  in-memory: [{"key":"(null)", "type":"string", "total":2}, {"key":"won","type":"string","total":1}]

比最初以为的宽:这不是日期分桶专属。普通 groupBy 对空值列一样分歧 —— 度量总额两边都对(2 和 1),坏的只有键的类型和字面量

两侧各自来自哪

  • 内存侧产出 '(null)',两个来源:
    • packages/objectql/src/in-memory-aggregation.ts:87projectGroupValue:v == null ? '(null)' : String(v)(普通 groupBy)
    • packages/objectql/src/in-memory-aggregation.ts:211bucketDateValue:if (value == null) return '(null)'(日期分桶)
  • SQL 侧产出 SQL NULL:分组列本身为 NULL,或 strftime(...) 对 NULL 输入返回 NULL。驱动不做转换,原样交出。

引擎按什么挑路

engine.aggregate(packages/objectql/src/engine.ts)下推的条件是:每个结构化 groupBy 项的 granularity 都被 supports.queryDateGranularity 声明,不是非 UTC 时区。否则走 driver.find() + applyInMemoryAggregation。所以同一个数据集:

  • SQLite + UTC + month → 下推 → 键是 null
  • 同一个查询换成 week(SQLite 不声明)或换个非 UTC 时区 → 内存 → 键是 '(null)'
  • driver-rest / driver-memory / Turso remote(queryDateGranularity: {})→ 恒内存 → 恒 '(null)'

也就是说换一个时区、换一个粒度、换一个驱动,同一份数据的空桶键就换一种形状

#3773 的关系:正交,且刻意未纳入

#3773/#3813 修的是"桶标签算错"。这一条不是算错——两边都正确地表示了"空",只是表示法不同。所以 checkDateBucketParity(packages/verify/src/date-bucket-parity.ts)的 fixture 刻意不含 null 实例,并在注释里写明了理由:放进去会让那道门为一个它不负责的原因而红

修这条时,顺带把那条排除去掉,让 fixture 带一个 null 实例,门就能长期守住收敛后的约定。落点在 date-bucket-parity.tsFIXTURE 注释处。

往哪边收敛:未定,而且现有依据站不住

注释 in-memory-aggregation.ts:25'(null)' 是「to remain consistent with the client useReportData hook」。核实结果:

  • useReportData 确实存在,在 objectuipackages/plugin-report/src/index.tsx;
  • 但该文件里 grep "(null)" 无任何命中 —— 这个字面量不在它里面。

所以"客户端约定"这条依据需要重新核实,不能直接拿来定方向。两个方向都要先回答"谁在依赖现状":

  1. 内存侧改成产出 null —— 与 SQL 对齐,类型也更诚实(空就是空)。风险:任何按 '(null)' 字符串匹配的下游会静默失配。
  2. SQL 侧改成产出 '(null)' —— 与现有内存约定对齐,前端不必区分。风险:把一个哨兵字符串塞进本可为 null 的数据列,且要在每个驱动上实现。

一个已知的相关点:packages/services/service-analytics/src/strategies/cross-object-rebucket.ts:124String(row[f] ?? '(null)') 构造内部分组键,所以它在重新分桶时会把两种形状合并成同一个桶(不会因为形状不同而分裂);但输出仍然 bucket[f] = row[f],原样保留,所以分歧照旧传到下游。

建议

先做调查再改:枚举 '(null)' 与"空桶键"的全部消费方(framework + objectui + cloud 三仓),确认现状到底有没有人依赖,再定方向。这条不阻塞任何东西 —— 度量值一直是对的,坏的只有一个空桶的标签形状。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions