diff --git a/.changeset/20644-dataset-answer-object.md b/.changeset/20644-dataset-answer-object.md new file mode 100644 index 00000000000..211be8d4900 --- /dev/null +++ b/.changeset/20644-dataset-answer-object.md @@ -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`. diff --git a/packages/services/service-analytics/src/__tests__/dataset-answer-object.test.ts b/packages/services/service-analytics/src/__tests__/dataset-answer-object.test.ts new file mode 100644 index 00000000000..9889298ee22 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/dataset-answer-object.test.ts @@ -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[], 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); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/dataset-degradation-envelope.test.ts b/packages/services/service-analytics/src/__tests__/dataset-degradation-envelope.test.ts index f50e4ab7ef8..7b284aaa737 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-degradation-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-degradation-envelope.test.ts @@ -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', diff --git a/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts b/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts index 7f79783fb15..327bad451e7 100644 --- a/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts +++ b/packages/services/service-analytics/src/__tests__/missing-column-phrase-hard-failure.test.ts @@ -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', diff --git a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts index cca867905cb..eab855be937 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -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 () => { @@ -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(); }); diff --git a/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts b/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts index 9cbb9fec81e..c318491128a 100644 --- a/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts +++ b/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts @@ -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/, ); @@ -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/); }); }); diff --git a/packages/services/service-analytics/src/__tests__/read-scope-resolution-envelope.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-resolution-envelope.test.ts index 031e955a644..81eacfb2e9b 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-resolution-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-resolution-envelope.test.ts @@ -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', diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 9d77adcd798..f5a69cba723 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -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; /** @@ -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 { + 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 { const compiled = this.compile(dataset); this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`); @@ -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]), ); @@ -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