Skip to content

Field.summary 的 count 汇总:从未有过子记录的父行停在 NULL,删光子记录才变 0 —— 同一个「零」两种值,筛选 = 0 静默漏行 #5749

Description

@os-zhuang

浏览器 dogfood 时发现(最新 main 5e3c83bd0 + objectui 5a24ad9cb89c,showcase 应用,SqlDriver/better-sqlite3)。

现象

Field.summary({ function: 'count' }) 汇总字段,同一个「零个子记录」的状态会有两种不同的值

父行经历 子记录数 task_count
A. 新建后从未有过子记录 0 null
B. 加了 1 条子记录 1 1
C. 把那条子记录删掉 0 0

A 和 C 是同一个逻辑状态(该项目下 0 条任务),却读出两个不同的值。count 在空集上有定义且等于 0,null 是「未知」,二者不是一回事。

影响:筛选静默漏行

这不是显示问题。showcase 种子数据里 Legacy Sunset 就是 A 类(从未有过任务):

零任务的项目共 2 个:
  Legacy Sunset  → task_count = NULL
  ROLLUP PROBE   → task_count = 0

filter ["task_count","=",0]  → 只返回 ["ROLLUP PROBE"]
filter ["task_count","<",1]  → 只返回 ["ROLLUP PROBE"]

也就是说「列出所有还没有任务的项目」这个再普通不过的查询,恰好漏掉了那些从来没建过任务的项目 —— 而那正是现实中这个查询最想找的一批行。漏掉的是行,不是格式,且无任何报错。

同理受影响的还有:排序(null 与 0 落在不同分组)、GROUP BY、以及任何以该字段为输入的公式字段(null 会继续向下传播)。

真实原因

packages/objectql/src/engine.tsrecomputeSummaries()

  • 第 4225 行的兜底是对的 —— if (value == null) value = (desc.fn === 'count' || desc.fn === 'sum') ? 0 : null;,聚合返回空时确实会落成 0。这也正是上表 C 能拿到 0 的原因。
  • 问题在第 4202–4205 行的父行选取:待重算的 parentId 集合只从本次写入的子记录里取recsprevsdesc.fkField):
const ids = new Set<string>();
for (const r of recs)  { const v = r?.[desc.fkField]; if (v != null && v !== '') ids.add(String(v)); }
for (const p of prevs) { const v = p?.[desc.fkField]; if (v != null && v !== '') ids.add(String(v)); }

于是一个从未出现在任何子记录写入里的父行,永远不会进入 ids,它的汇总字段一次也不会被写过,就停留在插入时的默认值 null

删除子记录之所以能得到 0,是因为被删的那条子记录进了 prevs,父行因此被选中重算了一次 —— 这恰好反证了:坏的不是兜底逻辑,而是父行选取这一步

descriptors 是按子对象索引的(getSummaryDescriptors(childObject)),所以父对象自己 insert 时也不会顺带初始化自己的汇总字段。

复现

showcase 应用,登录后在浏览器控制台跑(account 为必填、status 受状态机约束,故用 planned):

const call=(m,u,b)=>fetch(u,{method:m,credentials:'include',headers:{'content-type':'application/json'},body:b?JSON.stringify(b):undefined}).then(r=>r.json());
const o={};
call('GET','/api/v1/data/showcase_account?top=1')
 .then(r=>{o.acct=r.records[0].id; return call('POST','/api/v1/data/showcase_project',{name:'ROLLUP PROBE',account:o.acct,status:'planned',health:'green',budget:1});})
 .then(r=>{o.id=r.id; return call('GET','/api/v1/data/showcase_project/'+o.id);})
 .then(r=>{o.A_从未有子记录=r.task_count;  // → null
   return call('POST','/api/v1/data/showcase_task',{title:'PROBE',project:o.id,estimate_hours:7,status:'todo'});})
 .then(r=>{o.tid=r.id; return call('GET','/api/v1/data/showcase_project/'+o.id);})
 .then(r=>{o.B_一条子记录=r.task_count;    // → 1
   return call('DELETE','/api/v1/data/showcase_task/'+o.tid);})
 .then(()=>call('GET','/api/v1/data/showcase_project/'+o.id))
 .then(r=>{o.C_删光后=r.task_count; return o;});  // → 0

实测输出:{A_从未有子记录: null, B_一条子记录: 1, C_删光后: 0}

建议方向(实现者自选)

  1. 父行 insert 时初始化汇总字段:以父对象为索引再取一份 descriptor(当前 getSummaryDescriptors 只按子对象索引),在父行创建时把 count/sum 落成 0。语义最正,且一次性解决存量以外的所有新数据。
  2. 读取时兜底:查询投影阶段把 count/sum 型 summary 字段的 null 视作 0。改动小,但治不了筛选 —— filter task_count = 0 是在库里比对的,除非把兜底下推进 SQL(COALESCE),否则漏行照旧。因此若选这条,必须同时处理筛选下推。
  3. 存量数据需要一次回填(对所有汇总字段为 null 的父行重算一遍)。

个人倾向 1 + 3:让「零」在库里就是 0,筛选、排序、公式全都自然正确。

影响面

Field.summarycount / sum 汇总字段,所有驱动通用(这是 objectql 引擎层,不是 SQL 驱动层)。任何「按汇总值筛选/排序」的列表视图、报表、看板都可能少行。本次在 showcase 的 showcase_project.task_count 上复现,total_estimatesum)同样受影响(同一父行为 null)。

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions