Skip to content

Commit 835b2d1

Browse files
fix(service-analytics): a compareTo dataset answer fills the time buckets empty in both windows (#27213)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XCk5RnKsekSKbGXkwHbRvqM)_ Fixes #27111 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:带 `compareTo`(同比/环比)的数据集查询,现在也会补上“两个时间段都没有数据”的时间桶:计数和求和读 `0`(本期列和 `__compare` 对照列都是),平均/最小/最大不写这个键。生产代码只删了一处:`timeBucketFillPlan` 里对 `compareTo` 的提前返回,补零沿用 #26893 的同一套规则。 - **为什么改**:#26893 只给普通序列补了零,`compareTo` 被刻意跳过。同比趋势图里,两年都空的月份仍然缺失,图表把它两边的月份画成相邻。 - **风险与回滚**:低。只在带 `compareTo` 的查询上生效;任一窗口有数据的桶、`limit`/`offset`、按度量或 `__compare` 列排序、2,000 行上限等守卫都不变,普通序列逐字节不变。回滚即 revert。 - **先看**:`packages/services/service-analytics/src/dataset-executor.ts`(`timeBucketFillPlan` 的一行与其文档注释);`packages/services/service-analytics/src/__tests__/dataset-time-bucket-zero-fill.test.ts`(新的两个 `[#27111]` describe 块)。 - **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 src/__tests__/dataset-time-bucket-zero-fill.test.ts`,预期 27/27 通过;把那一处提前返回加回去,同一文件 5 条失败(两边都空的月份消失),2 条对照仍绿。 - **需要你做的**:无,席位落地。 ## What changes `DatasetExecutor.timeBucketFillPlan` returned no plan when `selection.compareTo` was set, the deliberate skip #26893 (PR #27109) left for this card. The skip is lifted; nothing else in the production path changes. - **One fill rule.** A `compareTo` answer gets the same `TimeBucketFillPlan` and the same `fillEmptyTimeBuckets` walk a plain series gets. No second walker, no new helper. - **The primary window decides the buckets.** The plan reads the axis's own `timeDimensions[].dateRange`, never the shifted one. It runs after the comparison merge, where `alignedCompareBucketKey` has already restated the comparison rows in primary bucket keys (when the axis is the anchor), so a bucket either window holds is already a row and keeps the merge's values. What the fill adds is only a bucket empty in both windows. - **The `__compare` columns.** Measured, not changed: `fillColumns` already lists `MEASURE__compare` beside each base measure (`dataset-executor.ts` L1364), and `fillEmptyGroups` runs after the bucket fill. So an added row reads `0` for `count`/`sum` in both the measure and its `__compare`, and `avg`/`min`/`max` are absent keys on both sides (pinned in the tests and seen live below). Derived measures are evaluated after the fill, as before. - **Guards unchanged for `compareTo`.** `limit`/`offset`, an `order` on a non-dimension key (a measure, a `__compare` column, a derived measure), more than one bucketed axis, and `MAX_ZERO_FILLED_GRID_ROWS`. - **An axis with no `dateRange` of its own** (the comparison anchored on another, window-only date dimension) gets its interior gaps filled and no edges, the same one-bound rule a plain series follows. That is the only "unbounded" `compareTo` shape that exists: `compareTo` refuses an anchor without a two-bound `dateRange` (`resolveCompareDimension`, `explicitDateRangeWindow`). - The `timeBucketFillPlan` docblock now states the `compareTo` reading instead of "a separate question". ## Tests `packages/services/service-analytics/src/__tests__/dataset-time-bucket-zero-fill.test.ts` (the real `AnalyticsServicePlugin` over a real ObjectQL engine and a real SQLite driver, as in #26893): - The #26893 `compareTo keeps its shape` control pinned the skip, so it is replaced by the card's own case: May–August 2026 vs the previous year (2025-05..08 empty) answers `2026-05, 06, 07, 08`, with `total_amount` `[0, 70, 80, 0]`, every `__compare` `0`, `opp_count`/`opp_count__compare` `toBe(0)` on May and August, and `avg_amount` / `avg_amount__compare` absent keys. - A new fixture (stage `cmp_probe`), one month in each state: both windows, primary only, compare only, neither. Cases: - the full sequence `2026-03|1|1, 04|1|0, 05|0|1, 06|0|0, 07|1|0, 08|0|0` (count, count `__compare`) plus the sums: the three one-window months and the both-window month keep the merge's values, June and August are added; - the primary columns of the `compareTo` answer equal the plain series' answer for the same window (one fill rule); - `previousPeriod` (June–August vs March–May, ordinal alignment): `06|0|1, 07|1|1, 08|0|0`; - an axis with no `dateRange` (anchor `closed_at`, window-only): every month between the first and last bucket the grid holds (`2025-03` … `2026-07`), none before or after; - controls: `limit` with `compareTo`, and an `order` on `opp_count__compare`, are not filled (4 rows, the base answer). - Fixture fallout, one file: `dataset-compare-bucket-alignment.test.ts` `day` uses a window (1–4 March) wider than its rows (2–3 March), so 1 and 4 March now come back at `0 / 0`. The expected rows are updated with exact values; the alignment it pins (`03-02` ↔ 5, `03-03` ↔ 6) is unchanged. <details><summary>Ablation: restoring the compareTo early return</summary> `node scripts/ablation-replace.mjs` on `dataset-executor.ts`, anchor `if (selection.limit != null || selection.offset != null) return undefined;` → `if (selection.compareTo || selection.limit != null || selection.offset != null) return undefined;` (anchor x1 → x0, replacement x0 → x1, blob `201a3ec3` → `0fe9ae21`). The suite imports the executor from `src/`, so no build sits between the mutation and the run. ``` × May–August vs the previous year: May and August come back, with 0 in the measure and in its __compare × a bucket either window holds keeps the merge's values; June and August are added at 0 / 0 × the primary columns are the plain series' answer: one fill rule × previousPeriod aligns by ordinal and fills the same way × an axis with no dateRange of its own (a window-only anchor) fills interior gaps only, no edges Tests 5 failed | 22 passed (27) ``` The base answer for the fixture case was `2026-03|1|1, 04|1|0, 05|0|1, 07|1|0`: the one-window months are the same rows with the same values. Restore: blob after restore `201a3ec3` = HEAD, `git diff HEAD` empty. </details> <details><summary>Live red/green through the REST door (app-crm, opportunity_metrics)</summary> `pnpm dev:crm -- --fresh -p PORT` after a full `pnpm build`, signed in as the dev admin, `POST /api/v1/analytics/dataset/query` with `datasetName: opportunity_metrics`, `runtimeFilter: { stage: 'closed_won' }`, `timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-06-01', '2026-10-31'] }]`, measures `opp_count`, `total_amount`, `avg_amount`. The seed's closed-won deals sit in 2026-07, 2026-10, 2025-09 and 2025-10. Red = the base blob of `dataset-executor.ts` (`55592517`) written over the file and `@objectstack/service-analytics` rebuilt (dist marker `selection.compareTo || selection.limit`: 1 hit each in `index.js` / `index.cjs`); green = HEAD rebuilt (0 hits). Restore proven by the blob hash equal to HEAD's and `git diff HEAD` empty. Columns: count / count `__compare`, sum / sum `__compare`, avg. ``` A compareTo previousYear RED 200 rows 3 2026-07 opp 2 / 0 amt 170000 / 0 avg=85000 avgc-absent 2026-09 opp 0 / 1 amt 0 / 210000 avg-absent avgc=210000 2026-10 opp 3 / 1 amt 235000 / 75000 avg=78333.33 avgc=75000 GREEN 200 rows 5 2026-06 opp 0 / 0 amt 0 / 0 avg-absent avgc-absent 2026-07 opp 2 / 0 amt 170000 / 0 avg=85000 avgc-absent 2026-08 opp 0 / 0 amt 0 / 0 avg-absent avgc-absent 2026-09 opp 0 / 1 amt 0 / 210000 avg-absent avgc=210000 2026-10 opp 3 / 1 amt 235000 / 75000 avg=78333.33 avgc=75000 B same + limit 10 (control): RED and GREEN both 3 rows, identical to RED A C plain series, same window (control): RED and GREEN both 5 rows, identical ``` </details> ## Acceptance notes - **Clause-②: no**, measured: no input is refused or newly accepted, no export or type changes (the diff touches one private method's guard and comments); a `compareTo` answer gains rows it lacked. `Metadata-protocol: none`. - The pending `.changeset/26893-time-series-empty-buckets.md` (unreleased, from PR #27109) still lists `compareTo` under **Not filled**. It is left at its base bytes: the required `Check Changeset` gate refuses any change to a changeset this PR did not add (#17712). Instead, this PR's own changeset ends with a sentence that supersedes that `compareTo` mention for the same release (seat claim amendment 2, `6467147455`). - Dogfood: no dogfood file sends a `compareTo` (`git grep -l compareTo -- packages/qa` is empty), and this diff changes an answer only when `compareTo` is set, so the full battery is not run (no `!` and no `(narrowing)` arm). ## Verification All on head `93470eef` (this branch with `origin/main` `b322b5da` merged in), after `pnpm install --frozen-lockfile && pnpm build`: - `pnpm --filter @objectstack/service-analytics test`: 194 files, 4698 passed, 282 skipped. `typecheck`: exit 0. `build`: exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0 (spec moved on `main`; untouched here). - `@objectstack/rest` analytics suites (`vitest run src/analytics-`, reading the rebuilt `@objectstack/service-analytics` dist): 23 files, 298 passed, 22 skipped. - `pnpm exec eslint --no-inline-config packages/services/service-analytics`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 59 derived families, all run, all exit 0. `--ran` reconciliation: `59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN`. (`check-plugin-teardown-shape --self-test` after the documented `git fetch --depth=1` of its fixture commit.) - Ablation and the live REST red/green are under **Tests** above; both were taken on `1083b988`, before the merge, whose incoming commits do not touch `dataset-executor.ts` or the two test files. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mEZzWjC6chaa9yue6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdb949a commit 835b2d1

4 files changed

Lines changed: 182 additions & 17 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): a `compareTo` dataset answer also returns the time buckets that are empty in both windows (#27111)
6+
7+
Clause-②: no — no input is refused or newly accepted and no export or type changes; a `compareTo` dataset answer gains the rows for the time buckets no record fell in, in either window, which it used to leave out
8+
Metadata-protocol: none
9+
10+
The empty time-bucket fill for a date-dimension series did not run when the selection carried a `compareTo`, so a period-over-period trend still dropped a month that held no record in either window, and a chart drew the months on either side of it as neighbours. `queryDataset` (and `POST /api/v1/analytics/dataset/query`) now fills a `compareTo` answer by the same rule as a plain series:
11+
12+
- **Which buckets.** The primary window decides: every bucket of the time axis's own `dateRange`, never the shifted one. A bucket that holds records in either window already comes back, with the same values as before; only a bucket empty in both windows is added. An axis with no `dateRange` of its own (the comparison anchored on another, window-only date) gets only the gaps between its first and last bucket filled.
13+
- **Their values.** The same values an empty group reads, for the measure and for its `<measure>__compare` column alike: `0` for `count` and `sum`; `avg`, `min` and `max` stay unset (the column is absent from the row). A derived measure is computed over them as over any row.
14+
- **Not filled.** As for a plain series: a selection with a `limit` or `offset`, an `order` on a measure or on a `__compare` column, a grid with two bucketed time dimensions, and a fill that would leave more than 2,000 rows answer exactly as before.
15+
16+
This supersedes the "or a `compareTo`" in the **Not filled** line of the #26893 note in the same release: a `compareTo` answer is filled too, as above.

‎packages/services/service-analytics/src/__tests__/dataset-compare-bucket-alignment.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,13 @@ describe('#6007 — the shift-back at each bucket size the selection can ask for
370370
},
371371
CTX,
372372
);
373+
// 1 and 4 March hold nothing in either window: since #27111 the
374+
// time-bucket fill adds them at 0 on both sides.
373375
expect(result.rows).toEqual([
376+
{ close_date: '2026-03-01', opp_count: 0, opp_count__compare: 0 },
374377
{ close_date: '2026-03-02', opp_count: 1, opp_count__compare: 5 },
375378
{ close_date: '2026-03-03', opp_count: 2, opp_count__compare: 6 },
379+
{ close_date: '2026-03-04', opp_count: 0, opp_count__compare: 0 },
376380
]);
377381
});
378382

‎packages/services/service-analytics/src/__tests__/dataset-time-bucket-zero-fill.test.ts‎

Lines changed: 148 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,12 @@
2626
* - another grid dimension is filled per combination that holds a row;
2727
* - a non-UTC zone: the keys the fill writes are the keys the query writes;
2828
* - the controls — a full window, a top-N, an order on a measure, no time
29-
* dimension, `compareTo` — come back as they always have;
30-
* - the bound: a fill past `MAX_ZERO_FILLED_GRID_ROWS` does not run at all.
29+
* dimension — come back as they always have;
30+
* - the bound: a fill past `MAX_ZERO_FILLED_GRID_ROWS` does not run at all;
31+
* - #27111, `compareTo`: the same fill, over the PRIMARY window, adds the
32+
* buckets empty in both windows with `0` in both the measure and its
33+
* `__compare`; a bucket either window holds is unchanged, and the guards
34+
* (no axis `dateRange`, a `limit`, an order on a `__compare`) hold.
3135
*/
3236

3337
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
@@ -354,29 +358,160 @@ describe('[#26893] controls — answers the fill must not change', () => {
354358
expect(res.rows.map((r) => `${r.owner}|${r.opp_count}`).sort()).toEqual(['ann|10', 'bob|5']);
355359
});
356360

