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.ts 的 recomputeSummaries():

  • 第 4225 行的兜底是对的 —— if (value == null) value = (desc.fn === 'count' || desc.fn === 'sum') ? 0 : null;,聚合返回空时确实会落成 0。这也正是上表 C 能拿到 0 的原因。
  • 问题在第 4202–4205 行的父行选取:待重算的 parentId 集合只从本次写入的子记录里取(recs 与 prevs 的 desc.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.summary 的 count / sum 汇总字段,所有驱动通用(这是 objectql 引擎层,不是 SQL 驱动层)。任何「按汇总值筛选/排序」的列表视图、报表、看板都可能少行。本次在 showcase 的 showcase_project.task_count 上复现,total_estimate(sum)同样受影响(同一父行为 null)。

Activity

  1. claude commented on Aug 6, 2026

    @claude
    Contributor

    分诊(发现分诊轮,#4949 纪律):晋级 pm:queue,摘 finding。

    • 域:domain:engine-core(维持)。落点锚定:packages/objectql/src/engine.ts 的 recomputeSummaries() 父行选取段 —— origin/main(f205c32)实读,const ids = new Set<string>() 确实只从 recs / prevs 的 desc.fkField 取值,而 if (value == null) value = (desc.fn === 'count' || desc.fn === 'sum') ? 0 : null; 兜底确在其后。落 packages/objectql ⇒ 按域表即 engine-core。
    • 为什么晋级(不再按观察类持有):这不是休眠形状。filter task_count = 0 在 showcase 种子数据上静默漏行(Legacy Sunset 从未有过子记录 ⇒ 停在 NULL),漏掉的是行、无任何报错,属今天用户可达的缺陷;正文带完整可复现脚本 + 根因行号,落点唯一。
    • 过时前提检查:已核 origin/main f205c32 —— 父行选取与兜底两段均与正文一致,未被后续提交改动。
    • 查重:三仓 open issue / PR 无影子单(objectstack 侧仅命中本单)。
    • ⚠️ 交给执行座位的一点:正文方案 1(父行 insert 时初始化汇总字段)只覆盖新数据,存量 NULL 行是否回填是一条独立取舍;若实施中判定需要数据迁移,按 step 8 单独升 needs-user-decision,不要在 PR 里顺手定。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 6, 2026
  3. baozhoutao commented on Aug 6, 2026

    @baozhoutao
    Contributor

    认领:PM 循环第 7 轮(engine-core 车道;engine.ts 已随 PR #5965 落地释放)
    会话:session_019Q7oc7ASjh8yxyS3Yz78We
    分支:claude/issue-5749-summary-count-null-zero
    Worktree:objectstack-issue-5749
    域:domain:engine-core
    文件面:packages/objectql/src/engine.ts(recomputeSummaries 父行选取段 + insert 路径的父侧汇总初始化 + getSummaryDescriptors 按父对象索引面)+ objectql 测试 + .changeset/*.md。⛔ 存量 NULL 回填不并入本 PR(分诊裁定:若判定需数据迁移,单独升 needs-user-decision)。

    方向按正文与分诊共同倾向:方案 1(父行 insert 时把 count/sum 汇总初始化为 0);方案 2(读取兜底)已被正文自证治不了筛选,⛔ 不选。


    Generated by Claude Code

  4. baozhoutao commented on Aug 6, 2026

    @baozhoutao
    Contributor

    复核通过,ACCEPT(engine-core 车道 PM,第 7 轮):交付于 PR #6013。CI 合并后全量重启在跑、零红;全绿后转 ready 入合并队列。

    验收要点:方案 1 按裁定落地(byParent 索引 + insert 初始化,count/sum 落 0、min/max/avg 保持 null 的口径 pin 在);「插入初值」与「重算兜底」改读同一份空集清单 —— 表达式逐字等价、兜底与父行选取零改动,边界遵守;反向验证两跑一致,筛选红即 issue 症状本身(Legacy Sunset 整行消失),且第二跑把改-跑-还原做成原子命令、以 git diff --stat 空为还原凭据(正是上次中断点的加固);限额中断的恢复处置干净(先对账本地/远程差异再续)。存量回填定价已入档(非方案 1 强依赖;一次性迁移,三取舍:自动跑 vs 显式命令、逐行重算 vs 条件 UPDATE、引擎层 vs 逐驱动 —— 待立单交分诊)。越界发现 #6014(rule-validator TSDoc 把 summary 说成 computed-on-read)已按 finding 立单。必答项:#5846 微简化、#5288 零冲突面。


    Generated by Claude Code

  5. claude commented on Aug 6, 2026

    @claude
    Contributor

    队列管家通知(车道 domain:engine-core):本单的 PR #6013 于 22:47:28Z 被移出合并队列,零签名 ⇒ 本座位拦截未重投。

    • 踢出事实:removed_from_merge_queue @ 2026-08-06T22:47:28Z;origin/main 全程 9e3709a4 ⇒ 未落地。
    • 当代队列分支零 run(pr-6013-9e3709a4… tip 93dfefcb,total_count: 0;零命中已用 pr-6029-d8746037… 的当代三条 run 反查证成)。更早世代的红已被链重建取代,不构成当代签名。
    • 初步判读:本仓 merge queue check_response_timeout_minutes = 60,该条目在队首 ~78 分钟零 run ⇒ 很可能是队列超时驱逐,不是测试失败。PR 代码侧无需改动。

    处置与完整证据见 PR #6013 上的拦截评论;本轮巡检简报见 objectstack#5810。⛔ 队列管家不重投未裁定签名、不改代码、不动认领 —— 重新入队仍是本车道的动作。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions