Skip to content

Commit f3fb94d

Browse files
committed
fix(service-analytics): every dataset answer names its base object
`queryDataset` set `object` only inside the two drill-through blocks, which need a drillable dimension and at least one row. A dimension-less answer, a zero-row answer, the draft-preview answer and the degraded "backing object unavailable" answer went without it, although `AnalyticsResult.object` declares it on every dataset answer. `queryDataset` now wraps its former body (`answerDataset`) and sets `object: dataset.object` once, on the path all three exits leave through, as a copy rather than a write onto the answer. The two drill-block writes and the local `AnalyticsResultWithDrill.object` member are retired; the four drill sidecars are unchanged. `query()` does not pass through the wrapper, so a cube answer carries no `object`. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e08d07 commit f3fb94d

2 files changed

Lines changed: 53 additions & 9 deletions

File tree

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`.

‎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)