357-
it('compareTo keeps its shape: its empty buckets are not filled', async () => {
361+
it(`a fill past ${MAX_ZERO_FILLED_GRID_ROWS} rows does not run at all — never a partial fill`, async () => {
358362
const res = await run({
359363
dimensions: ['close_date'],
360-
measures: ['total_amount'],
364+
measures: ['opp_count'],
365+
dateGranularity: 'day',
366+
runtimeFilter: WON,
367+
timeDimensions: [{ dimension: 'close_date', dateRange: ['2017-01-01', '2026-12-31'] }],
368+
});
369+
expect(res.rows).toHaveLength(DEALS.filter((d) => d.stage === 'closed_won').length);
370+
});
371+
});
372+
373+
describe('[#27111] compareTo: a bucket empty in both windows is filled, over the primary window', () => {
374+
it('May–August vs the previous year: May and August come back, with 0 in the measure and in its __compare', async () => {
375+
const res = await run({
376+
dimensions: ['close_date'],
377+
measures: ['opp_count', 'total_amount', 'avg_amount'],
361378
runtimeFilter: WON,
362379
timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-05-01', '2026-08-31'] }],
363380
compareTo: { kind: 'previousYear' },
364381
});
365-
// May and August hold nothing in either window (2025-05..08 holds no
366-
// deal), and stay absent: the fill does not run under `compareTo`.
367-
expect(series(res.rows)).toEqual([['2026-06', 70], ['2026-07', 80]]);
368-
expect(res.rows.map((r) => r.total_amount__compare)).toEqual([0, 0]);
382+
// 2025-05..08 holds no deal, and 2026 holds June and July only.
383+
expect(series(res.rows)).toEqual([['2026-05', 0], ['2026-06', 70], ['2026-07', 80], ['2026-08', 0]]);
384+
expect(res.rows.map((r) => r.total_amount__compare)).toEqual([0, 0, 0, 0]);
385+
for (const month of ['2026-05', '2026-08']) {
386+
const row = res.rows.find((r) => r.close_date === month)!;
387+
expect(row.opp_count).toBe(0);
388+
expect(row.opp_count__compare).toBe(0);
389+
// avg is an absent key on both sides, the way an empty group reads it.
390+
expect('avg_amount' in row).toBe(false);
391+
expect('avg_amount__compare' in row).toBe(false);
392+
}
369393
});
394+
});
370395

