diff --git a/.changeset/25330-null-label-carry.md b/.changeset/25330-null-label-carry.md new file mode 100644 index 00000000000..3b9d8cf24a8 --- /dev/null +++ b/.changeset/25330-null-label-carry.md @@ -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". diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 0b3cf2f6dc1..264264ad68f 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -474,7 +474,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`, `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, @@ -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. ### `GET /analytics/meta` diff --git a/packages/platform-objects/src/apps/dashboards/system-overview-null-label.test.ts b/packages/platform-objects/src/apps/dashboards/system-overview-null-label.test.ts new file mode 100644 index 00000000000..c207d3d336c --- /dev/null +++ b/packages/platform-objects/src/apps/dashboards/system-overview-null-label.test.ts @@ -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; + 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); + }); +}); diff --git a/packages/platform-objects/src/apps/dashboards/system.datasets.ts b/packages/platform-objects/src/apps/dashboards/system.datasets.ts index a5b464a348d..eef0bf4ef63 100644 --- a/packages/platform-objects/src/apps/dashboards/system.datasets.ts +++ b/packages/platform-objects/src/apps/dashboards/system.datasets.ts @@ -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' }], }); diff --git a/packages/services/service-analytics/src/__tests__/dataset-dimension-null-label.test.ts b/packages/services/service-analytics/src/__tests__/dataset-dimension-null-label.test.ts new file mode 100644 index 00000000000..947176ad9e8 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/dataset-dimension-null-label.test.ts @@ -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) => { + const groupBy = (options.groupBy ?? []) as Array; + const aggregations = (options.aggregations ?? []) as Array<{ alias: string }>; + const row: Record = {}; + 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 = {}; + 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)'); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 3702287fce4..a1493498a56 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -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; + } } } } diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index 7b1e18a2c43..7fd492fe06e 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -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. diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index e152cbf583c..956f40db77a 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -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({ diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index 0d0d89ac5ae..a86b2f7df33 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -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;