|
| 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