371-
it(`a fill past ${MAX_ZERO_FILLED_GRID_ROWS} rows does not run at all — never a partial fill`, async () => {
396+
/**
397+
* A compare-only fixture, its own stage: March–August 2026 against
398+
* March–August 2025, one month in each of the four states.
399+
*
400+
* | month | 2026 (primary) | 2025 (compare) |
401+
* |:--|:--|:--|
402+
* | 03 | 1 deal | 1 deal |
403+
* | 04 | 1 deal | — |
404+
* | 05 | — | 1 deal |
405+
* | 06 | — | — |
406+
* | 07 | 1 deal | — |
407+
* | 08 | — | — |
408+
*
409+
* `closed_at` dates the same deals for the window-only anchor case: 2026's
410+
* deals close in 2026 and 2025's in 2025, so anchoring on `closed_at` while
411+
* the grid buckets `close_date` gives a series with no axis window.
412+
*/
413+
const CMP = { stage: 'cmp_probe' };
414+
const CMP_DEALS = [
415+
{ id: 'c1', close_date: '2026-03-10', closed_at: '2026-03-10T12:00:00.000Z', amount: 4 },
416+
{ id: 'c2', close_date: '2026-04-10', closed_at: '2026-04-10T12:00:00.000Z', amount: 8 },
417+
{ id: 'c3', close_date: '2026-07-10', closed_at: '2026-07-10T12:00:00.000Z', amount: 16 },
418+
{ id: 'c4', close_date: '2025-03-10', closed_at: '2025-03-10T12:00:00.000Z', amount: 1 },
419+
{ id: 'c5', close_date: '2025-05-10', closed_at: '2025-05-10T12:00:00.000Z', amount: 2 },
420+
];
421+
const CMP_WINDOW: [string, string] = ['2026-03-01', '2026-08-31'];
422+
const cells = (rows: Record<string, unknown>[]) =>
423+
rows.map((r) => `${r.close_date}|${r.opp_count}|${r.opp_count__compare}`);
424+
425+
describe('[#27111] compareTo: only the both-empty buckets are added; every other answer is unchanged', () => {
426+
beforeAll(async () => {
427+
for (const deal of CMP_DEALS) await engine.insert(DEAL, { ...deal, ...CMP, owner: 'ann' } as any);
428+
});
429+
430+
it('a bucket either window holds keeps the merge\'s values; June and August are added at 0 / 0', async () => {
431+
const res = await run({
432+
dimensions: ['close_date'],
433+
measures: ['opp_count', 'total_amount'],
434+
runtimeFilter: CMP,
435+
timeDimensions: [{ dimension: 'close_date', dateRange: CMP_WINDOW }],
436+
compareTo: { kind: 'previousYear' },
437+
});
438+
expect(cells(res.rows)).toEqual([
439+
'2026-03|1|1', '2026-04|1|0', '2026-05|0|1', '2026-06|0|0', '2026-07|1|0', '2026-08|0|0',
440+
]);
441+
expect(res.rows.map((r) => [r.total_amount, r.total_amount__compare].map(Number))).toEqual([
442+
[4, 1], [8, 0], [0, 2], [0, 0], [16, 0], [0, 0],
443+
]);
444+
});
445+
446+
it('the primary columns are the plain series\' answer: one fill rule', async () => {
447+
const selection: DatasetSelection = {
448+
dimensions: ['close_date'],
449+
measures: ['opp_count', 'total_amount'],
450+
runtimeFilter: CMP,
451+
timeDimensions: [{ dimension: 'close_date', dateRange: CMP_WINDOW }],
452+
};
453+
const plain = await run(selection);
454+
const compared = await run({ ...selection, compareTo: { kind: 'previousYear' } });
455+
const primary = (rows: Record<string, unknown>[]) =>
456+
rows.map((r) => [r.close_date, r.opp_count, Number(r.total_amount)]);
457+
expect(primary(compared.rows)).toEqual(primary(plain.rows));
458+
expect(plain.rows.map((r) => r.close_date)).toEqual([
459+
'2026-03', '2026-04', '2026-05', '2026-06', '2026-07', '2026-08',
460+
]);
461+
});
462+
463+
it('previousPeriod aligns by ordinal and fills the same way', async () => {
464+
// June–August 2026 against March–May 2026: 03↔06, 04↔07, 05↔08.
372465
const res = await run({
373466
dimensions: ['close_date'],
374467
measures: ['opp_count'],
375-
dateGranularity: 'day',
376-
runtimeFilter: WON,
377-
timeDimensions: [{ dimension: 'close_date', dateRange: ['2017-01-01', '2026-12-31'] }],
468+
runtimeFilter: CMP,
469+
timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-06-01', '2026-08-31'] }],
470+
compareTo: { kind: 'previousPeriod' },
378471
});
379-
expect(res.rows).toHaveLength(DEALS.filter((d) => d.stage === 'closed_won').length);
472+
expect(cells(res.rows)).toEqual(['2026-06|0|1', '2026-07|1|1', '2026-08|0|0']);
473+
});
474+
475+
it('an axis with no dateRange of its own (a window-only anchor) fills interior gaps only, no edges', async () => {
476+
const res = await run({
477+
dimensions: ['close_date'],
478+
measures: ['opp_count'],
479+
runtimeFilter: CMP,
480+
timeDimensions: [{ dimension: 'closed_at', dateRange: ['2026-01-01', '2026-12-31'] }],
481+
compareTo: { kind: 'previousYear', dimension: 'closed_at' },
482+
});
483+
// The anchor is not a grid column, so the comparison rows key by their
484+
// own close_date (2025-03, 2025-05). Between the first and last bucket
485+
// the grid holds, every month; nothing before 2025-03 or after 2026-07.
486+
expect(cells(res.rows)).toEqual([
487+
'2025-03|0|1', '2025-04|0|0', '2025-05|0|1', '2025-06|0|0', '2025-07|0|0', '2025-08|0|0',
488+
'2025-09|0|0', '2025-10|0|0', '2025-11|0|0', '2025-12|0|0', '2026-01|0|0', '2026-02|0|0',
489+
'2026-03|1|0', '2026-04|1|0', '2026-05|0|0', '2026-06|0|0', '2026-07|1|0',
490+
]);
491+
});
492+
493+
it('a limit with compareTo is not filled', async () => {
494+
const res = await run({
495+
dimensions: ['close_date'],
496+
measures: ['opp_count'],
497+
runtimeFilter: CMP,
498+
timeDimensions: [{ dimension: 'close_date', dateRange: CMP_WINDOW }],
499+
compareTo: { kind: 'previousYear' },
500+
limit: 10,
501+
});
502+
expect(cells(res.rows)).toEqual(['2026-03|1|1', '2026-04|1|0', '2026-05|0|1', '2026-07|1|0']);
503+
});
504+
505+
it('an order on a __compare column is not filled', async () => {
506+
const res = await run({
507+
dimensions: ['close_date'],
508+
measures: ['opp_count'],
509+
runtimeFilter: CMP,
510+
timeDimensions: [{ dimension: 'close_date', dateRange: CMP_WINDOW }],
511+
compareTo: { kind: 'previousYear' },
512+
order: { opp_count__compare: 'desc' },
513+
});
514+
expect(res.rows.map((r) => r.close_date).sort()).toEqual(['2026-03', '2026-04', '2026-05', '2026-07']);
380515
});
381516
});
382517

‎packages/services/service-analytics/src/dataset-executor.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,9 +1550,19 @@ export class DatasetExecutor {
15501550
* invented bucket would compete for its slots;
15511551
* - an `order` on anything but a dimension — a measure, a `__compare`
15521552
* column or a derived measure ranks rows, and a column of invented
1553-
* zeros reorders the ranking;
1554-
* - a `compareTo` — its answer keeps the shape it has (its own empty
1555-
* buckets are a separate question).
1553+
* zeros reorders the ranking.
1554+
*
1555+
* A `compareTo` answer is filled by this same plan (#27111), and the
1556+
* PRIMARY window decides the buckets: the plan reads the axis's own
1557+
* `dateRange`, never the shifted one. It runs after the comparison merge —
1558+
* when the axis is the anchor, {@link alignedCompareBucketKey} has already
1559+
* restated the comparison rows in primary bucket keys — so every bucket
1560+
* either window holds is a row already, left as the merge made it, and what
1561+
* the fill adds is only a bucket empty in BOTH windows. {@link fillEmptyGroups}
1562+
* then gives that row's `<measure>__compare` cells the empty values its
1563+
* primary cells get. An axis with no `dateRange` of its own (the anchor is
1564+
* another, window-only dimension) gets its interior gaps filled, as a plain
1565+
* series does. Every other guard holds for `compareTo` as for a plain series.
15561566
*
15571567
* The window is the axis's own `timeDimensions[].dateRange`, read in the
15581568
* zone {@link buildQuery} resolves the query in. A window stated only in
@@ -1567,7 +1577,7 @@ export class DatasetExecutor {
15671577
order: Record<string, 'asc' | 'desc'> | undefined,
15681578
context?: ExecutionContext,
15691579
): TimeBucketFillPlan | undefined {
1570-
if (selection.compareTo || selection.limit != null || selection.offset != null) return undefined;
1580+
if (selection.limit != null || selection.offset != null) return undefined;
15711581
if (Object.keys(order ?? {}).some((key) => !dimensions.includes(key))) return undefined;
15721582
const bucketed = dimensions.flatMap((d) => {
15731583
const granularity = this.granularityOf(compiled, selection, d);

0 commit comments

Comments
 (0)