Skip to content

Commit b4be309

Browse files
os-zhuangclaude
andauthored
fix(analytics): a new spec aggregate can no longer silently return a row count (#4153)
Track C item 4 of objectui#2945 — "AggregationFunction: three places in lockstep". They agreed only by coincidence: AggregationFunction (spec) 8 members UNSUPPORTED_AGGREGATES 2 rejected at compile native-sql-strategy switch 6 cases + `default: COUNT(*)` 8 − 2 = 6 = the six cases, today. Add a ninth to the spec and it passes the compiler's gate (not in UNSUPPORTED), is advertised as supported by that gate's error message (a hand-written prose list — a third copy of the vocabulary), reaches the switch, matches nothing, and falls to `default: COUNT(*)`. The author asks for a median and gets a row count: no error, no log, wrong figures on a dashboard. Same shape as #3948, in the analytics SQL builder. Derivation plus a guard, no behaviour change: - the switch becomes AGGREGATE_SQL, a table whose coverage is assertable; - the error message's prose becomes SUPPORTED_AGGREGATES, derived as AggregationFunction.options minus UNSUPPORTED_AGGREGATES; - aggregation-lockstep.test.ts asserts the arithmetic in both directions. Verified by adding a hypothetical `median` to the spec: three assertions now fail naming it, including "these would fall through to the COUNT(*) fallback and return a row count". The same edit was green before. Same six aggregates, same six SQL expressions, same fallback for everything else. Reported not fixed (recorded in the strategy's doc comment): the fallback is also reached by a `number`/`string`/`boolean` metric — a custom SQL expression per AggregationMetricType — whose expression is replaced by a row count. Datasets cannot produce one, so it needs a hand-authored Cube; emitting `col` instead is a behavioural change deserving its own tests. service-analytics: 420 tests / 32 files green; tsc clean in the touched files. Refs objectstack-ai/objectui#2945, #3948 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 7309c81 commit b4be309

4 files changed

Lines changed: 200 additions & 11 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): a new spec aggregate can no longer silently return a row count
6+
7+
Track C item 4 of objectstack-ai/objectui#2945*"`AggregationFunction`: three
8+
places in lockstep"*. They agreed only by coincidence, and the failure mode when
9+
they stopped agreeing was silent wrong numbers.
10+
11+
The three:
12+
13+
1. `AggregationFunction` (`@objectstack/spec/data`) — eight members, what an
14+
author may declare as a dataset measure's `aggregate`.
15+
2. `UNSUPPORTED_AGGREGATES` (`dataset-compiler.ts`) — `array_agg`/`string_agg`,
16+
rejected at compile time with a clear error.
17+
3. The aggregate `switch` in `native-sql-strategy.ts` — six cases, then
18+
`default: return 'COUNT(*)'`.
19+
20+
8 − 2 = 6 = the six cases, today. Add a ninth member to the spec — `median`,
21+
`percentile`, anything — and it would:
22+
23+
- pass the compiler's gate, since it is not in `UNSUPPORTED_AGGREGATES`;
24+
- be **advertised as supported** by that gate's error message, which listed
25+
`count, sum, avg, min, max, count_distinct` as hand-written prose — a third
26+
copy of the vocabulary;
27+
- reach the strategy's `switch`, match no case, and fall to
28+
`default: COUNT(*)`.
29+
30+
The author asks for a median and gets a row count. No error, no log, wrong
31+
figures on a dashboard — the same silent-wrong-answer shape as the filter
32+
operators in #3948, in the analytics SQL builder.
33+
34+
**The fix is derivation plus a guard, with no behaviour change.** The `switch`
35+
becomes `AGGREGATE_SQL`, a table whose coverage is assertable; the error
36+
message's prose list becomes `SUPPORTED_AGGREGATES`, derived as
37+
`AggregationFunction.options` minus `UNSUPPORTED_AGGREGATES`; and
38+
`aggregation-lockstep.test.ts` asserts the arithmetic — the lowered set equals
39+
the admitted set, every spec member is either lowered or explicitly rejected,
40+
nothing is both, and the rejection list names only aggregates the spec has.
41+
42+
Verified by adding a hypothetical `median` to the spec, which now fails three
43+
assertions naming it, including *"these would fall through to the COUNT(*)
44+
fallback and return a row count"*. Before this change the same edit was green.
45+
46+
Nothing is narrowed and no SQL changes: the same six aggregates lower to the
47+
same six expressions, and the `COUNT(*)` fallback still catches everything else.
48+
49+
**Reported, not fixed:** that fallback is also reached by a measure whose `type`
50+
is `number`/`string`/`boolean` — a custom SQL *expression*, per
51+
`AggregationMetricType` — whose expression is then replaced by a row count.
52+
Datasets cannot produce one (`aggregateToMetricType` only ever returns an
53+
`AggregationFunction` member), so it is reachable only from a hand-authored
54+
Cube. Emitting `col` instead is a behavioural change in an analytics SQL path
55+
and deserves its own change with its own tests; the strategy's doc comment now
56+
records it.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* One aggregate vocabulary, three places that must agree (objectui#2945 Track C).
5+
*
6+
* 1. `AggregationFunction` (`@objectstack/spec/data`) — what an author may
7+
* declare as a dataset measure's `aggregate`.
8+
* 2. `UNSUPPORTED_AGGREGATES` (`dataset-compiler.ts`) — the ones v1 rejects at
9+
* compile time, with a clear error.
10+
* 3. `AGGREGATE_SQL` (`native-sql-strategy.ts`) — the ones it can lower to SQL.
11+
*
12+
* (2) and (3) are complements of each other over (1), and nothing enforced it.
13+
* The strategy used to `switch` with `default: return 'COUNT(*)'`, so an
14+
* aggregate added to the spec would pass the compiler's gate, be advertised as
15+
* supported by its error message, and then return **a row count in place of the
16+
* number the author asked for** — no error, no log, wrong analytics.
17+
*
18+
* These tests make that arithmetic explicit, so growing the vocabulary fails
19+
* here instead of shipping a silently wrong figure.
20+
*/
21+
import { describe, it, expect } from 'vitest';
22+
import { AggregationFunction } from '@objectstack/spec/data';
23+
import { UNSUPPORTED_AGGREGATES, SUPPORTED_AGGREGATES } from './dataset-compiler.js';
24+
import { SUPPORTED_AGGREGATE_SQL_KEYS } from './strategies/native-sql-strategy.js';
25+
26+
describe('aggregate vocabulary lockstep', () => {
27+
it('the strategy lowers exactly the aggregates the compiler admits', () => {
28+
expect([...SUPPORTED_AGGREGATE_SQL_KEYS].sort()).toEqual([...SUPPORTED_AGGREGATES].sort());
29+
});
30+
31+
it('every spec aggregate is either lowered or explicitly rejected', () => {
32+
const lowered = new Set(SUPPORTED_AGGREGATE_SQL_KEYS);
33+
const unhandled = AggregationFunction.options.filter(
34+
(a: string) => !lowered.has(a) && !UNSUPPORTED_AGGREGATES.has(a),
35+
);
36+
expect(
37+
unhandled,
38+
'these would fall through to the COUNT(*) fallback and return a row count',
39+
).toEqual([]);
40+
});
41+
42+
it('nothing is both rejected and lowered', () => {
43+
const both = SUPPORTED_AGGREGATE_SQL_KEYS.filter((a) => UNSUPPORTED_AGGREGATES.has(a));
44+
expect(both).toEqual([]);
45+
});
46+
47+
it('the rejection list names only aggregates the spec actually has', () => {
48+
// A stale entry here silently *widens* what v1 claims to support: the name
49+
// is subtracted from SUPPORTED_AGGREGATES for nothing.
50+
const stray = [...UNSUPPORTED_AGGREGATES].filter(
51+
(a) => !(AggregationFunction.options as string[]).includes(a),
52+
);
53+
expect(stray).toEqual([]);
54+
});
55+
56+
it('the two halves partition the vocabulary', () => {
57+
expect(SUPPORTED_AGGREGATES.length + UNSUPPORTED_AGGREGATES.size)
58+
.toBe(AggregationFunction.options.length);
59+
});
60+
61+
it('records the current split, so a vocabulary change is visible in review', () => {
62+
expect([...SUPPORTED_AGGREGATES].sort())
63+
.toEqual(['avg', 'count', 'count_distinct', 'max', 'min', 'sum']);
64+
expect([...UNSUPPORTED_AGGREGATES].sort()).toEqual(['array_agg', 'string_agg']);
65+
});
66+
});
67+
68+
describe('the compiler error message is derived, not restated', () => {
69+
it('names every supported aggregate, and none of the unsupported ones', async () => {
70+
const { compileDataset } = await import('./dataset-compiler.js');
71+
let message = '';
72+
try {
73+
compileDataset({
74+
name: 'agg_probe',
75+
object: 'showcase_task',
76+
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
77+
measures: [{ name: 'names', field: 'title', aggregate: 'string_agg' }],
78+
} as never);
79+
} catch (e) {
80+
message = (e as Error).message;
81+
}
82+
expect(message).toContain('string_agg');
83+
for (const a of SUPPORTED_AGGREGATES) {
84+
expect(message, `error message omits supported aggregate "${a}"`).toContain(a);
85+
}
86+
// The prose list it replaced would have kept claiming these are supported.
87+
expect(message).not.toContain('array_agg,');
88+
});
89+
});

packages/services/service-analytics/src/dataset-compiler.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Cube, Metric, Dimension as CubeDimension, CubeJoin } from '@objectstack/spec/data';
4+
import { AggregationFunction } from '@objectstack/spec/data';
45
import type { Dataset, DatasetMeasure, DatasetDimension } from '@objectstack/spec/ui';
56
import type { FilterCondition } from '@objectstack/spec/data';
67

