fix(service-analytics): compareTo 带上 measure 自己的 filter,__compare 列不再是另一个 measure (#4820) - #4870
Conversation
…<measure>__compare is the same measure as its neighbour (#4820) A measure declared with its own `filter` was scoped by a supplementary grouped sub-query in the current period, but the `compareTo` pass issued ONE shifted query over every base measure with only the base filter — `measureFilters` was never read on that path. `won_count` counted won deals while `won_count__compare` counted every deal, side by side under one label, biased so the comparison window always looks better. Both windows now run the same `runMeasurePass`: unfiltered measures in one query plus one sub-query per filter-scoped measure, merged by dimension key. The split lives in one exported `splitMeasuresByFilter` so the two paths cannot re-diverge; the only difference between them is the shifted dateRange. Tests build a fake service that really evaluates `where` and groups seed rows, so the pinned number changes with the fix (won_count__compare 5 -> 1), not just the query shape. Selections whose measures carry no filter still compare in a single shifted query. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…w NUL byte `check:nul-bytes` (run inside the ESLint job) rejected a literal 0x00 written into the fake service's group key. A raw NUL makes grep/ripgrep treat the whole file as binary and return zero matches, so the file drops out of code search and every grep-based lint; git only scans the first 8000 bytes, so at offset 5440 it still diffed as text. Keyed with `JSON.stringify(...)` joined by `|` instead, which needs no exotic byte at all and is unambiguous for the multi-dimension case. Deliberately NOT the production `mergeByDimensions` key, whose delimiter-free concatenation is #4821 — the fake must not import a second defect into a test for this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
复核通过 —— ACCEPT,已标 ready 并送合并队列1. 没有长出第二套规则 —— 这是本单最重要的要求我派发时写的是:「如果两条路径各自实现一遍『怎么应用 measure filter』,下一个缺陷就在它们再次分叉的地方。」 实际做法是抽出 2. 假 service 真的在求值 —— 这是「数值可区分」要求的正确做法
而且种子设计得很巧:让 反向验证给出的是用户真正看到的东西:
一个「本月赢单 vs 上月」的卡片,修复前显示的是暴跌,实际是增长。断言查询形状证明不了这个,断言数字才行。 无 filter 的 measure 用例修复前后都通过(仍只发 2 条查询),主路径未误伤。 3. 与 #4708 的接缝处理对了基于 PR #4822 合并后的 main 工作, 4. NUL 字节首轮 CI 的 ESLint 红是 顺带记一个今天学到的东西:裸 NUL 会让 git 把文件判为二进制,于是 GitHub 上这个测试文件的 diff 显示成 5. changeset
|
…op merging "unassigned" into "blank" (objectstack-ai#4821) (objectstack-ai#4957) `mergeByDimensions` is the seam every multi-query dataset result is assembled through: the primary pass against each measure-scoped supplementary pass, and — since objectstack-ai#4870 — the current window against the shifted `compareTo` window, which now fans out per measure the same way. A key collision there does not fail; one group silently absorbs another's numbers. The reported mechanism was not quite the real one, which is worth recording. The old key was `String(row[d] ?? '')` joined on a RAW U+0001 byte written literally into the source. A raw control byte renders as nothing, so objectstack-ai#4821 was filed reading `join('')`, and its headline repro (`['ab','c']` vs `['a','bc']` both keying "abc") never actually reproduced — the separator was there, merely invisible. Two things did: - `?? ''` keyed a genuinely NULL dimension the same as an empty-string one, so "unassigned" merged into "blank": one row absorbed the other's measure and the other's column went absent — which objectstack-ai#4708's empty-group fill then turns into a confident 0. A group whose real count is 3 renders as 0. - A one-character separator is unambiguous only while no dimension VALUE contains it, and dimension values are user data (text fields, imports). Fixed by length-prefixing each segment (`2:ab1:c` vs `1:a2:bc` differ for every possible input, no character is reserved, and no invisible byte is left in the source for the next reader to misread) plus an explicit sentinel for null/undefined, kept separate from the disambiguation concern. The per-segment `String()` coercion is deliberately KEPT, and it is not the trade-off `cross-object-rebucket.ts` makes one file over. That function re-buckets ONE query's rows, where a column carries one type, so its JSON key is free and buys a real distinction. This key aligns rows across DIFFERENT queries, and drivers do type the same group differently across them — this file's own `compareValues` records it ("numeric strings, which is how some drivers return SUM results"). A `JSON.stringify` key would render `1` and `'1'` as two keys and split groups that merge correctly today, trading one silent defect for a new one. Pinned by a regression test. Tests: 9 new cases in `dataset-merge-dimension-key.test.ts` — the adjacent-value pair, a value carrying the old separator, null vs empty string, an absent dimension column, the numeric-vs-string regression pin, and the same properties through the real executor on both the measure-filter and `compareTo` merges. 5 of the 9 fail against the pre-fix implementation. Fixes objectstack-ai#4821 Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX Co-authored-by: Claude <noreply@anthropic.com>
Fixes #4820
问题
带
filter的 measure,在当前期是靠一条补充分组子查询(combineFilters(baseFilter, compiled.measureFilters[m]))来限定范围的;但compareTo那条路径只发一条涵盖全部 base measure 的位移查询,where里只有baseFilter——compiled.measureFilters在那条路径上从未被查阅过。于是同一个 measure,当前期这一列被限定了,对比列没有:
revenuewon_count{"stage":"closed_won"}revenue,won_countwon_count__compare数的是上个窗口的全部商机,被这个 measure 本来要排除的行精确地放大了。误差只往一个方向走:对比期永远显得更好,所以「本月赢单 vs 上月」的卡片会在什么都没变坏的时候读作暴跌。只有带 filter 的 measure 错,旁边不带 filter 的照常正确 —— 这正是它能一直活着的原因。修法
没有在 compare 路径上再写一遍「怎么应用 measure filter」。两条路径现在走同一个
runMeasurePass:不带 filter 的 measure 一条查询,带 filter 的每个一条子查询,按维度键合并;splitMeasuresByFilter是「这个 measure 有没有自己的 filter」的唯一答案。两个窗口之间唯一的差别,就是位移过的dateRange。长出第二套规则正是这类缺陷复发的地方 —— 它们下一次改动就会再次分叉。所以抽的是公共函数,不是补丁。
数据集 filter、presentation 的
runtimeFilter、measure 自己的 filter,在两个窗口里以完全相同的方式组合。代价:设了
compareTo时,每个带 filter 的 measure 多一条查询。measure 都不带 filter 的 selection 完全不受影响,仍然只发一条位移查询(测试钉住了这一条)。与 #4708 的接缝:compare 路径现在也会扇出,所以它的补充子查询同样可能整组缺失。
fillEmptyGroups已经覆盖__compare列,上个窗口被 filter 清空的分组读作0(count/sum)而不是空白 —— 和当前期的行为一致,已加测试。测试
测试的假 service 不是按查询形状返回硬编码数字,而是一个小的内存库:它真的对种子行求值
where和dateRange,真的分组。所以丢掉 measure filter,数字会变 —— 这是本 PR 唯一值得钉的东西,查询形状证明不了用户看到的数是对的。种子刻意让
revenue在两个窗口都是 300(3×100 = 100 + 4×50),于是能让won_count__compare动的只剩「filter 有没有到位移查询」。改动前后跑同一组用例:
won_count查询whereclosed_won{ stage: 'closed_won' }won_count__compare修复前的实际报错(把 fix 临时回退成原来那条扁平查询跑出来的):
命令与结果:
changeset 等级:
patchsplitMeasuresByFilter没有加进src/index.ts,包的公开入口面一字未变,可观察到的变化就是__compare列的数字被改对了。作为对照,#4708 的 PR #4822 是minor,因为它确实新增了公开面(fillEmptyGroups进了 index、emptyGroupValueFor进了 spec);本 PR 没有对应的东西。范围
packages/spec/**零改动;未触碰packages/lint、skills/**、content/docs/**、content/docs/releases/。mergeByDimensions用无分隔符拼接维度键)就在本 PR 改的同一个文件里,本 PR 刻意不碰,留给它自己那一单。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Generated by Claude Code