From 4636bcb2bceccf39593ba8dd1fb9413d3e50bfb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 04:44:33 +0000 Subject: [PATCH] fix(service-analytics): a draft-preview dataset response describes its columns like the live one (#16097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `queryDataset`'s ADR-0037 P3 preview branch returned ~250 lines before the ADR-0021 result-column enrichment, so a response over drafted seed rows carried no `label`, `format`, `currency`, `percentScale`, `builtinAggregate` and no `type` correction — on measure and dimension columns alike. The same dataset in the same widget described its columns differently depending only on whether a pending seed draft existed. Every key that block writes is read off the authored dataset and `sourceFieldMeta`, never off `result.rows`, so it is extracted into one `enrichResultColumns` seam that both paths call — one rule, not a per-path copy free to drift, the same argument #15768/#16101's `type` correction already makes for living there. Dimension VALUE label resolution stays skipped on the preview path on purpose; the standing comment is narrowed to say that it is a statement about row values and never covered the column descriptors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .changeset/preview-column-enrichment.md | 19 + .../preview-column-enrichment.test.ts | 375 ++++++++++++++++++ .../src/analytics-service.ts | 121 ++++-- 3 files changed, 487 insertions(+), 28 deletions(-) create mode 100644 .changeset/preview-column-enrichment.md create mode 100644 packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts diff --git a/.changeset/preview-column-enrichment.md b/.changeset/preview-column-enrichment.md new file mode 100644 index 0000000000..04389e6b07 --- /dev/null +++ b/.changeset/preview-column-enrichment.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-analytics': patch +--- + +Analytics: a draft-preview dataset response now describes its columns like the live one + +`AnalyticsService.queryDataset`'s ADR-0037 P3 draft-preview branch returned before the +ADR-0021 result-column enrichment ever ran, so a dataset queried while the base object had a +pending seed draft came back with none of its column metadata: `fields[].label`, `format`, +`currency`, `percentScale`, `builtinAggregate`, and the temporal `type` correction were all +absent, on measure and dimension columns alike. A renderer then fell back to humanizing the +raw measure name and guessing a percent scale from magnitude — so the same dataset in the +same widget described its columns differently depending only on whether a pending seed draft +existed, which is the surface an author is looking at while authoring the dataset. + +Every one of those keys is read off the authored dataset and the source object's field +metadata, never off the rows, so the enrichment is now one method both paths call. Dimension +VALUE label resolution (resolving a lookup id to a display name) stays skipped on the preview +path deliberately: drafted seed rows reference lookups by name, so there is no id to resolve. 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 new file mode 100644 index 0000000000..f1b8eea3aa --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts @@ -0,0 +1,375 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16097 — a draft-preview dataset response must describe its COLUMNS the same + * way the live response does. + * + * `AnalyticsService.queryDataset` has an ADR-0037 P3 branch: when the request + * renders the as-if-published world and the base object has a PENDING seed + * draft, the selection is evaluated over the seed rows in memory and returned + * immediately. Measured on `main` @ `7d7ca6c0c` (after #16101 landed in this + * same file and moved both line numbers the card quoted), that early `return` + * sat at `analytics-service.ts:1115` and the ADR-0021 result-column enrichment + * began at `:1367` — ~250 lines it could never reach. + * + * ## What that cost, driven rather than read + * + * One dataset, one row set, two services differing ONLY in whether a pending + * seed exists — so the only thing that can differ between the two responses is + * the enrichment. Before this change: + * + * ``` + * live {"name":"total_amount","type":"number","label":"Total Amount","format":"$0,0","currency":"EUR"} + * preview {"name":"total_amount","type":"number"} + * live {"name":"avg_margin","type":"number","label":"Avg Margin","format":"0.0%","percentScale":"whole"} + * preview {"name":"avg_margin","type":"number"} + * live {"name":"expense_count","type":"number","builtinAggregate":"count"} + * preview {"name":"expense_count","type":"number"} + * live {"name":"latest_spend","type":"time","label":"Latest Spend"} + * preview {"name":"latest_spend","type":"number"} + * live {"name":"category","type":"string","label":"Category"} + * preview {"name":"category","type":"string"} + * ``` + * + * A renderer then falls back to humanizing the raw measure name and guessing a + * percent scale from magnitude — the failures #5537, objectui#3136 and #14492 + * each closed on the live path — and it does so only because a pending seed + * draft exists, a state that has nothing to do with how a column is described. + * + * ## Per-key: why each one is answerable over a seed row set + * + * Every key asserted here is read off the DATASET (the authored measure or + * dimension) and `sourceFieldMeta` (the source object's declared field + * metadata). None is read off `rows`, which is the whole reason one seam can + * serve both paths: + * + * | key | resolved from | + * |:-------------------|:------------------------------------------------| + * | `label` | `measure.label` / `dimension.label` + `ctx.locale` (#6761) | + * | `format` | `measure.format` | + * | `builtinAggregate` | `measure.aggregate` + `measure.label == null` (#14492) | + * | `currency` | ADR-0053 chain: `measure.currency` → `sourceFieldMeta().defaultCurrency` → `ctx.currency` | + * | `percentScale` | `measure.derived.op === 'ratio'`, else `percentScaleOf(sourceFieldMeta())` (objectui#3136) | + * | `type` | `measureResultType(measure.aggregate, sourceFieldMeta().type)` (#16101) | + * + * The two chains that reach OUTSIDE the measure are pinned deliberately: the + * currency chain's `ExecutionContext` limb and its `sourceFieldMeta` limb both + * have a case below, because "the block is self-contained" was an assumption + * worth measuring rather than believing. + * + * ## ⛔ What stays skipped, and why that is not the same question + * + * Dimension VALUE label resolution — turning a stored lookup id into the + * related record's display name — remains skipped on the preview path, and the + * last describe block pins it: `fetchRecordLabels` is not called at all there. + * Drafted seed rows reference lookups by NAME (the seed convention), so there is + * no id to resolve and the value already reads well. That reasoning is about ROW + * VALUES; it never covered COLUMN descriptors, which come from the authored + * measure and which no property of the seed rows can supply. + * + * ## Known-divergent and deliberately NOT asserted + * + * Two preview/live differences survive this change because they are produced + * BEFORE any enrichment, by `evaluateAnalyticsQueryOverRows` (`preview- + * evaluator.ts`), and no descriptor pass can reach them. They are filed + * separately rather than pinned here, so that fixing them does not have to red + * this file: + * + * - a time dimension's `fields[].type` is minted `'string'` on preview and + * `'time'` on live (`evaluateAnalyticsQueryOverRows` mints every dimension + * as `'string'`); + * - `min`/`max` over a non-numeric field returns `0` on preview + * (`aggregate()` coerces with `Number()` and drops non-finite values), so + * `latest_spend` is `0` there and `'2026-05-12'` on live. + * + * The second one meets this card at exactly one point: `latest_spend` is now + * correctly described as `type: 'time'` on BOTH paths, which is what its + * authored `max` over a `date` field means, while the preview VALUE beside it + * stays wrong until the evaluator is fixed. The descriptor is not withheld to + * accommodate a producer defect — that would be a consumer-side `??` in + * descriptor form (Prime Directive #12), and it would make the response's + * account of the column depend on a bug. + * + * ## Reverse verification, direction predicted BEFORE running + * + * Deleting the `enrichResultColumns(previewResult, …)` call from the preview + * branch must turn RED every `preview` assertion below and leave every `live` + * assertion GREEN. Ordinary direction, no inversion: the change ADDS descriptor + * keys that were absent, narrows no rule and removes no limb, so nothing + * downstream can gain a finding from it. The dimension-value-label block is + * predicted GREEN under that mutation too — it pins what the preview path does + * NOT do, which the mutation does not change. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; +import type { DimensionLabelDeps, FieldMetaLite } from '../dimension-labels.js'; + +// ── one fixture, two paths ────────────────────────────────────────────────── + +interface Expense extends Record { + amount: number; + fee: number; + margin: number; + category: string; + vendor: string; + spent_on: string; +} + +/** + * `vendor` is a LOOKUP carrying the SEED convention: the drafted row references + * the related record by NAME (`Acme Air`), not by the FK id the live table + * stores. That is the fixture the standing skip is about. + */ +const ROWS: Expense[] = [ + { amount: 1200, fee: 30, margin: 40, category: 'travel', vendor: 'Acme Air', spent_on: '2026-05-03' }, + { amount: 800, fee: 20, margin: 20, category: 'travel', vendor: 'Acme Air', spent_on: '2026-05-12' }, + { amount: 60, fee: 5, margin: 10, category: 'meals', vendor: 'Bistro Ltd', spent_on: '2026-06-01' }, +]; + +const DATASET = DatasetSchema.parse({ + name: 'expense_ds', + label: 'Expense', + object: 'expense', + dimensions: [ + { name: 'category', field: 'category', type: 'string', label: 'Category' }, + ], + measures: [ + // aggregate + NO label ⇒ `builtinAggregate`, and no invented header. + { name: 'expense_count', aggregate: 'count' }, + // label + format + the currency chain's `sourceFieldMeta` limb. + { name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount', format: '$0,0' }, + // the currency chain's ExecutionContext limb (no measure currency, no + // field default) — the limb that lives furthest from the measure. + { name: 'total_fee', aggregate: 'sum', field: 'fee', label: 'Total Fee' }, + // percentScale from the source field (`percent`, max 100 ⇒ 'whole'). + { name: 'avg_margin', aggregate: 'avg', field: 'margin', label: 'Avg Margin', format: '0.0%' }, + // percentScale from the measure shape (`ratio` ⇒ 'fraction'). + { name: 'amount_per_item', derived: { op: 'ratio', of: ['total_amount', 'expense_count'] }, label: 'Per Item', format: '0.0%' }, + // #16101 — `max` over a `date` field is temporal, not a number. + { name: 'latest_spend', aggregate: 'max', field: 'spent_on', label: 'Latest Spend' }, + ], +}); + +/** The `vendor` variant, used only by the dimension-value-label block. */ +const VENDOR_DATASET = DatasetSchema.parse({ + name: 'expense_by_vendor', + label: 'Expense by Vendor', + object: 'expense', + dimensions: [{ name: 'vendor', field: 'vendor', type: 'lookup', label: 'Vendor' }], + measures: [{ name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount' }], +}); + +const sourceFieldMeta = (object: string, field: string) => { + if (object !== 'expense') return undefined; + if (field === 'amount') return { type: 'currency', defaultCurrency: 'EUR' }; + if (field === 'fee') return { type: 'currency' }; + if (field === 'margin') return { type: 'percent', max: 100 }; + if (field === 'spent_on') return { type: 'date' }; + return undefined; +}; + +const FIELDS: Record> = { + expense: { vendor: { type: 'lookup', reference: 'crm_account' } }, + crm_account: { name: { type: 'text' } }, +}; + +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: Expense[] }>(); + 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) { + const vals = rows.map((r) => r[a.field]); + const sorted = vals.map(String).sort(); + row[a.alias] = + a.method === 'sum' ? vals.reduce((s: number, v) => s + Number(v ?? 0), 0) + : a.method === 'avg' ? vals.reduce((s: number, v) => s + Number(v ?? 0), 0) / rows.length + : a.method === 'max' ? sorted[sorted.length - 1] + : rows.length; + } + return row; + }); +} + +/** + * The two services differ in exactly one config key. Everything else — the + * dataset, the rows, the field metadata, the ExecutionContext — is shared, so a + * difference in the response is a difference the preview branch caused. + */ +function svc(opts: { preview: boolean; labels?: DimensionLabelDeps } = { preview: false }) { + return new AnalyticsService({ + sourceFieldMeta, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object: string, options: Record) => evaluateAggregate(options), + ...(opts.labels ? { labelResolver: opts.labels } : {}), + ...(opts.preview ? { draftRowsResolver: async () => ROWS as Record[] } : {}), + }); +} + +const SELECTION = { + dimensions: ['category'], + measures: ['expense_count', 'total_amount', 'total_fee', 'avg_margin', 'amount_per_item', 'latest_spend'], +}; + +type Field = Awaited>['fields'][number]; + +async function bothPaths() { + const live = await svc({ preview: false }).queryDataset(DATASET, SELECTION, CTX); + const preview = await svc({ preview: true }).queryDataset(DATASET, SELECTION, CTX, { previewDrafts: true }); + const by = (fields: Field[]) => Object.fromEntries(fields.map((f) => [f.name, f])); + return { live: by(live.fields), preview: by(preview.fields) }; +} + +/** The six keys the card tabulates, 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) { + const v = (f as Record | undefined)?.[k]; + if (v != null) out[k] = v; + } + return out; +} + +describe('#16097 — a preview response describes its measure columns like the live one', () => { + it('carries every measure descriptor the live path carries, key for key', async () => { + const { live, preview } = await bothPaths(); + for (const name of ['expense_count', 'total_amount', 'total_fee', 'avg_margin', 'amount_per_item', 'latest_spend']) { + expect(descriptor(preview[name]), `measure "${name}"`).toEqual(descriptor(live[name])); + } + }); + + it('label — the authored measure label, on both paths', async () => { + const { live, preview } = await bothPaths(); + expect(live.total_amount.label).toBe('Total Amount'); + expect(preview.total_amount.label).toBe('Total Amount'); + }); + + it('format — the authored measure format, on both paths', async () => { + const { live, preview } = await bothPaths(); + expect(live.total_amount.format).toBe('$0,0'); + expect(preview.total_amount.format).toBe('$0,0'); + }); + + it('builtinAggregate (#14492) — present exactly where the measure has no authored label', async () => { + const { live, preview } = await bothPaths(); + expect(live.expense_count.builtinAggregate).toBe('count'); + expect(preview.expense_count.builtinAggregate).toBe('count'); + // …and the enrichment still invents no header for it, on either path. + expect(live.expense_count.label).toBeUndefined(); + expect(preview.expense_count.label).toBeUndefined(); + // An authored label keeps its text and gets no discriminator. + expect(preview.total_amount.builtinAggregate).toBeUndefined(); + }); + + it('currency (ADR-0053) — the sourceFieldMeta limb resolves on the preview path', async () => { + const { live, preview } = await bothPaths(); + expect(live.total_amount.currency).toBe('EUR'); + expect(preview.total_amount.currency).toBe('EUR'); + }); + + it('currency (ADR-0053) — the ExecutionContext limb reaches the preview path too', async () => { + const { live, preview } = await bothPaths(); + // No measure currency, no field default ⇒ the tenant default on `ctx`. + expect(live.total_fee.currency).toBe('USD'); + expect(preview.total_fee.currency).toBe('USD'); + // A non-monetary measure never acquires one, on either path. + expect(preview.avg_margin.currency).toBeUndefined(); + expect(preview.expense_count.currency).toBeUndefined(); + }); + + it('percentScale (objectui#3136) — both limbs resolve on the preview path', async () => { + const { live, preview } = await bothPaths(); + expect(live.avg_margin.percentScale).toBe('whole'); // from the percent field's max + expect(preview.avg_margin.percentScale).toBe('whole'); + expect(live.amount_per_item.percentScale).toBe('fraction'); // from `derived.op === 'ratio'` + expect(preview.amount_per_item.percentScale).toBe('fraction'); + }); + + it('type (#16101) — a `max` over a date field is temporal on the preview path too', async () => { + const { live, preview } = await bothPaths(); + expect(live.latest_spend.type).toBe('time'); + expect(preview.latest_spend.type).toBe('time'); + // Every other measure keeps the `number` its producer minted. + expect(preview.total_amount.type).toBe('number'); + expect(preview.expense_count.type).toBe('number'); + }); + + it('resolves the label through the #6761 i18n map with the REQUEST locale', async () => { + const localized = DatasetSchema.parse({ + ...DATASET, + name: 'expense_localized', + measures: [{ name: 'total_amount', aggregate: 'sum', field: 'amount', label: { en: 'Total Amount', 'zh-CN': '总金额' } }], + }); + const selection = { dimensions: ['category'], measures: ['total_amount'] }; + const zh = { ...CTX, locale: 'zh-CN' } as ExecutionContext; + const preview = await svc({ preview: true }).queryDataset(localized, selection, zh, { previewDrafts: true }); + const live = await svc({ preview: false }).queryDataset(localized, selection, zh); + expect(live.fields.find((f) => f.name === 'total_amount')?.label).toBe('总金额'); + expect(preview.fields.find((f) => f.name === 'total_amount')?.label).toBe('总金额'); + }); +}); + +describe('#16097 — dimension COLUMN headers, same authored source, same answer', () => { + it('gives the grouped column its authored header on both paths', async () => { + const { live, preview } = await bothPaths(); + expect(live.category.label).toBe('Category'); + expect(preview.category.label).toBe('Category'); + }); +}); + +describe('⛔ the fence: dimension VALUE label resolution stays skipped on preview', () => { + it('resolves the lookup value on the live path and leaves the seed name alone on preview', async () => { + const fetchRecordLabels = vi.fn(async (_t: string, ids: unknown[]) => { + const m = new Map(); + for (const id of ids) m.set(id, `Resolved(${String(id)})`); + return m; + }); + const labels: DimensionLabelDeps = { getObjectFields: (o) => FIELDS[o], fetchRecordLabels }; + + const selection = { dimensions: ['vendor'], measures: ['total_amount'] }; + const live = await svc({ preview: false, labels }).queryDataset(VENDOR_DATASET, selection, CTX); + const liveCalls = fetchRecordLabels.mock.calls.length; + expect(liveCalls).toBeGreaterThan(0); + expect(live.rows.map((r) => r.vendor)).toContain('Resolved(Acme Air)'); + + fetchRecordLabels.mockClear(); + const preview = await svc({ preview: true, labels }) + .queryDataset(VENDOR_DATASET, selection, CTX, { previewDrafts: true }); + // The row value is the seed's own name, untouched — and NOTHING was fetched. + expect(preview.rows.map((r) => r.vendor).sort()).toEqual(['Acme Air', 'Bistro Ltd']); + expect(fetchRecordLabels).not.toHaveBeenCalled(); + }); + + it('but the preview still describes that dimension COLUMN — the two are different questions', async () => { + const labels: DimensionLabelDeps = { + getObjectFields: (o) => FIELDS[o], + fetchRecordLabels: async () => new Map(), + }; + const preview = await svc({ preview: true, labels }).queryDataset( + VENDOR_DATASET, + { dimensions: ['vendor'], measures: ['total_amount'] }, + CTX, + { previewDrafts: true }, + ); + expect(preview.fields.find((f) => f.name === 'vendor')?.label).toBe('Vendor'); + expect(preview.fields.find((f) => f.name === 'total_amount')?.label).toBe('Total Amount'); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 978d82eaa1..e7a40f97d0 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -1110,28 +1110,29 @@ export class AnalyticsService implements IAnalyticsService { query: async (q: AnalyticsQuery) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows!), } as IAnalyticsService; const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context); - // Label resolution is skipped on purpose: drafted seed rows reference - // lookups by NAME (the seed convention), which already reads well. + // 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 + // 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 + // same widget described its columns differently depending only on + // whether a pending seed draft existed — and preview is what an author + // is looking at WHILE authoring the dataset, which is when column + // metadata matters most. + this.enrichResultColumns(previewResult, dataset, selection, context); + // ⛔ What stays skipped here, deliberately, is dimension VALUE label + // resolution — turning a stored lookup id into the related record's + // display name. Drafted seed rows reference lookups by NAME (the seed + // convention), so the value already reads well and there is no id to + // resolve. That reasoning is about ROW VALUES and only about them; it + // never covered the COLUMN descriptors above, which come from the + // authored measure and so are neither unnecessary nor unavailable here. return previewResult; } } - // [#6761] The audience's language for THIS request, and the only locale - // either field-label enrichment site below is entitled to use. - // - // `ExecutionContext.locale` is the BCP-47 tag `resolveExecutionContext` - // resolves per request — the caller's `Accept-Language` when it expressed a - // preference, else the workspace `localization` setting. It is the same - // context field the currency chain a few lines down already reads, so both - // display decisions in this response answer to one request identity. - // - // `undefined` (no context, or an anonymous request that skips localization) - // is passed through deliberately rather than defaulted here: the shared - // resolver documents nullish as "no locale known" and answers `en`, the - // platform's source language. Choosing a different default in this file - // would be this service disagreeing with the renderer about the same map. - const requestLocale = context?.locale; - // #3602 — every label lookup in this request (sort keys below, display // labels further down) reads the REFERENCED object, so bind that object's // own read scope to this request once, up front. @@ -1223,9 +1224,7 @@ export class AnalyticsService implements IAnalyticsService { // Selected dimensions resolved against the dataset definition — shared by // drill metadata, label resolution, and dimension field-label enrichment. - const selectedDims = (selection.dimensions ?? []) - .map((name) => dataset.dimensions?.find((d) => d.name === name)) - .filter((d): d is NonNullable => !!d); + const selectedDims = this.selectedDimensions(dataset, selection); // ADR-0021 D2 — drill-through metadata. A host (dashboard/report) drills a // clicked bucket back to the underlying records, but it only knows the @@ -1364,6 +1363,72 @@ export class AnalyticsService implements IAnalyticsService { } } + this.enrichResultColumns(result, dataset, selection, context); + return result; + } + + /** + * The dataset dimensions this selection GROUPED THE GRID BY, resolved against + * the dataset definition. Shared by drill metadata, row-value label + * resolution and — through {@link enrichResultColumns} — the dimension column + * headers, so all three answer "which dimensions" the same way. + */ + private selectedDimensions(dataset: Dataset, selection: DatasetSelection) { + return (selection.dimensions ?? []) + .map((name) => dataset.dimensions?.find((d) => d.name === name)) + .filter((d): d is NonNullable => !!d); + } + + /** + * 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`. + * + * **Every key here is read off the DATASET** (the authored measure or + * dimension) **and `sourceFieldMeta`** (the source object's declared field + * metadata). Not one is read off `result.rows`. That is what makes this one + * seam serve both paths that produce a dataset response — the live engine + * query and the ADR-0037 P3 draft-data preview — and it is why #16097 was a + * defect rather than a deliberate omission: the preview branch returns ~250 + * lines before this ran, so a response over drafted seed rows carried none of + * these keys and a renderer fell back to humanizing the raw measure name and + * guessing a percent scale from magnitude — the exact failures #5537, + * objectui#3136 and #14492 each closed on the live path. + * + * Extracted rather than copied onto the second path, for the reason the + * `type` correction below already gives for living here at all: this is ONE + * rule holding both halves of the question, and a per-path copy would be two + * implementations of it, free to drift. + * + * ⛔ Not here, and deliberately: dimension VALUE label resolution + * ({@link resolveDimensionLabels}), which rewrites the grouped value in each + * ROW. That reads the rows, it is the one enrichment a seed-draft row set can + * make unnecessary, and the preview path skips it on purpose — see the note + * at that early return. + */ + private enrichResultColumns( + result: AnalyticsResult, + dataset: Dataset, + selection: DatasetSelection, + context: ExecutionContext | undefined, + ): void { + // [#6761] The audience's language for THIS request, and the only locale + // either field-label enrichment site below is entitled to use. + // + // `ExecutionContext.locale` is the BCP-47 tag `resolveExecutionContext` + // resolves per request — the caller's `Accept-Language` when it expressed a + // preference, else the workspace `localization` setting. It is the same + // context field the currency chain a few lines down already reads, so both + // display decisions in this response answer to one request identity. + // + // `undefined` (no context, or an anonymous request that skips localization) + // is passed through deliberately rather than defaulted here: the shared + // resolver documents nullish as "no locale known" and answers `en`, the + // platform's source language. Choosing a different default in this file + // would be this service disagreeing with the renderer about the same map. + const requestLocale = context?.locale; + // ADR-0021 — enrich measure columns with their display `label` + `format` // so presentations show "Tasks" / "$616,000" instead of the raw measure // name "task_count" / "616000". Carried on the result fields; the renderer @@ -1476,12 +1541,13 @@ export class AnalyticsService implements IAnalyticsService { // `result.fields` already carries, so an entry that stays a pure window // contributes no column and receives no descriptor. // - // Kept out of `selectedDims` deliberately. Drill metadata and row-value - // label resolution above answer a different question — which dimensions the - // caller GROUPED THE GRID BY, i.e. what a click can be turned back into - // records — and widening those would change drill payloads and row values, - // not table headers. - const describableDims = [...selectedDims]; + // Kept out of the SELECTED set deliberately, which is why the widening + // happens here and not in {@link selectedDimensions}. Drill metadata and + // row-value label resolution (both in `queryDataset`) answer a different + // question — which dimensions the caller GROUPED THE GRID BY, i.e. what a + // click can be turned back into records — and widening those would change + // drill payloads and row values, not table headers. + const describableDims = [...this.selectedDimensions(dataset, selection)]; for (const t of selection.timeDimensions ?? []) { if (describableDims.some((d) => d.name === t.dimension)) continue; const d = dataset.dimensions?.find((x) => x.name === t.dimension); @@ -1502,7 +1568,6 @@ export class AnalyticsService implements IAnalyticsService { if (label !== undefined) f.label = label; } } - return result; } /**