diff --git a/.changeset/21995-measure-column-aggregate.md b/.changeset/21995-measure-column-aggregate.md new file mode 100644 index 00000000000..06b997295f9 --- /dev/null +++ b/.changeset/21995-measure-column-aggregate.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-analytics": minor +--- + +feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (`fields[].aggregate`) + +Clause-②: yes (widening) + +- **What a renderer can now read.** Each measure column of a dataset answer (`POST /analytics/dataset/query`) carries `fields[].aggregate`: the aggregate its dataset measure declares, in the closed `AggregationFunction` vocabulary (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`). It is there whether or not the author gave the measure a `label`. So a chart can tell a count from a sum, for example to draw whole-number axis ticks for a count instead of 0.75 / 1.5 / 2.25. +- **What was missing.** The only aggregate on the wire was `builtinAggregate`, and it is present only when the measure has no `label`. A labelled measure, such as a `count` named "Tasks", reached the wire as `{ name, type: 'number', label }`, with nothing to say what kind of number it was. +- **Where it is set.** `AnalyticsService` writes it in the one step that describes a dataset answer's columns from the dataset's own measures. That step runs for both the live query and the draft-data preview, so the two answers agree. A measure's `__compare` column carries the same aggregate. +- **Where it is absent.** Dimension columns. Derived measures, which combine other measures and have no single aggregate (a stray `aggregate` written beside `derived` is ignored when the dataset compiles, so it is not stated here either). And a cube query answer (`POST /analytics/query`), which does not run through the dataset column step. +- **Unchanged.** `builtinAggregate` keeps its meaning: present only on a label-less measure column, to mark a header that is the server's default. No authoring key is added; `aggregate` is a response member only. `AnalyticsResultResponseSchema` and the `AnalyticsResult` contract declare the member, and the REST route relays it as it does every other column key. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index af2b77c4495..45316d60432 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -461,7 +461,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member, **`fields[]` is the resolved presentation surface — read it first.** Each entry carries `name` and `type` and, when the producer declares them, `label`, `format`, `currency`, -`percentScale` and `builtinAggregate`: the optional members +`percentScale`, `builtinAggregate` and `aggregate`: the optional members `AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`), mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to it at compile time. Optional means a given column may omit them — read them defensively, @@ -485,6 +485,13 @@ tenant default currency (the `localization.currency` setting), if one is set. A `dynamic` field (the default mode) never lends its `defaultCurrency` — only `fixed` does — so its column shows the tenant default. The cube query on this page carries no column `currency`. + +The dataset query also states each measure column's `aggregate` (`count`, `sum`, `avg`, +`min`, `max`, `count_distinct`), whether or not the author labelled the measure, so a +chart can tell a count from a sum. `builtinAggregate` is narrower: it is present only +when the measure has no `label`, to mark a header that is the server's default. Neither +is set on a dimension column or a derived measure, and the cube query on this page +carries neither. ### `GET /analytics/meta` diff --git a/packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts b/packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts new file mode 100644 index 00000000000..d082945be26 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `fields[].aggregate` — every measure column of a dataset answer states the + * aggregate its measure declares, whether or not the author labelled it. + * + * `builtinAggregate` answers one question only: "is this header the server's + * default?". It is absent the moment an author writes a `label`, and every + * measured showcase widget labels its measure (a `count` named "Tasks"). So on + * exactly the columns a dashboard draws, the answer said `{ name, type: + * 'number', label }` and nothing else, and a chart over a count drew 0.75 / + * 1.5 / 2.25 axis ticks because it could not tell the count from a sum. + * + * The aggregate is part of the dataset's own authored measure, so the ADR-0021 + * column-description seam (`enrichResultColumns`) states it from there, like + * `label` / `format` / `currency` / `percentScale`. That seam serves both paths + * that produce a dataset answer, the live engine query and the ADR-0037 P3 + * draft-data preview, so each pin below is asserted on both. + * + * Reverse verification, direction predicted BEFORE running: deleting the one + * producer line that writes `f.aggregate` turns every positive `aggregate` + * assertion RED on both paths and leaves the absence assertions and every + * `builtinAggregate` assertion GREEN. Deleting only its `!m.derived` guard + * turns the stray-aggregate case RED and nothing else. + */ + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; + +interface Task extends Record { + status: string; + amount: number; + due_on: string; +} + +const ROWS: Task[] = [ + { status: 'open', amount: 100, due_on: '2026-02-03' }, + { status: 'open', amount: 50, due_on: '2026-02-10' }, + { status: 'done', amount: 25, due_on: '2026-02-15' }, + { status: 'done', amount: 10, due_on: '2026-01-20' }, +]; + +const DATASET = DatasetSchema.parse({ + name: 'task_ds', + label: 'Tasks', + object: 'task', + dimensions: [ + { name: 'status', field: 'status', type: 'string', label: 'Status' }, + { name: 'due_on', field: 'due_on', type: 'date', label: 'Due' }, + ], + measures: [ + // The showcase shape: a `count` the author named. + { name: 'task_count', aggregate: 'count', label: 'Tasks' }, + // The built-in default: no label, so `builtinAggregate` too. + { name: 'count', aggregate: 'count' }, + // A `sum` over a currency field. + { name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount', format: '$0,0' }, + // A derived measure has no single aggregate. + { name: 'open_share', derived: { op: 'ratio', of: ['task_count', 'count'] }, label: 'Share' }, + ], +}); + +const sourceFieldMeta = (object: string, field: string) => { + if (object !== 'task') return undefined; + if (field === 'amount') return { type: 'currency' }; + if (field === 'due_on') return { type: 'date' }; + return undefined; +}; + +const CTX = { tenantId: 'org_A', currency: 'USD' } as ExecutionContext; + +/** Enough of a GROUP BY for this fixture: the LIVE path's engine. */ +function evaluateAggregate(opts: { groupBy?: unknown; aggregations?: unknown }) { + const groupBy = (opts.groupBy ?? []) as Array; + const aggs = (opts.aggregations ?? []) as Array<{ field: string; method: string; alias: string }>; + const buckets = new Map; rows: Task[] }>(); + for (const r of ROWS) { + const key: Record = {}; + for (const g of groupBy) { + const f = typeof g === 'string' ? g : g.field; + key[f] = r[f]; + } + const id = JSON.stringify(Object.values(key)); + let b = buckets.get(id); + if (!b) { b = { key, rows: [] }; buckets.set(id, b); } + b.rows.push(r); + } + return [...buckets.values()].map(({ key, rows }) => { + const row: Record = { ...key }; + for (const a of aggs) { + row[a.alias] = a.method === 'sum' + ? rows.reduce((s, r) => s + Number(r[a.field] ?? 0), 0) + : rows.length; + } + return row; + }); +} + +/** Two services that differ ONLY in whether a pending seed draft exists. */ +function svc(preview: boolean) { + return new AnalyticsService({ + sourceFieldMeta, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object: string, options: Record) => evaluateAggregate(options), + ...(preview ? { draftRowsResolver: async () => ROWS as Record[] } : {}), + }); +} + +type Field = Awaited>['fields'][number]; +const byName = (fields: Field[]) => Object.fromEntries(fields.map((f) => [f.name, f])); + +async function bothPaths(dataset = DATASET, selection: Record = { + dimensions: ['status'], + measures: ['task_count', 'count', 'total_amount', 'open_share'], +}) { + const live = await svc(false).queryDataset(dataset, selection as never, CTX); + const preview = await svc(true).queryDataset(dataset, selection as never, CTX, { previewDrafts: true }); + return { live: byName(live.fields), preview: byName(preview.fields) }; +} + +describe('fields[].aggregate — a dataset answer states each measure column\'s aggregate', () => { + it('a labelled `count` measure states `count`, and an unlabelled one still carries builtinAggregate', async () => { + const { live, preview } = await bothPaths(); + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { + // The showcase column: the author's label, and now the aggregate beside it. + expect(fields.task_count?.label, path).toBe('Tasks'); + expect(fields.task_count?.aggregate, path).toBe('count'); + // `builtinAggregate` keeps its label-only meaning: absent under a label… + expect(fields.task_count?.builtinAggregate, path).toBeUndefined(); + // …and present, beside the new member, on the label-less default. + expect(fields.count?.builtinAggregate, path).toBe('count'); + expect(fields.count?.aggregate, path).toBe('count'); + expect(fields.count?.label, path).toBeUndefined(); + } + }); + + it('a `sum` over a currency field states `sum`', async () => { + const { live, preview } = await bothPaths(); + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { + expect(fields.total_amount?.aggregate, path).toBe('sum'); + // The column's other descriptors are untouched by the new member. + expect(fields.total_amount?.currency, path).toBe('USD'); + expect(fields.total_amount?.format, path).toBe('$0,0'); + expect(fields.total_amount?.type, path).toBe('number'); + } + }); + + it('the preview path states the same as the live path, column for column', async () => { + const { live, preview } = await bothPaths(); + expect(Object.keys(preview).sort()).toEqual(Object.keys(live).sort()); + for (const name of Object.keys(live)) { + expect(preview[name]?.aggregate, `column "${name}"`).toBe(live[name]?.aggregate); + } + }); + + it('is absent on a dimension column and on a derived measure, on both paths', async () => { + const { live, preview } = await bothPaths(); + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { + expect(fields.status, path).toBeDefined(); + expect(fields.status?.aggregate, path).toBeUndefined(); + expect(fields.open_share, path).toBeDefined(); + expect(fields.open_share?.aggregate, path).toBeUndefined(); + } + }); + + it('a derived measure that also declares a stray `aggregate` states none: the compiler ignores it', async () => { + const stray = DatasetSchema.parse({ + ...DATASET, + name: 'task_ds_stray', + measures: [ + { name: 'task_count', aggregate: 'count', label: 'Tasks' }, + { name: 'count', aggregate: 'count' }, + { name: 'open_share', derived: { op: 'ratio', of: ['task_count', 'count'] }, aggregate: 'sum', label: 'Share' }, + ], + }); + const { live, preview } = await bothPaths(stray, { dimensions: ['status'], measures: ['task_count', 'count', 'open_share'] }); + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { + expect(fields.open_share, path).toBeDefined(); + expect(fields.open_share?.aggregate, path).toBeUndefined(); + // Control: the base measures beside it are still described. + expect(fields.task_count?.aggregate, path).toBe('count'); + } + }); + + it('a `__compare` column states its measure\'s aggregate, on both paths', async () => { + const selection = { + dimensions: ['status'], + measures: ['task_count', 'total_amount'], + timeDimensions: [{ dimension: 'due_on', dateRange: ['2026-02-01', '2026-02-28'] }], + compareTo: { kind: 'previousPeriod', dimension: 'due_on' }, + }; + const { live, preview } = await bothPaths(DATASET, selection); + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { + expect(fields.task_count__compare, path).toBeDefined(); + expect(fields.task_count__compare?.aggregate, path).toBe('count'); + expect(fields.total_amount__compare?.aggregate, path).toBe('sum'); + } + }); +}); + +describe('fields[].aggregate — absent on a cube query answer', () => { + it('`query()` (POST /analytics/query) never passes through the dataset column seam, so it states none', async () => { + const service = new AnalyticsService({ + cubes: [{ + name: 'task_cube', + title: 'Tasks', + sql: 'task', + measures: { task_count: { label: 'Tasks', type: 'count', sql: '*' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, + } as never], + sourceFieldMeta, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object: string, options: Record) => evaluateAggregate(options), + }); + const result = await service.query( + { cube: 'task_cube', measures: ['task_count'], dimensions: ['status'] }, + CTX, + ); + const measure = result.fields.find((f) => f.name === 'task_count'); + expect(measure).toBeDefined(); + expect(measure?.aggregate).toBeUndefined(); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts b/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts index c8253cec3c8..e66dd468e7f 100644 --- a/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts +++ b/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts @@ -48,6 +48,7 @@ * | `label` | `measure.label` / `dimension.label` + `ctx.locale` (#6761) | * | `format` | `measure.format` | * | `builtinAggregate` | `measure.aggregate` + `measure.label == null` (#14492) | + * | `aggregate` | `measure.aggregate`, labelled or not; never on a `derived` measure | * | `currency` | `measure.currency` → the source field's FIXED currency (`sourceFieldMeta().defaultCurrency`, relayed only under `currencyMode: 'fixed'`) → `ctx.currency` | * | `percentScale` | `measure.derived.op === 'ratio'`, else `percentScaleOf(sourceFieldMeta())` (objectui#3136) | * | `type` | `measureResultType(measure.aggregate, sourceFieldMeta().type)` (#16101) | @@ -238,10 +239,10 @@ async function bothPaths() { return { live: by(live.fields), preview: by(preview.fields) }; } -/** The six keys the card tabulates, absent ones dropped so they read as absent. */ +/** The keys the table above lists, absent ones dropped so they read as absent. */ function descriptor(f: Field | undefined): Record { const out: Record = {}; - for (const k of ['label', 'format', 'currency', 'percentScale', 'builtinAggregate', 'type'] as const) { + for (const k of ['label', 'format', 'currency', 'percentScale', 'builtinAggregate', 'aggregate', 'type'] as const) { const v = (f as Record | undefined)?.[k]; if (v != null) out[k] = v; } diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 190f6cb1352..4388292f931 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -2492,8 +2492,8 @@ export class AnalyticsService implements IAnalyticsService { const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context); // ADR-0021 result-column enrichment runs on this path too. Every key it // writes describes the dataset's OWN authored columns — a measure's - // `label` / `format` / `currency` / `percentScale` / `builtinAggregate` - // and the `type` its aggregate really returns, plus a dimension column's + // `label` / `format` / `currency` / `percentScale` / `builtinAggregate` / + // `aggregate` and the `type` its aggregate really returns, plus a dimension column's // header `label` — all read off the dataset definition and // `sourceFieldMeta`, never off the rows. #16097: this early `return` // used to sit ~250 lines ahead of that block, so the same dataset in the @@ -2777,8 +2777,8 @@ export class AnalyticsService implements IAnalyticsService { /** * ADR-0021 — describe the result's COLUMNS from the dataset's own authored * definition: a measure's `label` / `format` / `currency` / `percentScale` / - * `builtinAggregate` and the `type` its aggregate really returns, then a - * dimension column's header `label`. + * `builtinAggregate` / `aggregate` and the `type` its aggregate really + * returns, then a dimension column's header `label`. * * **Every key here is read off the DATASET** (the authored measure or * dimension) **and `sourceFieldMeta`** (the source object's declared field @@ -2864,6 +2864,16 @@ export class AnalyticsService implements IAnalyticsService { // #14492: it would catch an author who really named a field `Count`, // and break the moment the default is spelled in another language. if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate; + // The aggregate itself, stated whatever the header says. The + // discriminator above answers only "is this header the server's + // default?", so a LABELLED `count` ("Tasks") reached the wire as a bare + // `type: 'number'` and a chart could not tell it from a `sum`: it drew + // 0.75 / 1.5 / 2.25 ticks on a count axis. Read off the authored + // measure like every other key here, so the live and preview paths + // agree by construction. ⛔ Not on a derived measure: the compiler + // ignores a `derived` measure's stray `aggregate` and computes it from + // its `of` measures, so that `aggregate` describes nothing on the wire. + if (f.aggregate == null && !m.derived && m.aggregate) f.aggregate = m.aggregate; if (f.format == null && m.format) f.format = m.format; // Currency chain. A MONETARY measure resolves its display currency // from: explicit measure `currency` → the source field's FIXED diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index 7bd4b917a44..7b1e18a2c43 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -230,6 +230,39 @@ describe('AnalyticsResultResponseSchema', () => { expect(bad.success ? [] : bad.error.issues.map((i) => i.path.join('.'))).toContain('data.fields.0.builtinAggregate'); }); + // `fields[].aggregate` — the measure's aggregate, stated whether or not the + // author labelled the column, so it rides beside a `label` where + // `builtinAggregate` never does. Same closed `AggregationFunction` + // vocabulary: a spelling outside it is refused at the member, not stripped. + it('should preserve fields[].aggregate beside a label and refuse a spelling outside the closed enum', () => { + const resp = AnalyticsResultResponseSchema.parse({ + success: true, + data: { + rows: [{ status: 'open', task_count: 7, count: 7 }], + fields: [ + { name: 'status', type: 'string', label: 'Status' }, + { name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'count' }, + { name: 'count', type: 'number', builtinAggregate: 'count', aggregate: 'count' }, + ], + }, + }); + expect(resp.data.fields[1].aggregate).toBe('count'); + expect(resp.data.fields[1].label).toBe('Tasks'); + expect(resp.data.fields[1].builtinAggregate).toBeUndefined(); + expect(resp.data.fields[2].aggregate).toBe('count'); + expect(resp.data.fields[2].builtinAggregate).toBe('count'); + expect(resp.data.fields[0].aggregate).toBeUndefined(); + + const bad = AnalyticsResultResponseSchema.safeParse({ + success: true, + data: { rows: [], fields: [{ name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'total' }] }, + }); + expect(bad.success).toBe(false); + const issues = bad.success ? [] : bad.error.issues; + expect(issues.map((i) => i.path.join('.'))).toContain('data.fields.0.aggregate'); + expect(issues.find((i) => i.path.join('.') === 'data.fields.0.aggregate')?.code).toBe('invalid_value'); + }); + it('should preserve totals — the marginal-aggregate channel, grand total included', () => { const resp = AnalyticsResultResponseSchema.parse({ success: true, diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index 637b411ef84..e152cbf583c 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -129,6 +129,17 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema + 'name for the aggregate. Absent whenever the author declared a label, and ' + 'on dimension / derived columns.', ), + aggregate: AggregationFunction.optional().describe( + 'The aggregate a measure column carries, in the closed `AggregationFunction` ' + + 'vocabulary: the dataset measure\'s own `aggregate`, stated whether or not the ' + + 'author declared a `label`, so a renderer can tell a `count` from a `sum` (integer ' + + 'axis ticks for a count, for example). Set on every measure column of a dataset ' + + 'answer (`POST /analytics/dataset/query`, the live query and the draft-data ' + + 'preview alike) whose measure declares an `aggregate`, its `__compare` column ' + + 'included. Absent on dimension columns and on derived measures, which have no ' + + 'single aggregate, and on a cube query answer (`POST /analytics/query`). Unlike ' + + '`builtinAggregate`, it says nothing about who named the column.', + ), })).describe('Column metadata'), sql: z.string().optional().describe('Executed SQL (if debug enabled)'), totals: z.array(z.object({ diff --git a/packages/spec/src/contracts/analytics-service.test.ts b/packages/spec/src/contracts/analytics-service.test.ts index 4abc11d74a6..31154e6d0a8 100644 --- a/packages/spec/src/contracts/analytics-service.test.ts +++ b/packages/spec/src/contracts/analytics-service.test.ts @@ -88,6 +88,23 @@ describe('Analytics Service Contract', () => { expect(offEnum.name).toBe('count'); }); + // `fields[].aggregate` is optional and is the closed `AggregationFunction` + // vocabulary. Unlike `builtinAggregate` it rides beside an authored label: + // the first literal is the labelled `count` column the producer now emits; + // the second pins the closure at compile time. + it('carries aggregate on a labelled measure column and refuses a spelling outside the enum', () => { + const labelled: AnalyticsResult['fields'][number] = { name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'count' }; + expect(labelled.aggregate).toBe('count'); + expect(labelled.builtinAggregate).toBeUndefined(); + const offEnum: AnalyticsResult['fields'][number] = { + name: 'task_count', + type: 'number', + // @ts-expect-error — `total` is not an AggregationFunction; the member is closed + aggregate: 'total', + }; + expect(offEnum.name).toBe('task_count'); + }); + // `object` — the dataset's base object, declared on the answer itself and not // on a drill-through side type: a `queryDataset` implementation returns it on // a dimension-less, zero-row answer against the plain `AnalyticsResult`, and diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index ee792541cce..0d0d89ac5ae 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -116,6 +116,26 @@ export interface AnalyticsResult { * aggregate vocabulary; no second spelling. */ builtinAggregate?: AggregationFunction; + /** + * The aggregate the measure column carries — the dataset measure's own + * `aggregate`, stated whether or not the author declared a `label`. + * `builtinAggregate` answers "is this header the server's default?"; + * this answers "what kind of number is this?", which a renderer needs + * whatever the header says: a `count` is a whole number, so a chart + * axis over it takes integer ticks, while a `sum` or `avg` may be + * fractional. + * + * Present on every measure column of a `queryDataset` answer — the live + * query and the draft-data preview, which share one column-description + * seam — whose dataset measure declares an `aggregate`, the measure's + * `__compare` column included (the same aggregate over the shifted + * window). Absent on dimension columns and on derived measures, which + * combine other measures and have no single aggregate (a `derived` + * measure's stray `aggregate` is ignored at compile time and is not + * stated here either), and absent on a `query` (cube) answer. Reuses + * `AggregationFunction`, the one closed aggregate vocabulary. + */ + aggregate?: AggregationFunction; }>; /** Generated SQL (if available) */ sql?: string;