Skip to content

Commit 2f05139

Browse files
claude[bot]claude
andauthored
fix(service-analytics): compareTo 带上 measure 自己的 filter,__compare 列不再是另一个 measure (#4820) (#4870)
* fix(service-analytics): compareTo applies measure-scoped filters, so <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 * fix(service-analytics): key the compare-filter test fake without a raw 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac37fc6 commit 2f05139

3 files changed

Lines changed: 542 additions & 50 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): `compareTo` applies measure-scoped filters, so `<measure>__compare` is the same measure as the column beside it (#4820)
6+
7+
A dataset measure declared with its own `filter` is scoped by running a
8+
supplementary grouped sub-query — `combineFilters(baseFilter, measureFilters[m])`
9+
— and merging it back by dimension key. The `compareTo` pass did not: it issued
10+
**one** shifted query over every base measure with only the base filter as its
11+
`where`, and never consulted `compiled.measureFilters` at all.
12+
13+
For a dataset like
14+
15+
```ts
16+
measures: [
17+
{ name: 'revenue', aggregate: 'sum', field: 'amount' },
18+
{ name: 'won_count', aggregate: 'count', filter: { stage: 'closed_won' } },
19+
]
20+
```
21+
22+
the current-period column was scoped and the comparison column was not — two
23+
different measures rendered side by side under one label:
24+
25+
| # | measures | where | |
26+
|:---|:---|:---|:---|
27+
| 1 | `revenue` || current |
28+
| 2 | `won_count` | `{"stage":"closed_won"}` | current |
29+
| 3 | `revenue`, `won_count` | **absent** | shifted |
30+
31+
`won_count__compare` was therefore a count of **every** opportunity in the
32+
previous window, inflated by exactly the rows the measure exists to exclude.
33+
The error runs one way: the comparison period always looks better, so a "won
34+
deals vs. last month" tile reads as a collapse when nothing went wrong. Only
35+
filter-scoped measures were affected — the unfiltered ones next to them compared
36+
correctly, which is what made it survive.
37+
38+
The comparison window now runs the **same pass** as the current period —
39+
unfiltered measures in one shifted query plus one shifted sub-query per
40+
filter-scoped measure, merged by dimension key — through a single shared
41+
implementation, so the two paths cannot re-diverge at the next change. The
42+
dataset filter, the presentation's `runtimeFilter` and the measure's own filter
43+
compose identically in both windows; the only difference between them is the
44+
shifted `dateRange`.
45+
46+
Numbers reported by existing dashboards change where a filtered measure was
47+
compared: with 3 won deals this month against 1 won of 5 opportunities last
48+
month, `won_count__compare` was `5` and is now `1`.
49+
50+
Cost: one extra query per filter-scoped measure when `compareTo` is set.
51+
Selections whose measures carry no filter are untouched and still compare in a
52+
single shifted query.
53+
54+
The empty-group fill (#4708) covers the new seam: a group the measure's filter
55+
empties in the *previous* window now reports `0` for a `count`/`sum` compare
56+
column rather than blanking it, exactly as it already did for the current period.

0 commit comments

Comments
 (0)