Skip to content

fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) - #6013

Queued
baozhoutao wants to merge 3 commits into
mainfrom
claude/issue-5749-summary-count-null-zero
Queued

fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749)#6013
baozhoutao wants to merge 3 commits into
mainfrom
claude/issue-5749-summary-count-null-zero

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5749

前提复核(先于实现)

issue 正文的两段定位在 origin/main仍然成立,只是行号漂了(engine.ts 今天已合多个 PR),按内容定位:

  • 空集兜底(原文 :4225)现在在 packages/objectql/src/engine.tsrecomputeSummaries() 内:if (value == null) value = (desc.fn === 'count' || desc.fn === 'sum') ? 0 : null; —— 是对的,C 态(删光子记录)拿到 0 就是靠它。
  • 父行选取(原文 :4202-4205)现在是 recomputeSummaries() 里的 for (const r of recs) ... for (const p of prevs) ... 两行:待重算的 parentId 只从本次写入的子记录(以及被删子记录的 previous)里取。
  • getSummaryDescriptors() 确实只按子对象索引,父对象自己 insert 时拿不到自己的汇总字段。

所以「坏的是父行选取、不是兜底」这个判断成立,方案 1(父行 insert 时落初值)是对的方向。

改了什么

生产端修,三处:

  1. buildSummaryIndex() 现在一次扫描产出两个视图(byChild / byParent),存放的是同一批 descriptor 对象。子对象索引的语义一个字没动 —— getSummaryDescriptors(childObject) 行为完全不变,只是把「有没有过期」的判断抽成了 ensureSummaryIndexes(),让父侧视图共用同一条过期规则(cloud#970 那条运行时发布的过期规则,父侧同样需要,已加测试)。
  2. 新增 initializeSummaryFields(object, record):按父对象取出该行自己拥有的汇总字段,把 count / sum 落成空集合的值 0
  3. insert()applyFieldDefaults 之后、beforeInsert 钩子之前调用它 —— 与 defaultValue 完全同一个挂点、同一套规则。

另外把空集函数清单提取成模块级的 summaryEmptySetValue(fn),recomputeSummaries 的兜底改为调用它。这不是改兜底逻辑:表达式逐字等价,只是让「插入初值」和「重算兜底」读同一份清单 —— 两个地方各写一份 fn === 'count' || fn === 'sum' 正是「A 态和 C 态读出两个值」这类 bug 的温床。min / max / avg 在空集上没有定义,两边都仍然是 null

边界(逐条对应 issue 的取舍)

  • 作者显式提供的值不被覆盖:判断条件是 != null,与 applyFieldDefaults 完全同口径([objectql] 字段 defaultValue 语义:显式 null 不回填、解析晚于 hook、表单不预填 current_user #2706:insert 时 undefined 与显式 null 都算「未提供」)。beforeInsert 钩子在其后运行,仍有最终决定权(两条都有测试)。
  • 落初值 ⇔ 会被重算维护,是同一个集合:buildSummaryIndex 里解析不出子->父 FK 的 descriptor 会被 continue 跳过,它不进任何一个索引,所以也不会被落初值。否则就会出现一个「没人维护的 0」—— 那比 null 更像谎言。
  • 存量数据不在本 PR 范围:这是 create-time 初始化,已经存成 null 的老父行仍然是 null,直到某次子记录写入把它重算。实测确认这不是方案 1 的强依赖:方案 1 对新数据一次性全对,存量回填是独立取舍,建议另行立单。
  • 没有碰 recomputeSummaries 的父行选取逻辑。实现过程中未发现「必须同时改选取才正确」的情形:父行选取的职责是「谁被写了就重算谁」,它对「从未被写过的行」结构上就无话可说 —— 补的应该是初始化,不是把选取扩成全表扫描。

测试

packages/objectql/src/summary-rollup.test.ts 新增 8 个用例,复用文件里已有的 memory driver(没有引入新的 fake engine,check:engine-double-contract 绿):

  • issue 表格的 A/B/C 三态:A 态 insert 后即为 0(返回值与落库值都断言了),B=1,C 删光后=0,并直接断言 A === C;total_estimate(sum)同款。
  • ["task_count","=",0]["task_count","<",1] 两个筛选:A 态行进结果集,有子记录的行仍被排除。
  • avg/max 在有子记录前后都保持 null(口径 pin)。
  • 作者显式提供 task_count: 7 不被覆盖;批量 insert 每行都落初值、已提供的那行不动;beforeInsert 钩子仍能覆盖。
  • 关系解析不出来的汇总字段不落初值。
  • 索引已被前一次写入预热后再发布的父对象,照样落初值。

反向验证(方向先预测、后运行)

预测:去掉 insert 里的初始化调用 -> 恰好 4 个用例转红(A/C 一致性、=0/<1 筛选、批量、运行时发布的父对象);另外 4 个断言的是「不该发生的事」(不覆盖作者值、avg 仍为 null、无法解析不落初值、钩子优先),它们是护栏而不是本次修复的 pin,应当保持绿

实测与预测逐条一致:

× reads the SAME value for "never had a child" (A) and "had one, deleted it" (C)
  -> expected undefined to be +0
× `= 0` and `< 1` filters no longer drop the parent that never had a child
  -> expected [ 'ROLLUP PROBE' ] to deeply equal [ 'Legacy Sunset', 'ROLLUP PROBE' ]
× seeds every row of a batch insert, and only the unsupplied ones
× seeds a parent published AFTER the summary index was already warmed
✓ (其余 4 个 + 全部既有 summary 用例)
Tests  4 failed | 13 passed (17)

第二条的失败信息就是 issue 描述的现象本身:同一个查询只返回了 ROLLUP PROBE,Legacy Sunset 整行消失,无任何报错。

命令与结果

pnpm --filter @objectstack/objectql test        -> Test Files 130 passed (130) / Tests 2155 passed (2155)
pnpm --filter @objectstack/objectql typecheck   -> clean
pnpm --filter @objectstack/runtime test         -> Test Files 102 passed (102) / Tests 1474 passed (1474)
node scripts/check-engine-double-contract.mjs   -> OK (72 pinned, 133 DEBT, 2 exempt)
node scripts/check-query-options-erasure-ratchet.mjs -> ratchet holds(测试面 267,未增)
node scripts/check-nul-bytes.mjs                -> OK

packages/runtime 那一轮是特意跑的:bulk-write-real-driver.integration.test.ts 用的是真实 SqlDriver/better-sqlite3,验证了初值 0 能正常写进真实建表出来的列(汇总列本来就是物理列 —— 重算就是靠 update 写它的)。

changeset

@objectstack/objectql patch。行为变化:新建父行的 count/sum 汇总从 null 变 0;changeset 里写明了存量数据不受本 PR 影响、回填另行处理。


Generated by Claude Code

claude added 2 commits August 6, 2026 14:48
`recomputeSummaries()` only ever visits parents named by a CHILD write
(`recs`/`prevs` -> `desc.fkField`), so a parent that has never had a child
is never visited and its summary column keeps insert's `null`. Delete the
last child and the parent IS visited (via `previous`) and lands on 0 — one
logical state, two values. The consequence is not cosmetic: `= 0` / `< 1`
filters compare in the database and silently DROP every parent that never
had a child; sorting, GROUP BY and formula fields reading it inherit the
same null.

Fixed at the producer. `buildSummaryIndex` now publishes the identical
descriptors under a second, parent-side view, and `insert` seeds the
count/sum summaries a new row OWNS with the empty-collection value right
after `applyFieldDefaults`. The empty-set function list is extracted to
`summaryEmptySetValue` so the insert seed and the recompute fallback read
ONE list — min/max/avg have no empty-set value and stay `null`, unchanged.

Boundaries: author-supplied values are never overwritten (same `!= null`
rule as `applyFieldDefaults`, #2706) and `beforeInsert` still has the final
say; a roll-up whose relationship cannot be resolved is not seeded, so
"seeded" and "maintained by recompute" stay the same set; existing rows are
untouched — this is create-time only and backfill is a separate decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 3:29pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

合并 main 后完整重跑(中断前的绿一律作废)

分支中途因用量上限中断过一次,恢复后按流程重来了一遍:git merge origin/main(⛔ 未 rebase),合入了 #5991 / #6000 / #6003 / #6006 / #6004 / #5973,无冲突。pnpm install --frozen-lockfile + 重建依赖(packages/spec 这一侧动过,所以是真重建不是复用产物)后,前台阻塞重跑:

pnpm --filter @objectstack/objectql test        -> Test Files 130 passed (130) / Tests 2155 passed (2155)
pnpm --filter @objectstack/objectql typecheck   -> tsc --noEmit,无输出
pnpm --filter @objectstack/runtime  test        -> Test Files 102 passed (102) / Tests 1476 passed (1476)
npx eslint packages/objectql/src/{engine.ts,summary-rollup.test.ts} -> clean
node scripts/check-nul-bytes.mjs                -> OK (5780 files)
node scripts/check-engine-double-contract.mjs   -> OK — 73 pinned / 133 DEBT / 2 exempt
node scripts/check-query-options-erasure-ratchet.mjs -> ratchet holds(测试面 267,未增)

(runtime 从 1474 变 1476 是 main 自己新增的用例,不是本分支的。)

反向验证也在合并后重跑了一遍,方向与首次一致

预测不变:去掉 insert 里的初始化调用 -> 恰好 4 个用例转红,另外 4 个(断言「不该发生的事」的护栏)保持绿。实测:

× reads the SAME value for "never had a child" (A) and "had one, deleted it" (C)
× `= 0` and `< 1` filters no longer drop the parent that never had a child
× seeds every row of a batch insert, and only the unsupplied ones
× seeds a parent published AFTER the summary index was already warmed
Tests  4 failed | 13 passed (17)

这次把「改 -> 跑 -> 还原」放在同一条命令里原子执行,还原后以 git diff --stat 为空 + 调用点计数为 2 作为凭据 —— 上一轮正是死在「还原」这一步上。

skip-changeset 标签不适用:本 PR 带 .changeset/summary-count-zero-on-parent-insert.md(@objectstack/objectql patch),会发版。读回当前标签集为 ["size/m"](labeler 打的),没有做任何标签写入。


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review August 6, 2026 16:18
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 6, 2026
Any commits made after this event will not be merged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants