Skip to content

Commit d5b330d

Browse files
hotlongclaude
andauthored
feat(spec,analytics): AnalyticsResult.fields[].builtinAggregate discriminator for built-in default measures (#14492) (#15017)
`AnalyticsResult.fields[]` gains an optional `builtinAggregate` carrying the closed `AggregationFunction` vocabulary (count | sum | avg | min | max | count_distinct); `AnalyticsResultResponseSchema` mirrors it. The producer (`queryDataset`'s measure enrichment) sets it from the dataset measure's own `aggregate` exactly when the measure has NO authored `label` — the header a renderer draws for it is then the server's built-in default, not an author's text, so an i18n-aware consumer may substitute its own localized name. The `dataset` create seed drops its hardcoded `label: 'Count'` so a Studio-created dataset is a built-in default on the wire rather than an authored English literal. Authored labels (string or locale map) never carry the field. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 066dd3b commit d5b330d

9 files changed

Lines changed: 233 additions & 1 deletion

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-analytics": minor
4+
---
5+
6+
feat(spec,analytics): `AnalyticsResult.fields[].builtinAggregate` — a closed discriminator for a measure column whose display name is the server's built-in default (#14492)
7+
8+
**What a consumer sees.** `queryDataset()` (and `POST /api/v1/analytics/dataset/query`,
9+
which relays the result verbatim) now carries an optional
10+
`fields[].builtinAggregate?: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'`
11+
on a measure column. It is present exactly when the dataset measure behind the
12+
column declares an `aggregate` and **no** `label` — the producer then has nothing
13+
but the aggregate to name the column by, so it says which aggregate that is. It is
14+
absent whenever the author declared a label (a plain string or an inline locale
15+
map, even one with no entry for the request locale: an author's text is never
16+
re-labelled by a consumer), and absent on dimension columns and derived measures.
17+
The vocabulary is `AggregationFunction` (`data/query.zod.ts`), the one closed
18+
aggregate enum — no second spelling. `AnalyticsResultResponseSchema`
19+
(`api/analytics.zod.ts`) mirrors the member, refusing a spelling outside the enum.
20+
21+
**Why.** An AI-built dashboard's "count of customers by status" chart showed the
22+
English axis title "Count" on a Chinese UI. The renderer (objectui
23+
`buildChartSeries()` / `labelOf()`) treats `fields[].label` as resolved author
24+
content and passes it through verbatim — correctly, since a real custom label
25+
("Tasks") must survive. What it could not tell apart was an author's text from
26+
the server's built-in default for a bare `count`. Guessing from the label text
27+
was refused (it would catch an author who really named a field `Count`, and break
28+
the moment the default is spelled in another language); translating on the
29+
server was not taken (it copies the front end's language decision into the
30+
producer and leaves nothing for a per-widget override). The ruling (2026-09-02,
31+
option B) is a structured discriminator on the contract: the consumer prefers a
32+
locale lookup keyed by `builtinAggregate` — mirroring its existing
33+
`report.aggregate.*` keys — and falls back to `label`, then `name`.
34+
35+
**Producer-side changes.**
36+
37+
- `@objectstack/service-analytics``queryDataset`'s measure enrichment sets
38+
`builtinAggregate` from the dataset measure's own `aggregate` when the measure
39+
has no authored `label`. Judged on the authored key, never on the resolved
40+
string.
41+
- `@objectstack/spec` — the `dataset` create seed (`metadata-create-seeds.ts`)
42+
drops its hardcoded `label: 'Count'` from the seeded `count` measure, so a
43+
dataset created from Studio is a built-in default (wire: `builtinAggregate:
44+
'count'`) instead of an authored English literal. `getMeta()` for such a
45+
dataset now titles the metric by its name (`count`) rather than `Count`;
46+
`CubeMeta.measures[].type` already carried the aggregate there.
47+
48+
Purely additive: no key is removed or renamed, no authorable schema changes shape,
49+
and a consumer that ignores the member sees exactly the response it saw before.

packages/rest/src/analytics-routes.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ describe('POST /analytics/dataset/query', () => {
6565
expect(queryDataset.mock.calls[0][1]).toEqual(selection);
6666
});
6767

68+
// #14492 — `fields[].builtinAggregate` is relayed verbatim: the handler ends
69+
// in `res.json(result)` with no reshaping, so the discriminator the service
70+
// set is exactly what the renderer reads.
71+
it('relays fields[].builtinAggregate unchanged (#14492)', async () => {
72+
const fields = [
73+
{ name: 'status', type: 'string', label: 'Status' },
74+
{ name: 'count', type: 'number', builtinAggregate: 'count' },
75+
];
76+
const queryDataset = vi.fn().mockResolvedValue({ rows: [{ status: 'active', count: 3 }], fields });
77+
const { route } = buildServer(async () => ({ queryDataset }));
78+
const res = mockRes();
79+
await route!.handler({ method: 'POST', params: {}, headers: {}, body: { dataset: inlineDataset, selection } } as any, res);
80+
expect(res.statusCode).toBe(200);
81+
expect(res.body.fields).toEqual(fields);
82+
});
83+
6884
it('returns 501 when no analytics service is configured', async () => {
6985
const { route } = buildServer(undefined);
7086
const res = mockRes();

packages/services/service-analytics/src/__tests__/query-dataset.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,3 +531,81 @@ describe('AnalyticsService.queryDataset', () => {
531531
expect(result.rows).toEqual([{ account: 'name-acc1', revenue: 1000 }]);
532532
});
533533
});
534+
535+
// ── #14492 — built-in aggregate discriminator ──────────────────────────────
536+
// `fields[].label` is only ever written from the DATASET MEASURE's own `label`
537+
// (the enrichment block in `queryDataset`), so the question "is this header the
538+
// server's default or the author's text?" is answerable from the measure alone:
539+
// no `label` + an `aggregate` ⇒ built-in default ⇒ the column says which
540+
// aggregate, and an i18n-aware renderer substitutes its own name for it.
541+
describe('AnalyticsService.queryDataset — fields[].builtinAggregate (#14492)', () => {
542+
const svcFor = (rows: Array<Record<string, unknown>>) => new AnalyticsService({
543+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
544+
executeRawSql: async () => rows,
545+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
546+
});
547+
const ds = (measures: Array<Record<string, unknown>>) => DatasetSchema.parse({
548+
name: 'customers', label: 'Customers', object: 'customer', include: [],
549+
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
550+
measures,
551+
});
552+
const run = async (measures: Array<Record<string, unknown>>, names: string[], rows: Array<Record<string, unknown>>) => {
553+
const r = await svcFor(rows).queryDataset(ds(measures), { dimensions: ['status'], measures: names }, { tenantId: 'org_A' } as ExecutionContext);
554+
return Object.fromEntries((r.fields ?? []).map((f) => [f.name, f]));
555+
};
556+
557+
it('a label-less built-in `count` measure carries builtinAggregate: "count" and no label', async () => {
558+
const fields = await run([{ name: 'count', aggregate: 'count' }], ['count'], [{ status: 'active', count: 3 }]);
559+
expect(fields.count?.builtinAggregate).toBe('count');
560+
expect(fields.count?.label).toBeUndefined();
561+
// A dimension column is never a built-in aggregate.
562+
expect(fields.status?.builtinAggregate).toBeUndefined();
563+
});
564+
565+
it('an author-labelled measure ("Tasks") keeps its text and carries NO builtinAggregate', async () => {
566+
const fields = await run([{ name: 'task_count', aggregate: 'count', label: 'Tasks' }], ['task_count'], [{ status: 'open', task_count: 7 }]);
567+
expect(fields.task_count?.label).toBe('Tasks');
568+
expect(fields.task_count?.builtinAggregate).toBeUndefined();
569+
});
570+
571+
it('an inline locale-map label with no entry for the request locale is STILL an authored label — no discriminator', async () => {
572+
const r = await svcFor([{ status: 'open', task_count: 7 }]).queryDataset(
573+
ds([{ name: 'task_count', aggregate: 'count', label: { en: 'Tasks' } }]),
574+
{ dimensions: ['status'], measures: ['task_count'] },
575+
{ tenantId: 'org_A', locale: 'fr' } as ExecutionContext,
576+
);
577+
const f = (r.fields ?? []).find((x) => x.name === 'task_count');
578+
expect(f).toBeDefined();
579+
expect(f?.builtinAggregate).toBeUndefined();
580+
});
581+
582+
it('every aggregate in the closed vocabulary is carried verbatim when the measure is label-less', async () => {
583+
const fields = await run(
584+
[
585+
{ name: 'total', aggregate: 'sum', field: 'amount' },
586+
{ name: 'mean', aggregate: 'avg', field: 'amount' },
587+
{ name: 'owners', aggregate: 'count_distinct', field: 'owner' },
588+
],
589+
['total', 'mean', 'owners'],
590+
[{ status: 'open', total: 10, mean: 5, owners: 2 }],
591+
);
592+
expect(fields.total?.builtinAggregate).toBe('sum');
593+
expect(fields.mean?.builtinAggregate).toBe('avg');
594+
expect(fields.owners?.builtinAggregate).toBe('count_distinct');
595+
});
596+
597+
it('a derived measure has no aggregate and therefore no discriminator, while its label-less inputs keep theirs', async () => {
598+
const fields = await run(
599+
[
600+
{ name: 'base', aggregate: 'count' },
601+
{ name: 'met', aggregate: 'count', field: 'met' },
602+
{ name: 'rate', derived: { op: 'ratio', of: ['met', 'base'] } },
603+
],
604+
['base', 'met', 'rate'],
605+
[{ status: 'open', base: 4, met: 2 }],
606+
);
607+
expect(fields.base?.builtinAggregate).toBe('count');
608+
expect(fields.met?.builtinAggregate).toBe('count');
609+
expect(fields.rate?.builtinAggregate).toBeUndefined();
610+
});
611+
});

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,22 @@ export class AnalyticsService implements IAnalyticsService {
13551355
const label = resolveI18nLabel(m.label, requestLocale);
13561356
if (label !== undefined) f.label = label;
13571357
}
1358+
// #14492 — the built-in aggregate discriminator. A measure that declares
1359+
// an `aggregate` and NO `label` has nothing but the aggregate to be named
1360+
// by: whatever header a renderer draws for it is the server's built-in
1361+
// default, not an author's text, so the column says WHICH aggregate that
1362+
// is and an i18n-aware renderer may substitute its own localized name
1363+
// (objectui keys `report.aggregate.*` on it — objectui#7258).
1364+
// Judged on the AUTHORED key, never on the resolved string: an inline
1365+
// locale map with no entry for `requestLocale` resolves to nothing above,
1366+
// yet it IS an author's label and must never be re-labelled — so the
1367+
// test is `m.label == null`, not `f.label == null`. A derived measure
1368+
// carries no aggregate and gets nothing; an authored label ("Tasks")
1369+
// keeps its text and gets nothing. The other spelling that would work
1370+
// here — a heuristic on the label TEXT ("Count") — was refused on
1371+
// #14492: it would catch an author who really named a field `Count`,
1372+
// and break the moment the default is spelled in another language.
1373+
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
13581374
if (f.format == null && m.format) f.format = m.format;
13591375
// ADR-0053 currency chain. A MONETARY measure resolves its display
13601376
// currency from: explicit measure `currency` → source-field

packages/spec/src/api/analytics.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,31 @@ describe('AnalyticsResultResponseSchema', () => {
205205
expect(resp.data.fields[2].format).toBe('0.0%');
206206
});
207207

208+
// #14492 — the built-in aggregate discriminator rides the same channel, and
209+
// it is the CLOSED `AggregationFunction` vocabulary: a spelling outside it is
210+
// refused at the member, not stripped or passed through.
211+
it('should preserve fields[].builtinAggregate and refuse a spelling outside the closed enum', () => {
212+
const resp = AnalyticsResultResponseSchema.parse({
213+
success: true,
214+
data: {
215+
rows: [{ status: 'active', count: 3 }],
216+
fields: [
217+
{ name: 'status', type: 'string', label: 'Status' },
218+
{ name: 'count', type: 'number', builtinAggregate: 'count' },
219+
],
220+
},
221+
});
222+
expect(resp.data.fields[1].builtinAggregate).toBe('count');
223+
expect(resp.data.fields[0].builtinAggregate).toBeUndefined();
224+
225+
const bad = AnalyticsResultResponseSchema.safeParse({
226+
success: true,
227+
data: { rows: [], fields: [{ name: 'count', type: 'number', builtinAggregate: 'total' }] },
228+
});
229+
expect(bad.success).toBe(false);
230+
expect(bad.success ? [] : bad.error.issues.map((i) => i.path.join('.'))).toContain('data.fields.0.builtinAggregate');
231+
});
232+
208233
it('should preserve totals — the marginal-aggregate channel, grand total included', () => {
209234
const resp = AnalyticsResultResponseSchema.parse({
210235
success: true,

packages/spec/src/api/analytics.zod.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { z } from 'zod';
44
import { AnalyticsQuerySchema } from '../data/analytics.zod';
5+
import { AggregationFunction } from '../data/query.zod';
56
import { BaseResponseSchema } from './contract.zod';
67
import { retiredKey } from '../shared/retired-key';
78

@@ -110,6 +111,13 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema
110111
+ 'not a percentage. Renderers that receive it must scale by it instead '
111112
+ 'of guessing from the value.',
112113
),
114+
builtinAggregate: AggregationFunction.optional().describe(
115+
'Closed aggregate discriminator for a measure column whose display name '
116+
+ 'is the server\'s built-in default: the dataset measure declared an '
117+
+ '`aggregate` and no `label`. A renderer may substitute its own localized '
118+
+ 'name for the aggregate. Absent whenever the author declared a label, and '
119+
+ 'on dimension / derived columns.',
120+
),
113121
})).describe('Column metadata'),
114122
sql: z.string().optional().describe('Executed SQL (if debug enabled)'),
115123
totals: z.array(z.object({

packages/spec/src/contracts/analytics-service.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,24 @@ describe('Analytics Service Contract', () => {
7070
expect(meta[0].measures).toHaveLength(1);
7171
});
7272

73+
// #14492 — `fields[].builtinAggregate` is optional and is the closed
74+
// `AggregationFunction` vocabulary, nothing else. The first two literals are
75+
// the two shapes the producer emits (built-in default vs authored label); the
76+
// third pins the closure at compile time.
77+
it('carries builtinAggregate on a built-in default measure column and refuses a spelling outside the enum', () => {
78+
const builtin: AnalyticsResult['fields'][number] = { name: 'count', type: 'number', builtinAggregate: 'count' };
79+
const authored: AnalyticsResult['fields'][number] = { name: 'task_count', type: 'number', label: 'Tasks' };
80+
expect(builtin.builtinAggregate).toBe('count');
81+
expect(authored.builtinAggregate).toBeUndefined();
82+
const offEnum: AnalyticsResult['fields'][number] = {
83+
name: 'count',
84+
type: 'number',
85+
// @ts-expect-error — `total` is not an AggregationFunction; the discriminator is closed
86+
builtinAggregate: 'total',
87+
};
88+
expect(offEnum.name).toBe('count');
89+
});
90+
7391
it('should generate SQL without executing', async () => {
7492
const service: IAnalyticsService = {
7593
query: async () => ({ rows: [], fields: [] }),

packages/spec/src/contracts/analytics-service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ export interface AnalyticsResult {
6666
* by it instead of guessing from the value (objectui#3136).
6767
*/
6868
percentScale?: PercentScale;
69+
/**
70+
* #14492 — the closed aggregate discriminator for a measure column whose
71+
* display name is the SERVER's built-in default rather than an author's
72+
* text. Present exactly when the dataset measure behind the column
73+
* declares an `aggregate` and NO `label`: the producer then has nothing
74+
* but the aggregate to name the column by, so it says which aggregate
75+
* that is (`count` for the seeded / auto-derived `count` measure) and a
76+
* renderer may substitute its own localized name for it — objectui keys
77+
* a locale lookup on this value (its `report.aggregate.*` family) and
78+
* falls back to `label`, then `name`. Absent whenever the author declared
79+
* a label (a plain string OR an inline locale map, even one with no entry
80+
* for the request locale — an author's text is never re-labelled by a
81+
* consumer), and absent on dimension columns and derived measures.
82+
* Reuses `AggregationFunction` (data/query.zod.ts), the one closed
83+
* aggregate vocabulary; no second spelling.
84+
*/
85+
builtinAggregate?: AggregationFunction;
6986
}>;
7087
/** Generated SQL (if available) */
7188
sql?: string;

packages/spec/src/kernel/metadata-create-seeds.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,12 @@ const BUILTIN_METADATA_CREATE_SEEDS: Partial<Record<MetadataType, unknown>> = {
9595
object: PLACEHOLDER_OBJECT,
9696
dimensions: [],
9797
// A dataset needs at least one measure to be useful; seed a count.
98-
measures: [{ name: 'count', label: 'Count', aggregate: 'count' }],
98+
// [#14492] No `label` on purpose: an authored label is the author's text
99+
// and reaches the wire verbatim (`AnalyticsResult.fields[].label`), which
100+
// made this seeded "Count" an English literal on every non-English chart.
101+
// Label-less, the measure is the server's built-in default — the response
102+
// carries `builtinAggregate: 'count'` and the renderer localizes the name.
103+
measures: [{ name: 'count', aggregate: 'count' }],
99104
},
100105
object: {
101106
name: 'new_object',

0 commit comments

Comments
 (0)