Skip to content

Commit eb26ae5

Browse files
committed
fix(service-analytics): a window-only timeDimensions entry no longer buckets the grid (#5688)
A `timeDimensions` entry carrying only a `dateRange` — a dashboard date-range filter, with the dimension absent from `selection.dimensions` — had the dataset dimension's declared `dateGranularity` filled in, which made it a GROUP BY item: the response grew a time column nobody selected and rows split per bucket, so "count by Owner" plus a date filter came back as "by Owner x month". Narrow the backfill: an entry that states no `granularity` gets one only when the request says that date is being bucketed — the dimension is a grid dimension of this query, or `selection.dateGranularity` is set. An entry carrying its own granularity is unaffected and stays projected (#4033). compareTo alignment (#3588/#4870) holds by construction: the comparison pass re-enters `buildQuery` with the same grid dimensions, so both passes bucket an entry alike or not at all. For a window-only anchor this repairs the comparison — the merge keys on `dimensions` alone, so the backfilled bucket column sat outside the key and the comparison value landed on whichever split row the index held last. Pinned as a pair, both verdicts, both passes. Also closes the same-root-cause label blind spot: a time column projected via `timeDimensions` now carries its dataset `label` in `fields`. The enrichment lookup widens; the projection does not, so a window-only entry still mints no descriptor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
1 parent 628b028 commit eb26ae5

5 files changed

Lines changed: 644 additions & 41 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): a `timeDimensions` entry used only as a date WINDOW no longer buckets the grid (#5688)
6+
7+
**Observable behaviour change — read this if you render, page, or assert on
8+
dataset responses.** A selection that used a date dimension only as a window —
9+
`timeDimensions: [{ dimension, dateRange }]` with no `granularity`, and the
10+
dimension NOT listed in `selection.dimensions` — used to have the dataset
11+
dimension's declared `dateGranularity` filled in anyway. That made the entry a
12+
`GROUP BY` item, so the response grew a time column nobody selected and every
13+
row split per bucket. "Count by Owner" plus a dashboard date-range filter came
14+
back as "by Owner × month":
15+
16+
```
17+
before fields [owner, close_date, opp_count]
18+
rows [{owner:'u1', close_date:'2026-01', opp_count:1},
19+
{owner:'u1', close_date:'2026-02', opp_count:1},
20+
{owner:'u2', close_date:'2026-01', opp_count:1}]
21+
22+
after fields [owner, opp_count]
23+
rows [{owner:'u1', opp_count:2},
24+
{owner:'u2', opp_count:1}]
25+
```
26+
27+
Both the **row count and the column set** change for such a selection: the extra
28+
month column disappears and rows that were split per bucket collapse back into
29+
one row per selected dimension tuple. A KPI single-value card that was reading
30+
the first of several month rows now reads the only row. Consumers that pinned
31+
the previous shape (a snapshot of `fields`, a row count, a hard-coded column
32+
index) need updating; consumers that render the response's own `fields` do not.
33+
34+
Three conditions had to hold together to be affected, so a selection outside
35+
them is byte-identical: the dataset dimension declares an explicit
36+
`dateGranularity`, the `timeDimensions` entry states no `granularity`, and
37+
`selection.dateGranularity` is unset.
38+
39+
**What still buckets, unchanged.** An entry is bucketed when the request says
40+
that date is being bucketed: the dimension is one of the selection's own
41+
`dimensions`, the entry carries its own `granularity` (#4033 — still projected
42+
as a column even when not selected), or `selection.dateGranularity` is set. The
43+
granularity *precedence* chain is untouched. A dataset dimension's
44+
`dateGranularity` says how that date renders **when** grouped — it is no longer
45+
read as a request to group by it.
46+
47+
**`compareTo` alignment (#3588/#4870) holds by construction.** The comparison
48+
pass re-enters the same query builder with the same grid dimensions, differing
49+
only in the shifted `dateRange`, so both passes bucket an entry alike or not at
50+
all — never one of each, which was the state that left every `__compare` column
51+
empty. For a window-only anchor this **repairs** the comparison rather than
52+
preserving it: the merge has always keyed on `selection.dimensions` alone, so
53+
the backfilled bucket column sat outside the merge key, and with several
54+
month-split rows per group the comparison value landed on whichever row the
55+
index held last while the others read a confident `0`.
56+
57+
Also fixed, same root cause: a time column that IS projected via
58+
`timeDimensions` (an entry carrying its own `granularity`, never listed under
59+
`dimensions`) now carries its dataset `label` in `fields` instead of a bare
60+
`type` — the label enrichment walked `selection.dimensions` only.

packages/services/service-analytics/src/__tests__/dataset-dimension-field-descriptors.test.ts

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@
5353
* the change ADDS field entries that were absent, it narrows no rule and
5454
* removes no `??` limb, so nothing downstream can gain a finding from it.
5555
* Predicted 8 red / 4 green; measured exactly that.
56+
*
57+
* ## Amended by #5688
58+
*
59+
* Two cases here were written as a deliberate FLIP TARGET: the pair that fed a
60+
* `timeDimensions` entry carrying only a `dateRange` asserted, verbatim, that
61+
* the entry acquired the dataset's default bucket and became a second GROUP BY.
62+
* #5688 narrowed that backfill, so both now assert the opposite column set and,
63+
* additionally, the grid substance the descriptors describe (one row per owner,
64+
* no month split). The reverse verification above is unaffected — reverting
65+
* #5537's seam still reds exactly the "all base measures filter-scoped" cases,
66+
* and the flipped pair differs only in what the correct column set IS.
5667
*/
5768

5869
import { describe, it, expect } from 'vitest';
@@ -253,27 +264,26 @@ describe('#5537 — the single-query path already described its dimensions (guar
253264
});
254265

255266
/**
256-
* The CONTROL for the `compareTo` case in the next block, and the reason the
257-
* expectation there carries an unlabelled `close_date`.
267+
* The CONTROL for the `compareTo` case in the next block: the two selections
268+
* differ only in whether their measures carry filters, so their descriptors
269+
* must agree column for column.
258270
*
259-
* A `timeDimensions` entry that resolves a granularity is GROUPED BY, so it is
260-
* a COLUMN of the result and every producer of this shape projects it (#4033),
261-
* even when the caller never listed it under `dimensions`. It reaches `fields`
262-
* with a `type` and no `label`, because the label enrichment in `queryDataset`
263-
* walks `selection.dimensions` — and that is true on THIS path, which never
264-
* had the #5537 defect. Pinned here so the pair reads as convergence rather
265-
* than as something the fix introduced.
271+
* **This pin was FLIPPED by #5688 and now carries the opposite fact.** It used
272+
* to assert an unlabelled `close_date` descriptor and three month-split rows,
273+
* because a `timeDimensions` entry carrying only a `dateRange` had the
274+
* dataset's default granularity filled in — turning a WINDOW into a second
275+
* GROUP BY. #5688 narrowed that backfill: a window-only entry stays a filter,
276+
* so there is no `close_date` column on either path and `usr_1` is one row
277+
* again. Asserted as substance, not as an absence: the row values pin that the
278+
* three in-window opportunities landed in ONE `usr_1` bucket rather than being
279+
* split across months, which is the defect's actual signature.
266280
*
267-
* That the column exists AT ALL is itself a defect, filed as #5688 and
268-
* deliberately not fixed here: a `timeDimensions` entry carrying only a
269-
* `dateRange` gets the dataset's default granularity filled in, which turns a
270-
* WINDOW into a second GROUP BY — so this selection also comes back split by
271-
* month. It reproduces identically on this path, i.e. independently of #5537,
272-
* and settling it changes the response SHAPE (a row count, not a label), which
273-
* is not a call to make as a rider. Both asserted verbatim so the day #5688
274-
* lands, this pair goes red and gets updated on purpose.
281+
* The projection rule itself is unchanged (#4033: an entry that DOES resolve a
282+
* granularity is grouped, so it is a column) — see
283+
* `dataset-window-timedimension-bucketing.test.ts`, which pins both sides of
284+
* the narrowed criterion plus the label the projected column now carries.
275285
*/
276-
it('a granular `timeDimensions` column is projected here too — unlabelled, on this path as well', async () => {
286+
it('a window-only `timeDimensions` entry adds no column here — same as the filtered path', async () => {
277287
const result = await svc().queryDataset(
278288
dataset,
279289
{
@@ -286,18 +296,19 @@ describe('#5537 — the single-query path already described its dimensions (guar
286296
);
287297
expect(descriptors(result.fields)).toEqual([
288298
{ name: 'owner', type: 'string', label: 'Owner' },
289-
{ name: 'close_date', type: 'time' },
290299
{ name: 'opp_count', type: 'number', label: 'Opportunities' },
291300
{ name: 'opp_count__compare', type: 'number', label: 'Opportunities' },
292301
]);
293-
// #5688, stated as data rather than as prose: `usr_1` is one owner and comes
294-
// back as two rows because the window entry acquired a `month` bucket. The
295-
// descriptors are honest about the grid — the grid is what is wrong.
296-
expect(result.rows.map((r) => [r.owner, r.close_date])).toEqual([
297-
['usr_1', '2026-01'],
298-
['usr_1', '2026-02'],
299-
['usr_2', '2026-02'],
302+
// The grid the descriptors describe: one row per owner, the window applied
303+
// as a filter. `usr_1` has three in-window opportunities (two in January,
304+
// one in February) and they aggregate into a single bucket — before #5688
305+
// this was two rows carrying 2 and 1.
306+
expect(result.rows).toEqual([
307+
{ owner: 'usr_1', opp_count: 3, opp_count__compare: 0 },
308+
{ owner: 'usr_2', opp_count: 1, opp_count__compare: 0 },
300309
]);
310+
// …and no row carries a column no descriptor mentions.
311+
expect(result.rows.every((r) => !('close_date' in r))).toBe(true);
301312
});
302313
});
303314

@@ -398,15 +409,20 @@ describe('#5537 — all base measures filter-scoped: the dimension is described
398409
},
399410
CTX,
400411
);
401-
// Identical to the control in the previous block, measure for measure
402-
// including the unlabelled `close_date` a granular `timeDimensions` entry
403-
// projects on BOTH paths.
412+
// Identical to the control in the previous block, column for column
413+
// including the ABSENCE of a `close_date` column, which since #5688 a
414+
// window-only `timeDimensions` entry no longer mints on either path.
404415
expect(descriptors(result.fields)).toEqual([
405416
{ name: 'owner', type: 'string', label: 'Owner' },
406-
{ name: 'close_date', type: 'time' },
407417
{ name: 'won_count', type: 'number', label: 'Won' },
408418
{ name: 'won_count__compare', type: 'number', label: 'Won' },
409419
]);
420+
// Same substance as the control: one row per owner, no month split. The two
421+
// paths converge on the grid as well as on the descriptors.
422+
expect(result.rows).toEqual([
423+
{ owner: 'usr_1', won_count: 1, won_count__compare: 0 },
424+
{ owner: 'usr_2', won_count: 1, won_count__compare: 0 },
425+
]);
410426
});
411427

412428
it('the `totals` grid re-enters the same pass and still describes the dimension', async () => {

0 commit comments

Comments
 (0)