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
22 changes: 22 additions & 0 deletions .changeset/20644-dataset-answer-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/service-analytics': patch
---

fix(service-analytics): every dataset answer names its base object as `object` (#20644)

Clause-②: no

**What was wrong.** `queryDataset` set `object`, the dataset's base object, only
while it built drill-through metadata, which it builds only when a drillable
dimension is selected and at least one row came back. A dimension-less (KPI)
answer, a zero-row answer and the degraded answer for an unavailable backing
object carried no `object`, and neither did a draft preview (`previewDrafts`),
grouped or not. `POST /api/v1/analytics/dataset/query` relays the service answer
as it is, so a consumer that refreshes on that object's record changes had
nothing to subscribe to for those answers.

**What changed.** Every `queryDataset` answer carries `object`, the dataset's
`object` by machine name, whatever dimensions are selected and whether or not
rows came back, as `AnalyticsResult.object` in `@objectstack/spec` declares. A
grouped answer is unchanged: `object` sits beside the same drill-through keys
as before. A cube `query` answer still carries no `object`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #20644 — every dataset answer names its base object.
*
* `AnalyticsResult.object` (`@objectstack/spec/contracts`) is declared as a
* producer obligation for `queryDataset`: EVERY dataset answer carries it,
* whatever dimensions are selected and whether or not rows came back, and a
* `query` (cube) answer carries none. A consumer keys its record-change
* refresh on it, so a dimension-less KPI tile whose answer lacks it subscribes
* to nothing and never re-reads.
*
* The service used to set it only inside the two drill-through blocks, which
* need a drillable dimension AND at least one row. So a dimension-less answer,
* a zero-row answer, the draft-preview answer (it returns before those blocks)
* and the degraded "backing object unavailable" answer all went without.
*
* One block per exit `queryDataset` has — the main return, the draft-preview
* return and the degraded return — and the contract's cube-side negative. The
* main exit is driven through both strategies a dataset query can take, so the
* answer does not depend on which one served it.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { AnalyticsResult } from '@objectstack/spec/contracts';
import { AnalyticsService } from '../analytics-service.js';

const CTX = { tenantId: 'org_A' } as ExecutionContext;

const DATASET = DatasetSchema.parse({
name: 'pipeline',
label: 'Pipeline',
object: 'opportunity',
include: [],
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
});

/** A KPI tile: no dimension selected. */
const KPI = { dimensions: [], measures: ['revenue'] };
/** A chart grouped by a drillable (non-date) dimension. */
const GROUPED = { dimensions: ['stage'], measures: ['revenue'] };

type Capabilities = () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean };

const STRATEGY_PATHS: ReadonlyArray<{ label: string; capabilities: Capabilities }> = [
{ label: 'NativeSQLStrategy', capabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }) },
{ label: 'ObjectQLStrategy', capabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) },
];

/**
* A service whose driver answers `rows` to every query, on either strategy.
* A dimension-less selection gets the rows with their dimension keys dropped,
* which is what a real `GROUP BY`-less aggregate answers.
*/
function serviceAnswering(capabilities: Capabilities, rows: Record<string, unknown>[], extra: object = {}) {
const answer = async () => rows.map((r) => ({ ...r }));
return new AnalyticsService({
queryCapabilities: capabilities,
executeRawSql: answer,
executeAggregate: answer,
...extra,
});
}

describe.each(STRATEGY_PATHS)('the main exit names the base object — $label', ({ capabilities }) => {
it('`dimensions: []` answers `object`', async () => {
const answer = await serviceAnswering(capabilities, [{ revenue: 150 }]).queryDataset(DATASET, KPI, CTX);

expect(answer.rows).toEqual([{ revenue: 150 }]);
expect(answer.object).toBe('opportunity');
});

it('a zero-row answer answers `object` — grouped or not', async () => {
const svc = serviceAnswering(capabilities, []);

const grouped = await svc.queryDataset(DATASET, GROUPED, CTX);
expect(grouped.rows).toEqual([]);
expect(grouped.object).toBe('opportunity');

const kpi = await svc.queryDataset(DATASET, KPI, CTX);
expect(kpi.rows).toEqual([]);
expect(kpi.object).toBe('opportunity');
});

it('a grouped answer is unchanged: `object` beside the drill-through keys it always carried', async () => {
const answer = await serviceAnswering(capabilities, [{ stage: 'won', revenue: 100 }]).queryDataset(
DATASET,
GROUPED,
CTX,
);

expect(answer).toMatchObject({
rows: [{ stage: 'won', revenue: 100 }],
object: 'opportunity',
dimensionFields: { stage: 'stage' },
drillRawRows: [{ stage: 'won' }],
});
});
});

