Skip to content

Commit 10c36cc

Browse files
fix(service-analytics): every dataset answer names its base object (#20712)
Fixes #20644 Clause-②: no This unblocks the consumer card objectstack-ai/objectui#11095. Its `DatasetWidget` keys record-change refresh on the answer's `object`, and it can stop waiting once this change is published. ## What changed - `queryDataset` now wraps its former body, a private `answerDataset`, and returns `{ ...answer, object: dataset.object }`. The stamp is set once, on the path all three exits leave through: the draft-preview return, the degraded "backing object unavailable" return, and the main return. An exit added later inherits it. The stamp is a copy rather than a write onto the answer, following the same ownership rule `applySqlEchoPolicy` keeps. - Retired: the two drill-block writes of `object` and the local `AnalyticsResultWithDrill.object` member. `object` is now typed by the declared `AnalyticsResult.object` (`@objectstack/spec/contracts`, landed by #20647). The four drill sidecars (`dimensionFields`, `drillRawRows`, `drillRawTotals`, `drillRanges`) are untouched; they belong to #20700. - `query()` (cube) does not pass through the wrapper, so a cube answer carries no `object`, as the contract states. - Changeset `.changeset/20644-dataset-answer-object.md`: `@objectstack/service-analytics` patch. ## Premises measured (dispatch A1 to A5) - **A1, the exits.** At `defc7f7b` there are three: preview `:1815`, degraded `:1922` and main `:2069`, with no fourth among the method's return statements. `object` was set only at `:1942` (equality drill) and `:2022` (date-range drill). So before this change a grouped preview answer lacked `object` too. - **A2, the member.** The package had exactly two writes of `AnalyticsResultWithDrill.object`, and the type is module-private. Tests read `result.object`, and the new pin file reads `answer.object` against plain `AnalyticsResult` with no cast. `tsc --noEmit` is green, and `--listFiles` holds all 135 `src/__tests__` files. - **A3, the cube side.** `query()` runs `queryIn`, then the strategy, then `applySqlEchoPolicy`, and nothing on that path stamps `object`. The pin: a cube answer over a registered dataset's cube carries none, even after a dataset answer on the same service. - **A4, other producers.** `git grep -n queryDataset -- packages` finds one implementation, `AnalyticsService.queryDataset`, and no other producer. - `MemoryAnalyticsService` (driver-memory) implements `IAnalyticsService` without the optional `queryDataset`. - `@objectstack/client`'s `analytics.queryDataset` relays the route body, and `@objectstack/rest` relays the service answer. - `metadata-protocol`'s build probes consume the answer. - **A5, the wire.** The route is `packages/rest/src/rest-server.ts:11093-11099`, and it ends `res.json(result)`. No existing REST-level test covers a dimension-less answer: the route tests that drive the real service all select a dimension. I measured once through the real route handler over the real service (not committed): - dimension-less answer: keys `fields, object, rows`, with `object` = `crm_account`; - grouped answer: keys `dimensionFields, drillRawRows, fields, object, rows`; - zero-row, dimension-less answer: keys `fields, object, rows`. The committed pins are service-level. ## Pins: red first, then the fix New file `src/__tests__/dataset-answer-object.test.ts` holds 11 tests: - **Main exit**, on both `NativeSQLStrategy` and `ObjectQLStrategy`: - `dimensions: []` answers `object`; - a zero-row answer answers `object`, grouped or not; - a grouped answer is unchanged: `object` sits beside `dimensionFields` and `drillRawRows`. - **Draft-preview exit**: a dimension-less preview and a grouped preview both answer `object`. - **Degraded exit**: the answer equals `{ rows: [], fields: [], totals: [], object: 'opportunity' }`, and the unavailable-object warn fired. - **Negative**, on both strategies: a cube `query` answer carries no `object`. Fixture triage: five existing files pinned the answer shape the contract now forbids, and they now expect the base object. - The degraded answer without `object`: three shared `EMPTY` constants and three inline literals, 11 assertion sites. - `object` absent when no dimension is drillable: one site, in `query-dataset.test.ts`. Readings: - **Red** at `4e08d0730` (pins only): 23 failed and 68 passed over the six touched test files. - **Green** at `f3fb94dfe`: 91 passed. ## Ablations The fix was committed first. Each leg ran through `scripts/ablation-replace.mjs` in WRAP mode, inside an outer trap that restores on EXIT, INT and TERM. The subject is imported relatively (`../analytics-service.js`), so it resolves to `src/` and no `dist` leg applies. - **Leg A: delete the stamp.** `return { ...answer, object: dataset.object };` becomes `return { ...answer };` (anchor 1 to 0, blob `f5a69cba` to `c3d60928`). - Result: 30 failed and 61 passed. - Red: every dataset-side pin (the main exit on both strategies, including grouped-unchanged; both previews; the degraded exit), every triaged fixture, and five pre-existing grouped-drill `object` assertions in `query-dataset.test.ts`. - Green: the two cube negatives. - Restore: blob equals HEAD (`f5a69cba`) and `git diff HEAD` is empty. - **Leg B: stamp a registered dataset's object on the shared `queryIn` seam.** Anchor 1 to 0, blob `f5a69cba` to `59ed0642`. - Result: 2 failed and 89 passed. The two failures are exactly the two cube negatives, where `'object' in cube` read true. - Restore proven the same way. ## Verification, at `f3fb94dfe` - **Package tests.** `pnpm --filter @objectstack/service-analytics test`: 137 files and 3216 tests passed. - **Typecheck.** `pnpm --filter @objectstack/service-analytics run typecheck`: exit 0. - **Consumers of the wire shape.** - `@objectstack/rest`: 17 files and 308 tests, every test file that names the dataset route. `service-analytics` resolved through a `dist` rebuilt from this head. - `@objectstack/client`: 5 files and 251 tests. - **Gates.** - `dispatch-gates --commands` derived 62 commands, identical to the dispatch-time list. All 62 ran with exit 0, and `--ran` reads "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN". - The four roster families outside that list also ran green: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - `check:dual-build-cjs-loads` first answered exit 3 (PREREQUISITE NOT MET: 44 unbuilt packages). After a full build it was green: 104 require entry points across 66 packages load. - **Lint, a proven narrowing.** The repo-wide `pnpm lint` is CI's to run. - Population: eslint's own config puts the 7 changed `.ts` files in scope, and `isPathIgnored` is true for the changeset. - Count: `eslint --no-inline-config --format json` on those files reads 7 files, 0 errors, 0 warnings. - Invariance: the config enables no type-aware linting (`eslint.config.mjs:327-328`, and `calculateConfigForFile` agrees for all 7). It reads only two baseline JSONs, which this diff does not touch, so no untouched file's verdict can move. ## Acceptance notes - carrier: #20700 · noted, not filed. A draft-preview answer carries none of the four drill sidecars, grouped or not, because the preview branch returns before the drill blocks run. With this PR it does carry `object`. Whether a preview answer should drill at all is a question for the sidecars' own card: its rows are drafted seed rows, not stored records. #20700 remains open and is not addressed here. - carrier: objectstack-ai/objectui#11095 · noted, not filed. By #20647's dev report, objectui reads `object` for drill as well as for refresh. A consumer that gates drill on the presence of `object` alone will now see it on dimension-less and zero-row answers too. This is not measured here, because objectui is outside this card's read scope. That card is where it is judged. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dfc8547 commit 10c36cc

8 files changed

Lines changed: 235 additions & 16 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): every dataset answer names its base object as `object` (#20644)
6+
7+
Clause-②: no
8+
9+
**What was wrong.** `queryDataset` set `object`, the dataset's base object, only
10+
while it built drill-through metadata, which it builds only when a drillable
11+
dimension is selected and at least one row came back. A dimension-less (KPI)
12+
answer, a zero-row answer and the degraded answer for an unavailable backing
13+
object carried no `object`, and neither did a draft preview (`previewDrafts`),
14+
grouped or not. `POST /api/v1/analytics/dataset/query` relays the service answer
15+
as it is, so a consumer that refreshes on that object's record changes had
16+
nothing to subscribe to for those answers.
17+
18+
**What changed.** Every `queryDataset` answer carries `object`, the dataset's
19+
`object` by machine name, whatever dimensions are selected and whether or not
20+
rows came back, as `AnalyticsResult.object` in `@objectstack/spec` declares. A
21+
grouped answer is unchanged: `object` sits beside the same drill-through keys
22+
as before. A cube `query` answer still carries no `object`.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20644 — every dataset answer names its base object.
5+
*
6+
* `AnalyticsResult.object` (`@objectstack/spec/contracts`) is declared as a
7+
* producer obligation for `queryDataset`: EVERY dataset answer carries it,
8+
* whatever dimensions are selected and whether or not rows came back, and a
9+
* `query` (cube) answer carries none. A consumer keys its record-change
10+
* refresh on it, so a dimension-less KPI tile whose answer lacks it subscribes
11+
* to nothing and never re-reads.
12+
*
13+
* The service used to set it only inside the two drill-through blocks, which
14+
* need a drillable dimension AND at least one row. So a dimension-less answer,
15+
* a zero-row answer, the draft-preview answer (it returns before those blocks)
16+
* and the degraded "backing object unavailable" answer all went without.
17+
*
18+
* One block per exit `queryDataset` has — the main return, the draft-preview
19+
* return and the degraded return — and the contract's cube-side negative. The
20+
* main exit is driven through both strategies a dataset query can take, so the
21+
* answer does not depend on which one served it.
22+
*/
23+
24+
import { describe, it, expect, vi } from 'vitest';
25+
import { DatasetSchema } from '@objectstack/spec/ui';
26+
import type { ExecutionContext } from '@objectstack/spec/kernel';
27+
import type { AnalyticsResult } from '@objectstack/spec/contracts';
28+
import { AnalyticsService } from '../analytics-service.js';
29+
30+
const CTX = { tenantId: 'org_A' } as ExecutionContext;
31+
32+
const DATASET = DatasetSchema.parse({
33+
name: 'pipeline',
34+
label: 'Pipeline',
35+
object: 'opportunity',
36+
include: [],
37+
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
38+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
39+
});
40+
41+
/** A KPI tile: no dimension selected. */
42+
const KPI = { dimensions: [], measures: ['revenue'] };
43+
/** A chart grouped by a drillable (non-date) dimension. */
44+
const GROUPED = { dimensions: ['stage'], measures: ['revenue'] };
45+
46+
type Capabilities = () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean };
47+
48+
const STRATEGY_PATHS: ReadonlyArray<{ label: string; capabilities: Capabilities }> = [
49+
{ label: 'NativeSQLStrategy', capabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }) },
50+
{ label: 'ObjectQLStrategy', capabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) },
51+
];
52+
53+
/**
54+
* A service whose driver answers `rows` to every query, on either strategy.
55+
* A dimension-less selection gets the rows with their dimension keys dropped,
56+
* which is what a real `GROUP BY`-less aggregate answers.
57+
*/
58+
function serviceAnswering(capabilities: Capabilities, rows: Record<string, unknown>[], extra: object = {}) {
59+
const answer = async () => rows.map((r) => ({ ...r }));
60+
return new AnalyticsService({
61+
queryCapabilities: capabilities,
62+
executeRawSql: answer,
63+
executeAggregate: answer,
64+
...extra,
65+
});
66+
}
67+
68+
describe.each(STRATEGY_PATHS)('the main exit names the base object — $label', ({ capabilities }) => {
69+
it('`dimensions: []` answers `object`', async () => {
70+
const answer = await serviceAnswering(capabilities, [{ revenue: 150 }]).queryDataset(DATASET, KPI, CTX);
71+
72+
expect(answer.rows).toEqual([{ revenue: 150 }]);
73+
expect(answer.object).toBe('opportunity');
74+
});
75+
76+
it('a zero-row answer answers `object` — grouped or not', async () => {
77+
const svc = serviceAnswering(capabilities, []);
78+
79+
const grouped = await svc.queryDataset(DATASET, GROUPED, CTX);
80+
expect(grouped.rows).toEqual([]);
81+
expect(grouped.object).toBe('opportunity');
82+
83+
const kpi = await svc.queryDataset(DATASET, KPI, CTX);
84+
expect(kpi.rows).toEqual([]);
85+
expect(kpi.object).toBe('opportunity');
86+
});
87+
88+
it('a grouped answer is unchanged: `object` beside the drill-through keys it always carried', async () => {
89+
const answer = await serviceAnswering(capabilities, [{ stage: 'won', revenue: 100 }]).queryDataset(
90+
DATASET,
91+
GROUPED,
92+
CTX,
93+
);
94+
95+
expect(answer).toMatchObject({
96+
rows: [{ stage: 'won', revenue: 100 }],
97+
object: 'opportunity',
98+
dimensionFields: { stage: 'stage' },
99+
drillRawRows: [{ stage: 'won' }],
100+
});
101+
});
102+
});
103+
104+
describe('the draft-preview exit names the base object', () => {
105+
// The preview branch evaluates the selection over the base object's pending
106+
// seed rows in memory and returns early, ahead of everything the main exit
107+
// does after the query — so it is the exit a drill-branch-only `object` never
108+
// reached, grouped or not.
109+
const SEED = [
110+
{ stage: 'won', amount: 100 },
111+
{ stage: 'lost', amount: 50 },
112+
];
113+
const previewService = () =>
114+
serviceAnswering(STRATEGY_PATHS[1].capabilities, [], { draftRowsResolver: async () => SEED });
115+
116+
it('a dimension-less preview answers `object`', async () => {
117+
const answer = await previewService().queryDataset(DATASET, KPI, CTX, { previewDrafts: true });
118+
119+
expect(answer.rows).toEqual([{ revenue: 150 }]);
120+
expect(answer.object).toBe('opportunity');
121+
});
122+
123+
it('a grouped preview answers `object`', async () => {
124+
const answer = await previewService().queryDataset(DATASET, GROUPED, CTX, { previewDrafts: true });
125+
126+
expect(answer.rows).toHaveLength(2);
127+
expect(answer.object).toBe('opportunity');
128+
});
129+
});
130+
131+
describe('the degraded exit names the base object', () => {
132+
it('"backing object unavailable" answers no rows, and still `object`', async () => {
133+
const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn() } as any;
134+
const svc = new AnalyticsService({
135+
queryCapabilities: STRATEGY_PATHS[0].capabilities,
136+
executeRawSql: async () => {
137+
throw new Error('SELECT SUM("amount") FROM "opportunity" - no such table: opportunity');
138+
},
139+
logger,
140+
});
141+
142+
const answer = await svc.queryDataset(DATASET, KPI, CTX);
143+
144+
expect(answer).toEqual({ rows: [], fields: [], totals: [], object: 'opportunity' });
145+
// …and it is the degraded exit that answered, not a main-exit zero-row one.
146+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('backing object "opportunity" is unavailable'));
147+
});
148+
});
149+
150+
describe.each(STRATEGY_PATHS)('a cube `query` answer carries no `object` — $label', ({ capabilities }) => {
151+
// The negative the contract states: a cube answer has no dataset behind it.
152+
// The hardest case for it is a cube that IS a registered dataset's compiled
153+
// cube, queried by name through `query()` on a service that has just answered
154+
// a dataset query for the same dataset — every query `queryDataset` issues
155+
// runs through the same body as `query()`, so an `object` stamped anywhere
156+
// on that shared path would surface here.
157+
it('not on a registered dataset\'s cube, not after a dataset answer on the same service', async () => {
158+
const svc = serviceAnswering(capabilities, [{ revenue: 150 }], { datasets: [DATASET] });
159+
160+
// A dataset answer first, from the same service over the same compiled cube.
161+
await svc.queryDataset(DATASET, KPI, CTX);
162+
163+
const cube: AnalyticsResult = await svc.query({ cube: 'pipeline', measures: ['revenue'] }, CTX);
164+
expect(cube.rows).toEqual([{ revenue: 150 }]);
165+
expect('object' in cube).toBe(false);
166+
});
167+
});

