Skip to content

Commit ff39e63

Browse files
fix(service-analytics): key the dimension merge unambiguously, and stop merging "unassigned" into "blank" (#4821) (#4957)
`mergeByDimensions` is the seam every multi-query dataset result is assembled through: the primary pass against each measure-scoped supplementary pass, and — since #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 #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 #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 #4821 Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX Co-authored-by: Claude <noreply@anthropic.com>
1 parent f98fa65 commit ff39e63

4 files changed

Lines changed: 374 additions & 5 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): 维度合并键不再把「未分配」并进「空白」,并改为长度前缀消歧 (#4821)
6+
7+
`mergeByDimensions` 是每一份多查询 dataset 结果的装配缝:主查询与每个带 `filter`
8+
的 measure 的补充子查询在这里对齐,`compareTo` 窗口自 #4870 起也按 measure 扇出后
9+
经由同一个缝合并回来。这里一次键碰撞不会报错 —— 一个分组静默吸走另一个分组的数字,
10+
网格仍然保持看起来合理的行数和列数。
11+
12+
**#4821 报告的机制与实际的缺陷不完全一致,先把这一点说清楚。** 原键是
13+
`String(row[d] ?? '')` 以一个**直接写进源码的裸 U+0001 字节**相连。裸控制字符渲染
14+
为空,所以 issue 正文读到的是 `join('')`,其头号复现(`['ab','c']``['a','bc']`
15+
同键为 `"abc"`)其实并不成立 —— 分隔符一直在,只是看不见。真正咬人的是另外两条:
16+
17+
- `?? ''`**真正为 null** 的维度与**空字符串**维度键成同一个值。于是「未分配」被
18+
并进「空白」:一行吞掉另一行的 measure,另一行的列则整个缺失 —— 而 #4708 的空组
19+
填充随后会给它填上一个理直气壮的 `0`。一个真实计数为 3 的分组因此显示为 0。
20+
- 单字符分隔符只在「没有任何维度****包含该字符」时才无歧义。维度值是用户数据
21+
(文本字段、导入记录),所以那是一个假设而非保证,且一旦不成立同样静默。
22+
23+
**改法:长度前缀 + 显式空值哨兵。** 每段编码为 `<长度>:<值>`,`2:ab1:c`
24+
`1:a2:bc` 对任意输入都不同,不再保留任何字符、也不再有看不见的字节留给下一个读者
25+
误读(本 issue 正是这样被误读出来的)。null/undefined 单独走一个哨兵段,与消歧这件
26+
事解耦。
27+
28+
**逐段的 `String()` 强制被刻意保留**,这与一文件之隔的 `cross-object-rebucket.ts`
29+
的 JSON 键不是同一笔交易:后者重新分桶的是**同一个查询**的行,一列只有一种类型,
30+
JSON 在那里免费且能换来真实的区分(空桶 `null` vs 字面量字符串 `"null"`)。本函数
31+
做的是相反的事 —— 跨**不同查询**对齐行,而驱动确实会对同一个分组返回不同的 JS 类型
32+
(本文件 `compareValues` 的注释即记着 "numeric strings, which is how some drivers
33+
return SUM results")。改用 `JSON.stringify` 会把 `1``"1"` 渲染成两个键,让今天
34+
能正确合并的行不再合并 —— 用一个新的静默缺陷换掉旧的,不算修好。该行为已有回归钉
35+
测试锁住。
36+
37+
仅影响内部合并键,响应中的任何值都不改变。

packages/services/service-analytics/src/__tests__/dataset-compare-measure-filters.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,11 @@ function runQuery(q: AnalyticsQuery): AnalyticsResult {
107107
for (const opp of OPPS) {
108108
if (range && (opp.close_date < range[0] || opp.close_date > range[1])) continue;
109109
if (!matches(opp, q.where)) continue;
110-
// Keyed unambiguously on purpose: `mergeByDimensions` concatenates its own
111-
// key with no delimiter (#4821, filed separately and deliberately NOT
112-
// touched here), and this fake must not import that ambiguity — a test that
113-
// measured two defects at once could not tell which one it caught.
110+
// Keyed unambiguously on purpose, and independently of the executor's own
111+
// key (#4821 — which turned out to be a null-vs-empty conflation rather
112+
// than the missing delimiter it was reported as; the delimiter was a raw
113+
// U+0001 byte, invisible in the issue body). A fake that imported the
114+
// production key could not tell which defect a failure caught.
114115
const key = dims
115116
.map((d) => JSON.stringify((opp as unknown as Record<string, unknown>)[d] ?? null))
116117
.join('|');
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The dimension key `mergeByDimensions` aligns rows on (#4821).
5+
*
6+
* Every multi-query dataset result is assembled through that merge: the primary
7+
* pass against each measure-scoped supplementary pass, and — since #4870 — the
8+
* current window against the shifted `compareTo` window, which fans out per
9+
* measure the same way. A key collision there does not fail: one group silently
10+
* absorbs another's measures and the grid keeps a plausible shape.
11+
*
12+
* ## What was actually wrong, which is not quite what #4821 reported
13+
*
14+
* The old key was `String(row[d] ?? '')` joined on a **raw U+0001 byte written
15+
* literally into the source**. A raw control byte renders as nothing, so the
16+
* issue was filed reading `join('')` and its headline repro (`['ab','c']` vs
17+
* `['a','bc']` both keying `"abc"`) never actually reproduced — the separator
18+
* was there, just invisible. What did bite:
19+
*
20+
* - `?? ''` keyed a genuinely NULL dimension the same as an empty-string one,
21+
* merging "unassigned" into "blank";
22+
* - a one-character separator is only unambiguous while no dimension VALUE
23+
* contains it, and dimension values are user data.
24+
*
25+
* So the suite below pins the tuple-distinctness property directly — it holds
26+
* for adjacent-value ambiguity, for a value carrying the old separator, and for
27+
* null vs. empty — rather than pinning the particular encoding that delivers it.
28+
*
29+
* ## The pin that guards the rejected direction
30+
*
31+
* `numeric 1 and string '1' still key the SAME` is a REGRESSION PIN, not an
32+
* incidental observation. The obvious "fix" (and #4821's own suggestion) is the
33+
* `JSON.stringify` key `cross-object-rebucket.ts` uses, which would render those
34+
* `1` and `"1"` and split them into two groups. That function re-buckets ONE
35+
* query's rows, where a column has one type; this merge ALIGNS SEPARATE
36+
* QUERIES, and drivers do return the same group differently typed across them
37+
* (`compareValues`: "numeric strings, which is how some drivers return SUM
38+
* results"). Adopting JSON here would trade a silent defect for a new one.
39+
*/
40+
41+
import { describe, it, expect, vi } from 'vitest';
42+
import type { IAnalyticsService, AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
43+
import { DatasetSchema } from '@objectstack/spec/ui';
44+
import { compileDataset } from '../dataset-compiler.js';
45+
import { DatasetExecutor, mergeByDimensions } from '../dataset-executor.js';
46+
47+
/** The separator the old key joined on, referenced only as an escape sequence. */
48+
const OLD_SEPARATOR = '\u0001';
49+
50+
const DIMS = ['region', 'segment'];
51+
52+
describe('mergeByDimensions — distinct dimension tuples never share a key (#4821)', () => {
53+
it('keeps the adjacent-value pair apart: {ab, c} is not {a, bc}', () => {
54+
const base = [
55+
{ region: 'ab', segment: 'c', revenue: 10 },
56+
{ region: 'a', segment: 'bc', revenue: 20 },
57+
];
58+
const extra = [
59+
{ region: 'ab', segment: 'c', won: 1 },
60+
{ region: 'a', segment: 'bc', won: 2 },
61+
];
62+
63+
const rows = mergeByDimensions(base, extra, DIMS, ['won']);
64+
65+
expect(rows).toHaveLength(2);
66+
// Each group keeps its OWN number. The failure mode is not an error: it is
67+
// `won: 2` landing on the {ab, c} row while {a, bc} is left without one.
68+
expect(rows.find((r) => r.region === 'ab')).toMatchObject({ segment: 'c', revenue: 10, won: 1 });
69+
expect(rows.find((r) => r.region === 'a')).toMatchObject({ segment: 'bc', revenue: 20, won: 2 });
70+
});
71+
72+
it('cannot be forged by a value that CONTAINS the old separator', () => {
73+
// The property a single separator character can only assume: this pair is
74+
// indistinguishable once the tuple is joined on U+0001, and a text field
75+
// really can carry one (imported records, pasted payloads).
76+
const base = [
77+
{ region: `a${OLD_SEPARATOR}b`, segment: 'c', revenue: 10 },
78+
{ region: 'a', segment: `b${OLD_SEPARATOR}c`, revenue: 20 },
79+
];
80+
// Addressed to the FIRST row on purpose. Under the old key both base rows
81+
// index to one entry — the LAST one wins — so this merge landed on the
82+
// second row instead: the measure of one group written onto another.
83+
const extra = [{ region: `a${OLD_SEPARATOR}b`, segment: 'c', won: 7 }];
84+
85+
const rows = mergeByDimensions(base, extra, DIMS, ['won']);
86+
87+
expect(rows).toHaveLength(2);
88+
expect(rows.find((r) => r.region === `a${OLD_SEPARATOR}b`)?.won).toBe(7);
89+
expect(rows.find((r) => r.region === 'a')?.won).toBeUndefined();
90+
});
91+
92+
it('keeps a NULL dimension apart from an empty-string one — "unassigned" is not "blank"', () => {
93+
const base = [
94+
{ region: null, segment: 'ent', revenue: 10 },
95+
{ region: '', segment: 'ent', revenue: 20 },
96+
];
97+
const extra = [
98+
{ region: null, segment: 'ent', won: 1 },
99+
{ region: '', segment: 'ent', won: 2 },
100+
];
101+
102+
const rows = mergeByDimensions(base, extra, DIMS, ['won']);
103+
104+
expect(rows).toHaveLength(2);
105+
// `?? ''` keyed both as the empty tuple: the unassigned row came back
106+
// without a `won` at all while the blank row absorbed both merges.
107+
expect(rows.find((r) => r.region === null)?.won).toBe(1);
108+
expect(rows.find((r) => r.region === '')?.won).toBe(2);
109+
});
110+
111+
it('treats an ABSENT dimension column as the same "no value" as null', () => {
112+
// Deliberate, and the opposite call from null-vs-empty: drivers omit null
113+
// columns from row objects, so splitting here would re-create the
114+
// cross-query mismatch the key exists to absorb.
115+
const base = [{ region: null, segment: 'ent', revenue: 10 }];
116+
const extra = [{ segment: 'ent', won: 3 }];
117+
118+
const rows = mergeByDimensions(base, extra, DIMS, ['won']);
119+
120+
expect(rows).toHaveLength(1);
121+
expect(rows[0]).toMatchObject({ region: null, segment: 'ent', revenue: 10, won: 3 });
122+
});
123+
124+
it('REGRESSION PIN — numeric 1 and string "1" still key the SAME (no JSON.stringify key)', () => {
125+
// The main query and a measure-scoped sub-query can type one group
126+
// differently; `String()` per segment is what keeps them one row. A
127+
// JSON-encoded key splits this into two rows, silently — which is why this
128+
// test exists and why it must not be "corrected".
129+
const base = [{ region: 1, segment: 2, revenue: 10 }];
130+
const extra = [{ region: '1', segment: '2', won: 5 }];
131+
132+
const rows = mergeByDimensions(base, extra, DIMS, ['won']);
133+
134+
expect(rows).toHaveLength(1);
135+
expect(rows[0]).toMatchObject({ region: 1, segment: 2, revenue: 10, won: 5 });
136+
});
137+
});
138+
139+
// ── the same properties through the real executor ───────────────────────────
140+
141+
function fakeService(handler: (q: AnalyticsQuery) => AnalyticsResult): IAnalyticsService {
142+
return { query: vi.fn(async (q: AnalyticsQuery) => handler(q)), getMeta: async () => [] };
143+
}
144+
145+
const matrix = DatasetSchema.parse({
146+
name: 'matrix', label: 'Matrix', object: 'opportunity',
147+
dimensions: [
148+
{ name: 'region', field: 'region', type: 'string' },
149+
{ name: 'segment', field: 'segment', type: 'string' },
150+
{ name: 'close_date', field: 'close_date', type: 'date' },
151+
],
152+
measures: [
153+
{ name: 'revenue', aggregate: 'sum', field: 'amount' },
154+
{ name: 'won_count', aggregate: 'count', filter: { stage: 'closed_won' } },
155+
],
156+
});
157+
158+
/**
159+
* Four groups over `region × segment`, each with its own numbers:
160+
* - the adjacent-value pair {ab, c} / {a, bc};
161+
* - the unassigned/blank pair {null, ent} / {'', ent}.
162+
*
163+
* `won_count` carries a filter, so it arrives as a SEPARATE grouped query that
164+
* has to be merged back by dimension key — the seam under test. Every group
165+
* reports a distinct `won_count`, so any collision shows up as one row wearing
166+
* another's number rather than as a missing row.
167+
*/
168+
const GROUPS = [
169+
{ region: 'ab', segment: 'c', revenue: 10, won_count: 1 },
170+
{ region: 'a', segment: 'bc', revenue: 20, won_count: 2 },
171+
{ region: null as string | null, segment: 'ent', revenue: 30, won_count: 3 },
172+
{ region: '', segment: 'ent', revenue: 40, won_count: 4 },
173+
];
174+
175+
const isShifted = (q: AnalyticsQuery) => JSON.stringify(q.timeDimensions ?? []).includes('2025-12');
176+
177+
/** Previous-window numbers are the current ones ×10, so a mix-up is legible. */
178+
const matrixService = fakeService((q) => {
179+
const scale = isShifted(q) ? 10 : 1;
180+
const measure = q.measures[0];
181+
return {
182+
rows: GROUPS.map((g) => ({
183+
region: g.region,
184+
segment: g.segment,
185+
[measure]: (measure === 'revenue' ? g.revenue : g.won_count) * scale,
186+
})),
187+
fields: [],
188+
};
189+
});
190+
191+
const runMatrix = (compareTo?: boolean) =>
192+
new DatasetExecutor(matrixService).execute(compileDataset(matrix), {
193+
dimensions: ['region', 'segment'],
194+
measures: ['revenue', 'won_count'],
195+
...(compareTo
196+
? {
197+
timeDimensions: [
198+
{ dimension: 'close_date', dateRange: ['2026-01-01', '2026-01-31'] as [string, string] },
199+
],
200+
compareTo: { kind: 'previousPeriod' as const, dimension: 'close_date' },
201+
}
202+
: {}),
203+
});
204+
205+
const find = (rows: Record<string, unknown>[], region: string | null) =>
206+
rows.find((r) => r.region === region && r.segment === (region === null || region === '' ? 'ent' : r.segment))!;
207+
208+
describe('executor — a measure-scoped sub-query merges onto the right row (#4821)', () => {
209+
it('gives every group its own won_count, unassigned and blank included', async () => {
210+
const rows = (await runMatrix()).rows;
211+
212+
expect(rows).toHaveLength(4);
213+
expect(find(rows, 'ab')).toMatchObject({ segment: 'c', revenue: 10, won_count: 1 });
214+
expect(find(rows, 'a')).toMatchObject({ segment: 'bc', revenue: 20, won_count: 2 });
215+
expect(find(rows, null)).toMatchObject({ revenue: 30, won_count: 3 });
216+
expect(find(rows, '')).toMatchObject({ revenue: 40, won_count: 4 });
217+
});
218+
219+
it('does not render the unassigned group as an empty group', async () => {
220+
const rows = (await runMatrix()).rows;
221+
// The blank row used to absorb both merges, leaving the unassigned row's
222+
// `won_count` ABSENT — which #4708 then fills with a confident 0. A count
223+
// of 3 reported as 0 is the shape of this defect after that fill.
224+
expect(find(rows, null).won_count).not.toBe(0);
225+
expect(find(rows, null).won_count).toBe(3);
226+
});
227+
});
228+
229+
describe('executor — the compareTo merge lands on the right row too (#4870 seam, #4821)', () => {
230+
it('attaches each group its OWN __compare columns', async () => {
231+
const rows = (await runMatrix(true)).rows;
232+
233+
expect(rows).toHaveLength(4);
234+
// Previous window = current ×10. A collision on this merge writes one
235+
// group's history into another's row — the `__compare` column is what the
236+
// reader subtracts, so a wrong one inverts the direction of the tile.
237+
expect(find(rows, 'ab')).toMatchObject({ revenue__compare: 100, won_count__compare: 10 });
238+
expect(find(rows, 'a')).toMatchObject({ revenue__compare: 200, won_count__compare: 20 });
239+
expect(find(rows, null)).toMatchObject({ revenue__compare: 300, won_count__compare: 30 });
240+
expect(find(rows, '')).toMatchObject({ revenue__compare: 400, won_count__compare: 40 });
241+
});
242+
243+
it('does not append phantom rows for buckets that already exist', async () => {
244+
// `mergeByDimensions` APPENDS an unmatched extra row, so a key that fails
245+
// to match its own counterpart doubles the grid instead of merging it.
246+
const rows = (await runMatrix(true)).rows;
247+
const keys = rows.map((r) => `${String(r.region)}|${String(r.segment)}`);
248+
expect(new Set(keys).size).toBe(keys.length);
249+
});
250+
});

0 commit comments

Comments
 (0)