@@ -21,7 +22,20 @@ import type { FilterCondition } from '@objectstack/spec/data';
2122
*/
2223

2324
/** Operators v1 does NOT compile to the Cube SQL switch — surfaced as a clear error. */
24-
const UNSUPPORTED_AGGREGATES = new Set(['array_agg', 'string_agg']);
25+
export const UNSUPPORTED_AGGREGATES = new Set(['array_agg', 'string_agg']);
26+
27+
/**
28+
* What v1 *can* lower — derived from the spec's vocabulary rather than restated.
29+
*
30+
* The list used to be hand-written prose inside the error message below, which
31+
* made it a third copy of one vocabulary (after `AggregationFunction` and the
32+
* `native-sql-strategy` switch) with nothing keeping the three in step. An
33+
* aggregate added to the spec would have passed this gate, been reported as
34+
* supported by that message, and then hit the strategy's `default` — returning
35+
* a row count in place of the requested number. objectui#2945.
36+
*/
37+
export const SUPPORTED_AGGREGATES: string[] = AggregationFunction.options
38+
.filter((a: string) => !UNSUPPORTED_AGGREGATES.has(a));
2539

2640
export interface DerivedMeasureSpec {
2741
name: string;
@@ -82,7 +96,7 @@ function aggregateToMetricType(m: DatasetMeasure): Metric['type'] {
8296
if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
8397
throw new Error(
8498
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is ` +
85-
`not supported by the v1 dataset runtime (supported: count, sum, avg, min, max, count_distinct).`,
99+
`not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(', ')}).`,
86100
);
87101
}
88102
return m.aggregate as Metric['type'];

packages/services/service-analytics/src/strategies/native-sql-strategy.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,49 @@ import { normalizeAnalyticsFilters, coerceFilterValueForSql } from './filter-nor
77
import { compileScopedFilterToSql } from '../read-scope-sql.js';
88
import { nextUtcCalendarDay } from '@objectstack/core';
99

10+
/**
11+
* The SQL wrapper for each aggregate a measure's `type` can name.
12+
*
13+
* A table rather than a `switch` so its coverage is *assertable*: the aggregate
14+
* vocabulary lives in `@objectstack/spec` (`AggregationFunction`), the dataset
15+
* compiler subtracts the two it cannot lower (`array_agg`, `string_agg`), and
16+
* `aggregation-lockstep.test.ts` checks that what remains is exactly the keys
17+
* below. A `switch` gave that no purchase — the missing case fell to
18+
* `default: COUNT(*)`, so an aggregate the spec grew would have returned a row
19+
* count instead of the number the author asked for, silently. objectui#2945.
20+
*
21+
* Non-aggregate metric types (`number`/`string`/`boolean` — custom SQL
22+
* expressions, `AggregationMetricType` in `data/analytics.zod.ts`) are
23+
* deliberately absent, and keep the caller's existing fallback rather than
24+
* changing behaviour here; see the note at {@link NativeSQLStrategy}.
25+
*/
26+
const AGGREGATE_SQL: Record<string, (col: string) => string> = {
27+
'count': () => 'COUNT(*)',
28+
'sum': (col) => `SUM(${col})`,
29+
'avg': (col) => `AVG(${col})`,
30+
'min': (col) => `MIN(${col})`,
31+
'max': (col) => `MAX(${col})`,
32+
'count_distinct': (col) => `COUNT(DISTINCT ${col})`,
33+
};
34+
35+
/** Exported for the lockstep guard — the aggregates this strategy can lower. */
36+
export const SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
37+
1038
/**
1139
* NativeSQLStrategy — Priority 1
1240
*
1341
* Pushes the analytics query down to the database as a native SQL statement.
1442
* This is the most efficient path and is preferred whenever the backing driver
1543
* supports raw SQL execution (e.g. Postgres, MySQL, SQLite).
44+
*
45+
* Known gap, unchanged by the lockstep work and reported separately: a measure
46+
* whose `type` is `number`/`string`/`boolean` — a custom SQL *expression*, not
47+
* an aggregate — also lands on the `COUNT(*)` fallback in
48+
* `resolveMeasureSql`, so its expression is replaced by a row count. Datasets
49+
* cannot produce such a measure (`aggregateToMetricType` only ever returns an
50+
* `AggregationFunction` member), so this is reachable only from a hand-authored
51+
* Cube. Left as-is on purpose: emitting `col` instead is a behavioural change
52+
* in an analytics SQL path, and deserves its own change with its own tests.
1653
*/
1754
export class NativeSQLStrategy implements AnalyticsStrategy {
1855
readonly name = 'NativeSQLStrategy';
@@ -370,15 +407,8 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
370407
const col = measure.sql === '*'
371408
? '*'
372409
: this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
373-
switch (measure.type) {
374-
case 'count': return 'COUNT(*)';
375-
case 'sum': return `SUM(${col})`;
376-
case 'avg': return `AVG(${col})`;
377-
case 'min': return `MIN(${col})`;
378-
case 'max': return `MAX(${col})`;
379-
case 'count_distinct': return `COUNT(DISTINCT ${col})`;
380-
default: return `COUNT(*)`;
381-
}
410+
const wrap = AGGREGATE_SQL[measure.type];
411+
return wrap ? wrap(col) : `COUNT(*)`;
382412
}
383413

384414
private resolveFieldSql(

0 commit comments

Comments
 (0)