‎packages/services/service-analytics/src/__tests__/dataset-degradation-envelope.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ interface Refusal extends Error {
100100
status?: unknown;
101101
}
102102

103-
const EMPTY = { rows: [], fields: [], totals: [] };
103+
/** The degraded answer: no rows — and, like every dataset answer, its base object (#20644). */
104+
const EMPTY = { rows: [], fields: [], totals: [], object: 'opportunity' };
104105

105106
const dataset = DatasetSchema.parse({
106107
name: 'sales',

‎packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
9595
import { matchMissingColumnOfRelation } from '@objectstack/types';
9696
import { AnalyticsService } from '../analytics-service.js';
9797

98-
const EMPTY = { rows: [], fields: [], totals: [] };
98+
/** The degraded answer: no rows — and, like every dataset answer, its base object (#20644). */
99+
const EMPTY = { rows: [], fields: [], totals: [], object: 'opportunity' };
99100

100101
const dataset = DatasetSchema.parse({
101102
name: 'sales',

‎packages/services/service-analytics/src/__tests__/query-dataset.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ describe('AnalyticsService.queryDataset', () => {
5959
executeRawSql: async () => { throw new Error('SELECT COUNT(*) FROM "opportunity" - no such table: opportunity'); },
6060
});
6161
const result = await svc.queryDataset(dataset, { dimensions: ['region'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext);
62-
expect(result).toEqual({ rows: [], fields: [], totals: [] });
62+
// No rows — and, like every dataset answer, the base object (#20644).
63+
expect(result).toEqual({ rows: [], fields: [], totals: [], object: 'opportunity' });
6364
});
6465

6566
it('still throws on a non-missing-source error (real query bugs surface)', async () => {
@@ -282,8 +283,10 @@ describe('AnalyticsService.queryDataset', () => {
282283
const result = await svc.queryDataset(dated, { dimensions: ['closed'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext) as any;
283284
// No drillable (non-date) dimension → no drill metadata at all.
284285
expect(result.dimensionFields).toBeUndefined();
285-
expect(result.object).toBeUndefined();
286286
expect(result.drillRawRows).toBeUndefined();
287+
// `object` is not drill metadata: it names the answer's subject, and every
288+
// dataset answer carries it whether or not a dimension is drillable (#20644).
289+
expect(result.object).toBe('opportunity');
287290
// …and, absent a `dateGranularity`, no RANGE sidecar either (it's not a bucket).
288291
expect(result.drillRanges).toBeUndefined();
289292
});

‎packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,8 @@ describe('graceful degradation survives for a genuinely missing table (#5033)',
365365

366366
const result = await service.queryDataset(auditDataset as never, auditSelection as never);
367367

368-
expect(result).toEqual({ rows: [], fields: [], totals: [] });
368+
// No rows — and, like every dataset answer, the base object (#20644).
369+
expect(result).toEqual({ rows: [], fields: [], totals: [], object: 'sys_audit_log' });
369370
expect(warn.mock.calls.map(String).join('\n')).toMatch(
370371
/dataset "sys_audit_log_metrics" backing object "sys_audit_log" is unavailable/,
371372
);
@@ -389,7 +390,8 @@ describe('graceful degradation survives for a genuinely missing table (#5033)',
389390
{ dimensions: ['region'], measures: ['event_count'] } as never,
390391
);
391392

392-
expect(result).toEqual({ rows: [], fields: [], totals: [] });
393+
// No rows — and, like every dataset answer, the base object (#20644).
394+
expect(result).toEqual({ rows: [], fields: [], totals: [], object: 'sys_audit_log' });
393395
expect(warn.mock.calls.map(String).join('\n')).toMatch(/is unavailable/);
394396
});
395397
});

‎packages/services/service-analytics/src/__tests__/read-scope-resolution-envelope.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ interface Refusal extends Error {
7575
status?: unknown;
7676
}
7777

78-
const EMPTY = { rows: [], fields: [], totals: [] };
78+
/** The degraded answer: no rows — and, like every dataset answer, its base object (#20644). */
79+
const EMPTY = { rows: [], fields: [], totals: [], object: 'opportunity' };
7980

8081
const dataset = DatasetSchema.parse({
8182
name: 'sales',

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,12 @@ import { ACCEPTED_SQL_DIALECTS, isUnrecognisedSqlDialectAnswer, type AcceptedSql
9292
* Analytics result augmented with drill-through metadata (ADR-0021 D2; see
9393
* queryDataset). Carried alongside `rows` so the host can drill a clicked bucket
9494
* back to the underlying records without the renderer knowing field mappings.
95+
*
96+
* The object those records belong to is not part of this augmentation: it is
97+
* the contract's own `AnalyticsResult.object`, which every dataset answer
98+
* carries, drillable or not (#20644).
9599
*/
96100
type AnalyticsResultWithDrill = AnalyticsResult & {
97-
/** The dataset's base object — the host drills into its records. */
98-
object?: string;
99101
/** Selected drillable dimension NAME → underlying object FIELD name. */
100102
dimensionFields?: Record<string, string>;
101103
/**
@@ -1756,12 +1758,36 @@ export class AnalyticsService implements IAnalyticsService {
17561758
* call's queries and nothing else, so a dataset named like a configured cube
17571759
* neither replaces that cube nor re-publishes it, whatever the request's
17581760
* admission answers.
1761+
*
1762+
* [#20644] Every answer names the dataset's base object as `object`
1763+
* (`AnalyticsResult.object`), whatever dimensions were selected and whether
1764+
* or not rows came back. It is set here, once, on the path every exit of
1765+
* {@link answerDataset} leaves through — the draft-preview return, the
1766+
* degraded "backing object unavailable" return and the main return — so an
1767+
* exit added later inherits it instead of needing its own copy. `query()`
1768+
* never passes through here, so a cube answer carries none.
17591769
*/
17601770
async queryDataset(
17611771
dataset: Dataset,
17621772
selection: DatasetSelection,
17631773
context?: ExecutionContext,
17641774
options?: { previewDrafts?: boolean },
1775+
): Promise<AnalyticsResult> {
1776+
const answer = await this.answerDataset(dataset, selection, context, options);
1777+
// A copy rather than a write onto `answer`, which may be the very object a
1778+
// strategy returned (the same ownership rule `applySqlEchoPolicy` keeps).
1779+
return { ...answer, object: dataset.object };
1780+
}
1781+
1782+
/**
1783+
* The body of {@link queryDataset}. None of its exits sets `object`: the
1784+
* caller sets it once, for all of them.
1785+
*/
1786+
private async answerDataset(
1787+
dataset: Dataset,
1788+
selection: DatasetSelection,
1789+
context?: ExecutionContext,
1790+
options?: { previewDrafts?: boolean },
17651791
): Promise<AnalyticsResult> {
17661792
const compiled = this.compile(dataset);
17671793
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`);
@@ -1933,13 +1959,13 @@ export class AnalyticsService implements IAnalyticsService {
19331959
// dimension NAMES, and the label resolution below OVERWRITES the raw grouped
19341960
// value in each row with its display label. So before that happens, snapshot
19351961
// the raw grouped values into a PARALLEL array (aligned to `rows` by index —
1936-
// the result rows are NOT mutated) and expose the dataset's `object` +
1937-
// dimension→field mapping so the renderer can build an exact-match filter.
1962+
// the result rows are NOT mutated) and expose the dimension→field mapping so
1963+
// the renderer can build an exact-match filter over the answer's `object`
1964+
// (set by {@link queryDataset} on every answer, drillable or not).
19381965
// Date buckets are excluded — a humanized bucket ("2026-06") can't be
19391966
// exact-matched against the stored timestamp, so they are not drillable.
19401967
const drillDims = selectedDims.filter((d) => !!d.field && d.type !== 'date');
19411968
if (drillDims.length && result.rows.length) {
1942-
(result as AnalyticsResultWithDrill).object = dataset.object;
19431969
(result as AnalyticsResultWithDrill).dimensionFields = Object.fromEntries(
19441970
drillDims.map((d) => [d.name, d.field as string]),
19451971
);
@@ -2016,10 +2042,6 @@ export class AnalyticsService implements IAnalyticsService {
20162042
}
20172043
return ranges;
20182044
});
2019-
// The equality drill block sets `object` only when a NON-date drill dim
2020-
// exists; a report grouped ONLY by time still needs the base object so the
2021-
// host can open its list. Safe to (re)set to the same dataset object.
2022-
(result as AnalyticsResultWithDrill).object = dataset.object;
20232045
}
20242046

20252047
// ADR-0021 — resolve grouped dimension values to human display labels

0 commit comments

Comments
 (0)