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
16 changes: 16 additions & 0 deletions .changeset/25330-null-label-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': patch
'@objectstack/service-analytics': patch
'@objectstack/platform-objects': patch
---

feat(analytics): a dataset answer carries each dimension column's null-bucket name, and System Overview names its null user bucket "System"

Clause-②: no
Metadata-protocol: none

A dataset dimension's `nullLabel` now reaches the renderer. The chart reads every column's display metadata from the dataset answer's `fields[]` and never fetches the dataset document, so the name rides that descriptor:

- **`@objectstack/spec`:** `AnalyticsResult.fields[]` and its mirror `AnalyticsResultResponseSchema` gain the optional response member `nullLabel?: string`, the display name of a dimension column's null bucket. It is a response member only; nothing an author writes changes.
- **`@objectstack/service-analytics`:** the column-description pass shared by the live query and the draft-data preview sets `fields[].nullLabel` on a dimension column whose dataset dimension declares `nullLabel`, resolved to a string at the request locale by the same resolver as `label`. A dimension without one, and every measure column, carry no `nullLabel` key, so those answers are byte-identical to before.
- **`@objectstack/platform-objects`:** the `sys_audit_log_metrics` dataset's `user_id` dimension declares `nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' }`. The rows the platform writes itself carry no user, and System Overview's "Events by User" drew them, its largest bar, under the renderer's default "(None)". Once the console reads `fields[].nullLabel`, that bar reads "System".
8 changes: 7 additions & 1 deletion content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,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`, `builtinAggregate` and `aggregate`: the optional members
`percentScale`, `builtinAggregate`, `aggregate` and `nullLabel`: 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 Down Expand Up @@ -505,6 +505,12 @@ chart can tell a count from a sum. `builtinAggregate` is narrower: it is present
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.

A dimension column of a dataset answer carries `nullLabel` when its dataset dimension
declares one: the name of the bucket of rows whose value for that dimension is null,
resolved to the request's locale like `label`. A chart draws its null bucket under it
instead of its own default. It is never set on a measure column, nor on the cube query
on this page.
</Callout>

### `GET /analytics/meta`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// System Overview "Events by User": the rows the platform writes itself carry
// no `user_id`, and they were the board's largest bar, drawn under the
// renderer's default "(None)". The dataset dimension names that bucket with
// `nullLabel`, which the analytics service carries to the chart on the column
// descriptor (`fields[].nullLabel`).
//
// The label is an inline locale map because the translation bundles have no
// slot for a dimension's null label, so the map must name every locale the
// board is translated into; a missing one would show that viewer the English.

import { describe, it, expect } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import { SysAuditLogDataset } from './system.datasets.js';
import { SystemOverviewDashboard } from './system_overview.dashboard.js';
import { SetupAppTranslations } from '../translations/index.js';

const userDimension = () => SysAuditLogDataset.dimensions?.find((d) => d.name === 'user_id');

