Skip to content

Commit 4e08d07

Browse files
committed
test(service-analytics): pin that every dataset answer names its base object (red)
Pins, committed ahead of the fix and red against it: - the main exit, on NativeSQLStrategy and ObjectQLStrategy: a `dimensions: []` answer and a zero-row answer (grouped or not) carry `object`; a grouped answer keeps `object` beside its drill-through keys; - the draft-preview exit: a dimension-less and a grouped preview answer carry `object`; - the degraded "backing object unavailable" exit answers no rows and still `object`; - the negative: a cube `query` answer over a registered dataset's cube carries no `object`, even after a dataset answer on the same service. Five existing files pinned the answer shape the contract now forbids: the degraded answer without `object` (three shared EMPTY constants and three inline literals, eleven assertion sites) and `object` absent when no dimension is drillable (one site). They now expect the base object. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent defc7f7 commit 4e08d07

6 files changed

Lines changed: 182 additions & 7 deletions
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',

0 commit comments

Comments
 (0)