describe('the draft-preview exit names the base object', () => {
// The preview branch evaluates the selection over the base object's pending
// seed rows in memory and returns early, ahead of everything the main exit
// does after the query — so it is the exit a drill-branch-only `object` never
// reached, grouped or not.
const SEED = [
{ stage: 'won', amount: 100 },
{ stage: 'lost', amount: 50 },
];
const previewService = () =>
serviceAnswering(STRATEGY_PATHS[1].capabilities, [], { draftRowsResolver: async () => SEED });

it('a dimension-less preview answers `object`', async () => {
const answer = await previewService().queryDataset(DATASET, KPI, CTX, { previewDrafts: true });

expect(answer.rows).toEqual([{ revenue: 150 }]);
expect(answer.object).toBe('opportunity');
});

it('a grouped preview answers `object`', async () => {
const answer = await previewService().queryDataset(DATASET, GROUPED, CTX, { previewDrafts: true });

expect(answer.rows).toHaveLength(2);
expect(answer.object).toBe('opportunity');
});
});

describe('the degraded exit names the base object', () => {
it('"backing object unavailable" answers no rows, and still `object`', async () => {
const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn() } as any;
const svc = new AnalyticsService({
queryCapabilities: STRATEGY_PATHS[0].capabilities,
executeRawSql: async () => {
throw new Error('SELECT SUM("amount") FROM "opportunity" - no such table: opportunity');
},
logger,
});

const answer = await svc.queryDataset(DATASET, KPI, CTX);

expect(answer).toEqual({ rows: [], fields: [], totals: [], object: 'opportunity' });
// …and it is the degraded exit that answered, not a main-exit zero-row one.
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('backing object "opportunity" is unavailable'));
});
});