describe('System Overview: the null-user bucket reads "System"', () => {
it('"Events by User" groups the audit dataset by user_id', () => {
const widget = SystemOverviewDashboard.widgets?.find((w) => w.id === 'widget_events_by_user');
expect(widget).toMatchObject({ dataset: SysAuditLogDataset.name, dimensions: ['user_id'] });
});

it('declares the user_id null label as an inline locale map', () => {
expect(userDimension()?.nullLabel).toEqual({
en: 'System',
'zh-CN': '系统',
'ja-JP': 'システム',
'es-ES': 'Sistema',
});
});

it('names the bucket in every locale the platform bundles translate the board into', () => {
const map = userDimension()?.nullLabel as Record<string, string>;
for (const locale of Object.keys(SetupAppTranslations)) {
expect(SetupAppTranslations[locale]?.dashboards?.system_overview, locale).toBeDefined();
expect(typeof map[locale], locale).toBe('string');
}
});

it('survives a strict parse of the dataset', () => {
const parsed = DatasetSchema.parse(SysAuditLogDataset);
expect(parsed.dimensions?.find((d) => d.name === 'user_id')?.nullLabel).toEqual(userDimension()?.nullLabel);
});
});
11 changes: 10 additions & 1 deletion packages/platform-objects/src/apps/dashboards/system.datasets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ export const SysAuditLogDataset = defineDataset({
object: 'sys_audit_log',
dimensions: [
{ name: 'action', label: 'Action', field: 'action', type: 'string' },
{ name: 'user_id', label: 'User', field: 'user_id', type: 'lookup' },
// Rows the platform writes itself carry no `user_id`; their bucket reads
// "System", not the renderer's default "(None)". An inline locale map:
// the translation bundles have no slot for a dimension's null label.
{
name: 'user_id',
label: 'User',
field: 'user_id',
type: 'lookup',
nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' },
},
],
measures: [{ name: 'event_count', label: 'Events', aggregate: 'count' }],
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `DatasetDimension.nullLabel` reaches the renderer on the dimension column's
* descriptor, as `fields[].nullLabel`.
*
* The chart renderer (objectui `DatasetWidget`) reads a dataset answer's
* `fields[]` for every column's display metadata and never fetches the dataset
* document, so the null bucket's authored name has to ride that descriptor. It
* is resolved to a string at the request locale by the same `resolveI18nLabel`
* the column's `label` uses, so a viewer reads the bucket in their own
* language and the wire never carries a raw locale map.
*
* The other half is the control: a dimension that declares no `nullLabel`, and
* every measure column, carry no `nullLabel` key at all, so an answer over a
* dataset that never declares one is byte-identical to before.
*/

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';

const dataset = DatasetSchema.parse({
name: 'audit_metrics',
label: 'Audit Metrics',
object: 'sys_audit_log',
include: [],
dimensions: [
{ name: 'action', label: 'Action', field: 'action', type: 'string' },
{
name: 'user_id',
label: 'User',
field: 'user_id',
type: 'string',
nullLabel: { en: 'System', 'zh-CN': '系统' },
},
{ name: 'source', field: 'source', type: 'string', nullLabel: 'Unknown' },
],
// `user_id` is also a measure's field: the measure column must not inherit
// the dimension's null label through the descriptor pass's field match.
measures: [
{ name: 'event_count', label: 'Events', aggregate: 'count' },
{ name: 'user_id_count', aggregate: 'count_distinct', field: 'user_id' },
],
});

const DIMENSIONS = ['action', 'user_id', 'source'];
const MEASURES = ['event_count', 'user_id_count'];

function sqlService() {
return new AnalyticsService({
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
executeRawSql: async () => [
{ action: 'create', user_id: null, source: null, event_count: 740, user_id_count: 0 },
{ action: 'login', user_id: 'usr_1', source: 'web', event_count: 3, user_id_count: 1 },
],
});
}

function aggregateService() {
return new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: async (_object: string, options: Record<string, unknown>) => {
const groupBy = (options.groupBy ?? []) as Array<string | { field: string }>;
const aggregations = (options.aggregations ?? []) as Array<{ alias: string }>;
const row: Record<string, unknown> = {};
for (const g of groupBy) row[typeof g === 'string' ? g : g.field] = null;
for (const a of aggregations) row[a.alias] = 1;
return [row];
},
});
}

const STRATEGIES: [string, () => AnalyticsService][] = [
['NativeSQLStrategy', sqlService],
['ObjectQLStrategy', aggregateService],
];

async function nullLabels(svc: AnalyticsService, context?: ExecutionContext) {
const result = await svc.queryDataset(dataset, { dimensions: DIMENSIONS, measures: MEASURES }, context);
const out: Record<string, unknown> = {};
for (const f of result.fields) {
if (f.nullLabel !== undefined) expect(typeof f.nullLabel).toBe('string');
out[f.name] = 'nullLabel' in f ? f.nullLabel : '(no nullLabel key)';
}
return out;
}

