From 1d4a2aa1451caf54c9ccc0cd184ce9bac8ff0093 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 11:28:01 +0000 Subject: [PATCH 1/2] fix(service-analytics): compareTo applies measure-scoped filters, so __compare is the same measure as its neighbour (#4820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny --- .../analytics-compare-measure-filters.md | 56 ++++++ .../dataset-compare-measure-filters.test.ts | Bin 0 -> 15501 bytes .../service-analytics/src/dataset-executor.ts | 189 +++++++++++++----- 3 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 .changeset/analytics-compare-measure-filters.md create mode 100644 packages/services/service-analytics/src/__tests__/dataset-compare-measure-filters.test.ts diff --git a/.changeset/analytics-compare-measure-filters.md b/.changeset/analytics-compare-measure-filters.md new file mode 100644 index 0000000000..6644ba9ec8 --- /dev/null +++ b/.changeset/analytics-compare-measure-filters.md @@ -0,0 +1,56 @@ +--- +"@objectstack/service-analytics": patch +--- + +fix(service-analytics): `compareTo` applies measure-scoped filters, so `__compare` is the same measure as the column beside it (#4820) + +A dataset measure declared with its own `filter` is scoped by running a +supplementary grouped sub-query — `combineFilters(baseFilter, measureFilters[m])` +— and merging it back by dimension key. The `compareTo` pass did not: it issued +**one** shifted query over every base measure with only the base filter as its +`where`, and never consulted `compiled.measureFilters` at all. + +For a dataset like + +```ts +measures: [ + { name: 'revenue', aggregate: 'sum', field: 'amount' }, + { name: 'won_count', aggregate: 'count', filter: { stage: 'closed_won' } }, +] +``` + +the current-period column was scoped and the comparison column was not — two +different measures rendered side by side under one label: + +| # | measures | where | | +|:---|:---|:---|:---| +| 1 | `revenue` | — | current | +| 2 | `won_count` | `{"stage":"closed_won"}` | current | +| 3 | `revenue`, `won_count` | **absent** | shifted | + +`won_count__compare` was therefore a count of **every** opportunity in the +previous window, inflated by exactly the rows the measure exists to exclude. +The error runs one way: the comparison period always looks better, so a "won +deals vs. last month" tile reads as a collapse when nothing went wrong. Only +filter-scoped measures were affected — the unfiltered ones next to them compared +correctly, which is what made it survive. + +The comparison window now runs the **same pass** as the current period — +unfiltered measures in one shifted query plus one shifted sub-query per +filter-scoped measure, merged by dimension key — through a single shared +implementation, so the two paths cannot re-diverge at the next change. The +dataset filter, the presentation's `runtimeFilter` and the measure's own filter +compose identically in both windows; the only difference between them is the +shifted `dateRange`. + +Numbers reported by existing dashboards change where a filtered measure was +compared: with 3 won deals this month against 1 won of 5 opportunities last +month, `won_count__compare` was `5` and is now `1`. + +Cost: one extra query per filter-scoped measure when `compareTo` is set. +Selections whose measures carry no filter are untouched and still compare in a +single shifted query. + +The empty-group fill (#4708) covers the new seam: a group the measure's filter +empties in the *previous* window now reports `0` for a `count`/`sum` compare +column rather than blanking it, exactly as it already did for the current period. diff --git a/packages/services/service-analytics/src/__tests__/dataset-compare-measure-filters.test.ts b/packages/services/service-analytics/src/__tests__/dataset-compare-measure-filters.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d8d3a11fd21e3bb1a50831a0234ef6857803efc GIT binary patch literal 15501 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 literal 0 HcmV?d00001 diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index 3f153f7a70..c85bea3de5 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -25,12 +25,14 @@ export type CompareTo = DatasetCompareTo; * runtime filter, compareTo) into one or more `AnalyticsQuery`s against the Cube * runtime, then post-processes the results: * - resolves the base measures a selection needs (including derived deps), - * - applies measure-scoped filters via supplementary grouped queries, + * - applies measure-scoped filters via supplementary grouped queries — in + * EVERY window it runs, the `compareTo` one included (#4820), * - fills the empty-group value into columns no query reported, by aggregate * kind (#4708) — a count/sum over an excluded group is 0, avg/min/max null, * - evaluates derived measures (ratio/sum/difference/product) row-by-row (Q1), - * - shifts the query for `compareTo` (previousPeriod / previousYear) and - * attaches `__compare` columns, + * - shifts the queries for `compareTo` (previousPeriod / previousYear) and + * attaches `__compare` columns, re-running the same measure pass + * so a filtered measure means the same thing in both columns, * - computes server-side totals (`selection.totals.groupings`, #1753) by * re-running the selection per dimension subset, so matrix subtotals and * the grand total use each measure's true aggregate, @@ -133,6 +135,36 @@ export function combineFilters( return a ?? b; } +/** + * Partition base measures into those the dataset scopes with their own + * measure-level `filter` and those it does not — the single place that answers + * "does this measure carry its own filter?". + * + * Paired with {@link DatasetExecutor.runMeasurePass}, this is what keeps ONE + * definition of "how a measure filter is applied" for every grouped pass the + * executor runs: the current period, each `totals` subset, and the `compareTo` + * window. `compareTo` used to issue a single shifted query over all base + * measures with only the base filter, consulting `measureFilters` nowhere on + * that path — so a measure declared `filter: { stage: 'closed_won' }` was + * scoped in its own column and unscoped in `__compare`: two different + * measures rendered side by side under one label, and biased the worst way + * (the comparison window is inflated by exactly the rows the measure exists to + * exclude, so "won deals vs. last month" reads as a collapse). #4820. + * + * The remedy is deliberately NOT a second copy of the filter logic on the + * compare path — two implementations of one rule diverge again at the next + * change. Both paths call the same split and the same pass. + */ +export function splitMeasuresByFilter( + measures: Iterable, + measureFilters: Record, +): { unfiltered: string[]; filtered: string[] } { + const unfiltered: string[] = []; + const filtered: string[] = []; + for (const m of measures) (measureFilters[m] ? filtered : unfiltered).push(m); + return { unfiltered, filtered }; +} + /** * Evaluate derived measures on each aggregated row, mutating a shallow copy. * Division by zero (and missing operands) yields `null` rather than Infinity/NaN. @@ -544,11 +576,7 @@ export class DatasetExecutor { } // Split measures into those with a scoped filter and those without. - const unfiltered: string[] = []; - const filtered: string[] = []; - for (const m of baseMeasures) { - (compiled.measureFilters[m] ? filtered : unfiltered).push(m); - } + const { unfiltered, filtered } = splitMeasuresByFilter(baseMeasures, compiled.measureFilters); const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter); const dimensions = selection.dimensions ?? []; @@ -584,34 +612,17 @@ export class DatasetExecutor { ? { order, limit: selection.limit, offset: selection.offset } : undefined; - // Primary query: all unfiltered base measures in one pass. When every base - // measure is filter-scoped, the supplementary queries below build the grid. - let result: AnalyticsResult; - if (unfiltered.length > 0 || filtered.length === 0) { - result = await this.service.query(this.buildQuery(compiled, { - measures: unfiltered, - dimensions, - where: baseFilter, - selection, - contextTimezone: context?.timezone, - window: windowQuery, - }), context); - } else { - result = { rows: [], fields: [] }; - } - - // Supplementary queries: one per measure-scoped filter, merged by dimension key. - for (const m of filtered) { - const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]); - const sub = await this.service.query(this.buildQuery(compiled, { - measures: [m], dimensions, where: mFilter, selection, - contextTimezone: context?.timezone, - }), context); - result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]); - result.fields.push({ name: m, type: 'number' }); - } + // The current-period pass: unfiltered base measures in one query plus one + // supplementary query per measure-scoped filter, merged by dimension key. + const result = await this.runMeasurePass(compiled, selection, { + measures: [...baseMeasures], + dimensions, + baseFilter, + window: windowQuery, + context, + }); - // compareTo — run a shifted query over the same base measures and attach. + // compareTo — run the SAME pass over the shifted window and attach. if (selection.compareTo) { const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context); result.rows = mergeByDimensions( @@ -682,6 +693,80 @@ export class DatasetExecutor { return result; } + /** + * Run ONE grouped pass over a set of base measures, honouring each measure's + * own scoped `filter`: the unfiltered measures in a single query, plus one + * supplementary query per filter-scoped measure, merged back by dimension key. + * + * **This is the executor's only implementation of "how a measure filter is + * applied", and every window goes through it** — the current period, each + * `totals` subset (which re-enters via `executeSelection`), and the + * `compareTo` window. Before #4820 the comparison window had its own, + * simpler answer: one shifted query over all base measures with only the + * base filter, so `compiled.measureFilters` was never read on that path. + * `won_count` counted won deals and `won_count__compare` counted every deal, + * under one label, in adjacent columns. Only measures carrying a filter were + * wrong — which is what made it survive: the unfiltered ones next to them + * compared correctly. + * + * The caller supplies the `selection` this pass queries under, which is how + * the comparison window differs at all: same measures, same dimensions, same + * filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else + * about the two passes may drift, because anything that does becomes a + * discrepancy between two columns the reader is invited to subtract. + * + * Cost: one extra query per filter-scoped measure when `compareTo` is set. + * The alternative — declaring the discrepancy in the response — is not one, + * since the two columns exist to be directly comparable. + * + * @param window - Ordering/window to push into the SQL. Only ever set for a + * selection the caller proved is a single self-sufficient query; a pass + * that fans out must return its whole grid for the merge. + */ + private async runMeasurePass( + compiled: CompiledDataset, + selection: DatasetSelection, + opts: { + measures: string[]; + dimensions: string[]; + baseFilter?: FilterCondition; + window?: { order?: Record; limit?: number; offset?: number }; + context?: ExecutionContext; + }, + ): Promise { + const { measures, dimensions, baseFilter, window, context } = opts; + const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters); + + // Primary query: all unfiltered base measures in one pass. When every base + // measure is filter-scoped, the supplementary queries below build the grid. + let result: AnalyticsResult; + if (unfiltered.length > 0 || filtered.length === 0) { + result = await this.service.query(this.buildQuery(compiled, { + measures: unfiltered, + dimensions, + where: baseFilter, + selection, + contextTimezone: context?.timezone, + window, + }), context); + } else { + result = { rows: [], fields: [] }; + } + + // Supplementary queries: one per measure-scoped filter, merged by dimension key. + for (const m of filtered) { + const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]); + const sub = await this.service.query(this.buildQuery(compiled, { + measures: [m], dimensions, where: mFilter, selection, + contextTimezone: context?.timezone, + }), context); + result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]); + result.fields.push({ name: m, type: 'number' }); + } + + return result; + } + /** * The selected dimensions the compiled cube types as `time`, in selection * order (#3916) — the axis {@link resolveOrdering} defaults to ascending. @@ -796,21 +881,25 @@ export class DatasetExecutor { const shiftedTd = (selection.timeDimensions ?? []).map((t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t, ); - // Built through `buildQuery` so the comparison pass buckets its date - // dimensions EXACTLY like the primary pass. Hand-rolling the query here - // skipped granularity resolution, so a bucketed primary grid ("2026-04") - // was merged against raw-timestamp comparison rows and no dimension key - // ever matched — every `__compare` column came back empty. The shifted - // `timeDimensions` still win for their own dimension (rule 1 of the - // precedence chain); `window` is deliberately omitted — the comparison grid - // must stay whole for the merge. - const sub = await this.service.query(this.buildQuery(compiled, { - measures, - dimensions, - where: baseFilter, - selection: { ...selection, timeDimensions: shiftedTd }, - contextTimezone: context?.timezone, - }), context); + // Run the SAME pass the current period ran, over the shifted window: same + // measures, same dimensions, same base filter, and — since #4820 — the same + // measure-scoped filters, applied by the same supplementary sub-queries. + // Issuing one flat query here instead is what made `__compare` + // report a different measure than the column beside it. + // + // Going through `runMeasurePass` (and so `buildQuery`) also keeps the + // comparison pass bucketing its date dimensions EXACTLY like the primary + // pass. Hand-rolling the query here skipped granularity resolution, so a + // bucketed primary grid ("2026-04") was merged against raw-timestamp + // comparison rows and no dimension key ever matched — every `__compare` + // column came back empty. The shifted `timeDimensions` still win for their + // own dimension (rule 1 of the precedence chain); `window` is deliberately + // omitted — the comparison grid must stay whole for the merge. + const sub = await this.runMeasurePass( + compiled, + { ...selection, timeDimensions: shiftedTd }, + { measures, dimensions, baseFilter, context }, + ); // Rename measure columns to `__compare` so they merge alongside primary. return sub.rows.map((row) => { const out: Record = {}; From 0a3e4ecfd90d87a283564b6fad7d32d99892b7b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 11:34:17 +0000 Subject: [PATCH 2/2] fix(service-analytics): key the compare-filter test fake without a raw NUL byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny --- .../dataset-compare-measure-filters.test.ts | Bin 15501 -> 15841 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/dataset-compare-measure-filters.test.ts b/packages/services/service-analytics/src/__tests__/dataset-compare-measure-filters.test.ts index 3d8d3a11fd21e3bb1a50831a0234ef6857803efc..03859c4f5aa792f3c9f054786a3783a0e3c8afd7 100644 GIT binary patch delta 373 zcmXw!y-EW?6oq>WtE5PGz#z2|=CocDJ3^7-4_OsBY`mMUbg+`_DK$jP<{9(9g&NOXm{qqw4*_SQJ^ z$poL~D1$E*lb4@hI3YCt2=autrWTE14IfaEHO??a6eky#XQyKq^wQCQmqLtCl(Wiadn6c){3!jVYtQ5+XoaR;C^#J zI#MY|VR932UeS_D$uE~uiKJMzRR@(Q7L7KgmJLZslq#<@Pqw>W|G1+86Q}B;sJ3d* c+kW>a1I%Xd+1k;C=l58|nQb^rhX delta 63 zcmaD@-CMcgnTT?7eqM2jLUwAUf~`VIW^S=wZeoFkMvA6_t(`(}Nl|8Ay2fMyaXl_g SO}(uA%sdTshRsvOeAEHx9~933