describe.each(STRATEGY_PATHS)('a cube `query` answer carries no `object` — $label', ({ capabilities }) => {
// The negative the contract states: a cube answer has no dataset behind it.
// The hardest case for it is a cube that IS a registered dataset's compiled
// cube, queried by name through `query()` on a service that has just answered
// a dataset query for the same dataset — every query `queryDataset` issues
// runs through the same body as `query()`, so an `object` stamped anywhere
// on that shared path would surface here.
it('not on a registered dataset\'s cube, not after a dataset answer on the same service', async () => {
const svc = serviceAnswering(capabilities, [{ revenue: 150 }], { datasets: [DATASET] });

// A dataset answer first, from the same service over the same compiled cube.
await svc.queryDataset(DATASET, KPI, CTX);

const cube: AnalyticsResult = await svc.query({ cube: 'pipeline', measures: ['revenue'] }, CTX);
expect(cube.rows).toEqual([{ revenue: 150 }]);
expect('object' in cube).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ interface Refusal extends Error {
status?: unknown;
}

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

const dataset = DatasetSchema.parse({
name: 'sales',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
import { matchMissingColumnOfRelation } from '@objectstack/types';
import { AnalyticsService } from '../analytics-service.js';

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

const dataset = DatasetSchema.parse({
name: 'sales',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ describe('AnalyticsService.queryDataset', () => {
executeRawSql: async () => { throw new Error('SELECT COUNT(*) FROM "opportunity" - no such table: opportunity'); },
});
const result = await svc.queryDataset(dataset, { dimensions: ['region'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext);
expect(result).toEqual({ rows: [], fields: [], totals: [] });
// No rows — and, like every dataset answer, the base object (#20644).
expect(result).toEqual({ rows: [], fields: [], totals: [], object: 'opportunity' });
});

it('still throws on a non-missing-source error (real query bugs surface)', async () => {
Expand Down Expand Up @@ -282,8 +283,10 @@ describe('AnalyticsService.queryDataset', () => {
const result = await svc.queryDataset(dated, { dimensions: ['closed'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext) as any;
// No drillable (non-date) dimension → no drill metadata at all.
expect(result.dimensionFields).toBeUndefined();
expect(result.object).toBeUndefined();
expect(result.drillRawRows).toBeUndefined();
// `object` is not drill metadata: it names the answer's subject, and every
// dataset answer carries it whether or not a dimension is drillable (#20644).
expect(result.object).toBe('opportunity');
// …and, absent a `dateGranularity`, no RANGE sidecar either (it's not a bucket).
expect(result.drillRanges).toBeUndefined();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@ describe('graceful degradation survives for a genuinely missing table (#5033)',

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

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

expect(result).toEqual({ rows: [], fields: [], totals: [] });
// No rows — and, like every dataset answer, the base object (#20644).
expect(result).toEqual({ rows: [], fields: [], totals: [], object: 'sys_audit_log' });
expect(warn.mock.calls.map(String).join('\n')).toMatch(/is unavailable/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ interface Refusal extends Error {
status?: unknown;
}

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

const dataset = DatasetSchema.parse({
name: 'sales',
Expand Down
40 changes: 31 additions & 9 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@ import { ACCEPTED_SQL_DIALECTS, isUnrecognisedSqlDialectAnswer, type AcceptedSql
* Analytics result augmented with drill-through metadata (ADR-0021 D2; see
* queryDataset). Carried alongside `rows` so the host can drill a clicked bucket
* back to the underlying records without the renderer knowing field mappings.
*
* The object those records belong to is not part of this augmentation: it is
* the contract's own `AnalyticsResult.object`, which every dataset answer
* carries, drillable or not (#20644).
*/
type AnalyticsResultWithDrill = AnalyticsResult & {
/** The dataset's base object — the host drills into its records. */
object?: string;
/** Selected drillable dimension NAME → underlying object FIELD name. */
dimensionFields?: Record<string, string>;
/**
Expand Down Expand Up @@ -1756,12 +1758,36 @@ export class AnalyticsService implements IAnalyticsService {
* call's queries and nothing else, so a dataset named like a configured cube
* neither replaces that cube nor re-publishes it, whatever the request's
* admission answers.
*
* [#20644] Every answer names the dataset's base object as `object`
* (`AnalyticsResult.object`), whatever dimensions were selected and whether
* or not rows came back. It is set here, once, on the path every exit of
* {@link answerDataset} leaves through — the draft-preview return, the
* degraded "backing object unavailable" return and the main return — so an
* exit added later inherits it instead of needing its own copy. `query()`
* never passes through here, so a cube answer carries none.
*/
async queryDataset(
dataset: Dataset,
selection: DatasetSelection,
context?: ExecutionContext,
options?: { previewDrafts?: boolean },
): Promise<AnalyticsResult> {
const answer = await this.answerDataset(dataset, selection, context, options);
// A copy rather than a write onto `answer`, which may be the very object a
// strategy returned (the same ownership rule `applySqlEchoPolicy` keeps).
return { ...answer, object: dataset.object };
}

/**
* The body of {@link queryDataset}. None of its exits sets `object`: the
* caller sets it once, for all of them.
*/
private async answerDataset(
dataset: Dataset,
selection: DatasetSelection,
context?: ExecutionContext,
options?: { previewDrafts?: boolean },
): Promise<AnalyticsResult> {
const compiled = this.compile(dataset);
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`);
Expand Down Expand Up @@ -1933,13 +1959,13 @@ export class AnalyticsService implements IAnalyticsService {
// dimension NAMES, and the label resolution below OVERWRITES the raw grouped
// value in each row with its display label. So before that happens, snapshot
// the raw grouped values into a PARALLEL array (aligned to `rows` by index —
// the result rows are NOT mutated) and expose the dataset's `object` +
// dimension→field mapping so the renderer can build an exact-match filter.
// the result rows are NOT mutated) and expose the dimension→field mapping so
// the renderer can build an exact-match filter over the answer's `object`
// (set by {@link queryDataset} on every answer, drillable or not).
// Date buckets are excluded — a humanized bucket ("2026-06") can't be
// exact-matched against the stored timestamp, so they are not drillable.
const drillDims = selectedDims.filter((d) => !!d.field && d.type !== 'date');
if (drillDims.length && result.rows.length) {
(result as AnalyticsResultWithDrill).object = dataset.object;
(result as AnalyticsResultWithDrill).dimensionFields = Object.fromEntries(
drillDims.map((d) => [d.name, d.field as string]),
);
Expand Down Expand Up @@ -2016,10 +2042,6 @@ export class AnalyticsService implements IAnalyticsService {
}
return ranges;
});
// The equality drill block sets `object` only when a NON-date drill dim
// exists; a report grouped ONLY by time still needs the base object so the
// host can open its list. Safe to (re)set to the same dataset object.
(result as AnalyticsResultWithDrill).object = dataset.object;
}

// ADR-0021 — resolve grouped dimension values to human display labels
Expand Down
Loading