describe.each(STRATEGIES)('dataset dimension nullLabel on the wire (%s)', (_name, service) => {
it('carries the declared null label, resolved to the request locale', async () => {
const zh = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
const en = await nullLabels(service(), { tenantId: 'org_A', locale: 'en-US' } as ExecutionContext);
expect(zh.user_id).toBe('系统');
expect(en.user_id).toBe('System');
});

it('carries a plain-string null label as authored, whatever the locale', async () => {
const zh = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
expect(zh.source).toBe('Unknown');
});

it('falls back to the platform source language when the request states no locale', async () => {
const noContext = await nullLabels(service());
expect(noContext.user_id).toBe('System');
});

it('writes no nullLabel key on a dimension that declares none, nor on any measure column', async () => {
const cols = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
expect(cols.action).toBe('(no nullLabel key)');
expect(cols.event_count).toBe('(no nullLabel key)');
expect(cols.user_id_count).toBe('(no nullLabel key)');
});
});
17 changes: 14 additions & 3 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3157,16 +3157,27 @@ export class AnalyticsService implements IAnalyticsService {
if (result.fields?.length && describableDims.length) {
const dimByName = new Map(describableDims.map((d) => [d.name, d]));
const dimByField = new Map(describableDims.filter((d) => !!d.field).map((d) => [d.field as string, d]));
const measureNames = new Set((dataset.measures ?? []).map((m) => m.name));
for (const f of result.fields) {
if (f.label != null) continue;
// Result fields may be keyed by the dataset dimension NAME or the
// underlying cube FIELD depending on strategy — match either.
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
if (!d) continue;
// [#6761] Same resolver, same locale, same "write nothing on a miss"
// rule as the measure enrichment above.
const label = resolveI18nLabel(d.label, requestLocale);
if (label !== undefined) f.label = label;
if (f.label == null) {
const label = resolveI18nLabel(d.label, requestLocale);
if (label !== undefined) f.label = label;
}
// The null bucket's display name (`DatasetDimension.nullLabel`), read
// whether or not the header was already set, and never onto a measure
// column whose name happens to match a dimension's field. Written only
// when declared and resolvable, so an answer over a dimension without
// one is byte-identical to before.
if (f.nullLabel == null && !measureNames.has(f.name.replace(/__compare$/, ''))) {
const nullLabel = resolveI18nLabel(d.nullLabel, requestLocale);
if (nullLabel !== undefined) f.nullLabel = nullLabel;
}
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/src/api/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,23 @@ describe('AnalyticsResultResponseSchema', () => {
expect(resp.data.fields[2].format).toBe('0.0%');
});

// A dimension column's null-bucket name rides the same channel. The parse
// strips undeclared keys, so an undeclared `nullLabel` would vanish here.
it('should preserve fields[].nullLabel on a dimension column', () => {
const resp = AnalyticsResultResponseSchema.parse({
success: true,
data: {
rows: [{ user_id: null, event_count: 740 }],
fields: [
{ name: 'user_id', type: 'string', label: 'User', nullLabel: 'System' },
{ name: 'event_count', type: 'number', label: 'Events' },
],
},
});
expect(resp.data.fields[0].nullLabel).toBe('System');
expect('nullLabel' in resp.data.fields[1]).toBe(false);
});

// #14492 — the built-in aggregate discriminator rides the same channel, and
// it is the CLOSED `AggregationFunction` vocabulary: a spelling outside it is
// refused at the member, not stripped or passed through.
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/src/api/analytics.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema
+ 'single aggregate, and on a cube query answer (`POST /analytics/query`). Unlike '
+ '`builtinAggregate`, it says nothing about who named the column.',
),
nullLabel: z.string().optional().describe(
'Display name of a dimension column\'s null bucket: the dataset dimension\'s `nullLabel`, '
+ 'resolved at the request locale like `label`. A renderer draws the column\'s null values '
+ 'under it instead of its own default. Set only on a dimension column of a dataset answer '
+ '(`POST /analytics/dataset/query`) whose dimension declares one; absent on measure columns '
+ 'and on a cube query answer. Display copy only: grouping is unchanged.',
),
})).describe('Column metadata'),
sql: z.string().optional().describe('Executed SQL (if debug enabled)'),
totals: z.array(z.object({
Expand Down
13 changes: 13 additions & 0 deletions packages/spec/src/contracts/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ export interface AnalyticsResult {
* `AggregationFunction`, the one closed aggregate vocabulary.
*/
aggregate?: AggregationFunction;
/**
* The display name of a DIMENSION column's null bucket: the dataset
* dimension's `nullLabel` (`DatasetDimensionSchema`), resolved to a
* string at the request locale by the same `I18nLabel` resolver as
* `label`. A renderer that buckets the column's null values draws
* them under this name instead of its own default (`'(None)'`).
*
* Present only on a dimension column of a `queryDataset` answer whose
* dataset dimension declares a `nullLabel` that resolves; absent
* otherwise, on every measure column, and on a `query` (cube) answer.
* Display copy only: it never changes which rows fall in the bucket.
*/
nullLabel?: string;
}>;
/** Generated SQL (if available) */
sql?: string;
Expand Down
Loading