Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/21995-measure-column-aggregate.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
<Callout type="info">
**`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,
Expand All @@ -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.
</Callout>

### `GET /analytics/meta`
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string | { field: string }>;
const aggs = (opts.aggregations ?? []) as Array<{ field: string; method: string; alias: string }>;
const buckets = new Map<string, { key: Record<string, unknown>; rows: Task[] }>();
for (const r of ROWS) {
const key: Record<string, unknown> = {};
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<string, unknown> = { ...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<string, unknown>) => evaluateAggregate(options),
...(preview ? { draftRowsResolver: async () => ROWS as Record<string, unknown>[] } : {}),
});
}

type Field = Awaited<ReturnType<AnalyticsService['queryDataset']>>['fields'][number];
const byName = (fields: Field[]) => Object.fromEntries(fields.map((f) => [f.name, f]));

async function bothPaths(dataset = DATASET, selection: Record<string, unknown> = {
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<string, unknown>) => 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown> | undefined)?.[k];
if (v != null) out[k] = v;
}
Expand Down
18 changes: 14 additions & 4 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading