diff --git a/packages/services/service-analytics/src/__tests__/drill-sidecars-emission.test.ts b/packages/services/service-analytics/src/__tests__/drill-sidecars-emission.test.ts new file mode 100644 index 00000000000..81e70ba8a6f --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/drill-sidecars-emission.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20727 — the four drill-through sidecars are set on the declared + * `AnalyticsResult`, with nothing in between. + * + * `AnalyticsResult` (`@objectstack/spec/contracts`) declares `dimensionFields`, + * `drillRawRows`, `drillRawTotals` and `drillRanges` (#20700). The service once + * set them through a module-private augmentation of that type; it now sets the + * declared members directly. That retirement moved no byte of any answer, and + * this file holds that line. + * + * The fixture is the one answer that carries all four at once: a grouping by + * an equality-drillable dimension (`stage`) beside a month-bucketed date + * dimension (`close_date`), with a `totals` selection (a per-stage subtotal + * and the grand total). The answer is read as `AnalyticsResult` with no cast, + * so each member read below compiles only because the contract declares it. + */ + +import { describe, it, expect } 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: 'pipe_drill', + label: 'Pipe', + object: 'opportunity', + include: [], + dimensions: [ + { name: 'stage', field: 'stage', type: 'string' }, + { name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' }, + ], + measures: [{ name: 'cnt', aggregate: 'count' }], +}); + +/** The grouped field names of one `executeAggregate` call (a date bucket arrives as `{ field, dateGranularity }`). */ +function groupedFields(groupBy: unknown[] | undefined): string[] { + return (groupBy ?? []).map((g) => (typeof g === 'string' ? g : (g as { field: string }).field)); +} + +/** + * Granularity bucketing runs through the ObjectQL aggregate path, so the driver + * answers already-bucketed rows: the main grid (stage × month), the per-stage + * subtotal, and the grand total, told apart by what each call groups by. + */ +function drillService(): AnalyticsService { + return new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object, { groupBy }) => { + const grouped = groupedFields(groupBy); + if (grouped.includes('close_date')) { + return [ + { stage: 'qualification', close_date: '2026-06', cnt: 3 }, + { stage: 'won', close_date: '2026-07', cnt: 2 }, + ]; + } + if (grouped.includes('stage')) { + return [ + { stage: 'qualification', cnt: 3 }, + { stage: 'won', cnt: 2 }, + ]; + } + return [{ cnt: 5 }]; + }, + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); +} + +const DRILL_SIDECARS = ['dimensionFields', 'drillRawRows', 'drillRawTotals', 'drillRanges']; + +describe('the drill-through sidecars are emitted on the declared AnalyticsResult (#20727)', () => { + it('a grouped, date-bucketed answer with totals carries all four, byte for byte', async () => { + const answer: AnalyticsResult = await drillService().queryDataset( + DATASET, + { dimensions: ['stage', 'close_date'], measures: ['cnt'], totals: { groupings: [['stage'], []] } }, + CTX, + ); + + // All four are present, in the order the service sets them. + expect(Object.keys(answer).filter((k) => DRILL_SIDECARS.includes(k))).toEqual(DRILL_SIDECARS); + + const sidecars = { + dimensionFields: answer.dimensionFields, + drillRawRows: answer.drillRawRows, + drillRawTotals: answer.drillRawTotals, + drillRanges: answer.drillRanges, + }; + expect(JSON.stringify(sidecars)).toBe( + '{"dimensionFields":{"stage":"stage"},' + + '"drillRawRows":[{"stage":"qualification"},{"stage":"won"}],' + + '"drillRawTotals":[[{"stage":"qualification"},{"stage":"won"}],[{}]],' + + '"drillRanges":[' + + '{"close_date":{"field":"close_date","gte":"2026-06-01","lt":"2026-07-01"}},' + + '{"close_date":{"field":"close_date","gte":"2026-07-01","lt":"2026-08-01"}}]}', + ); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index a2921debb46..d22f648ef52 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -88,53 +88,6 @@ import { invalidMemberError } from './dataset-refusal.js'; // it rather than restating it. import { ACCEPTED_SQL_DIALECTS, isUnrecognisedSqlDialectAnswer, type AcceptedSqlDialect } from './text-match-sql.js'; -/** - * 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 & { - /** Selected drillable dimension NAME → underlying object FIELD name. */ - dimensionFields?: Record; - /** - * RAW grouped values per row, aligned to `rows` by index — each a map of - * drillable dimension NAME → stored value (BEFORE label resolution rewrote - * `rows[i][dim]` to the display label). The exact-match drill filter is built - * from these, never from the display labels. - */ - drillRawRows?: Array>; - /** - * RAW grouped values for the totals/subtotal rows (#3214), the totals-side - * companion to `drillRawRows`: `drillRawTotals[i]` aligns to `result.totals[i]` - * and `drillRawTotals[i][j]` to `result.totals[i].rows[j]`. Each map holds that - * grouping's DRILLABLE dimension NAME → stored value, snapshotted in the SAME - * pre-label-resolution pass (the totals loop below overwrites a subtotal row's - * dimension value with its display label just like the data rows). Restricted - * to the drillable dims present in the grouping, so the grand-total grouping - * (`[]`) contributes an empty map per row — which keeps the index alignment - * intact and correctly drills the whole (unfiltered) object. - */ - drillRawTotals?: Array>>; - /** - * #1752 — half-open date-range drill scope per row, the RANGE companion to - * `drillRawRows` (which handles equality dims). A time-bucketed date - * dimension (`dateGranularity`) groups a SPAN of records into one bucket - * ("2026-Q2"), so its drill needs `[gte, lt)`, not equality — the humanized - * bucket can't be exact-matched (which is why date dims are excluded from - * `dimensionFields`/`drillRawRows`). Aligned to `rows` by index; each entry - * maps a drillable date-dimension NAME → `{ field, gte, lt }` with `gte` - * inclusive and `lt` exclusive (bounds as `YYYY-MM-DD`). Present only for - * buckets whose boundaries are unambiguous — a `datetime` field under a - * non-UTC reference timezone is omitted (host drills an unscoped superset) - * until instant-boundary support lands. - */ - drillRanges?: Array>; -}; - /** * [#5717] Does this error carry an ADR-0112 envelope — i.e. did its PRODUCER * already classify it? @@ -1967,10 +1920,10 @@ export class AnalyticsService implements IAnalyticsService { // 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).dimensionFields = Object.fromEntries( + result.dimensionFields = Object.fromEntries( drillDims.map((d) => [d.name, d.field as string]), ); - (result as AnalyticsResultWithDrill).drillRawRows = result.rows.map((row) => { + result.drillRawRows = result.rows.map((row) => { const raw: Record = {}; for (const d of drillDims) raw[d.name] = row[d.name]; return raw; @@ -1982,7 +1935,7 @@ export class AnalyticsService implements IAnalyticsService { // groups by (the grand-total grouping `[]` keeps empty maps, so a subtotal // drill filters by the stored value while the grand total drills unfiltered). if (result.totals?.length) { - (result as AnalyticsResultWithDrill).drillRawTotals = result.totals.map((total) => { + result.drillRawTotals = result.totals.map((total) => { const groupingDims = drillDims.filter((d) => total.dimensions.includes(d.name)); return total.rows.map((row) => { const raw: Record = {}; @@ -2031,7 +1984,7 @@ export class AnalyticsService implements IAnalyticsService { if (rangeDims.length && result.rows.length) { const bound = (ymd: string, instant: boolean): string => instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd; - (result as AnalyticsResultWithDrill).drillRanges = result.rows.map((row) => { + result.drillRanges = result.rows.map((row) => { const ranges: Record = {}; for (const { d, granularity, instant } of rangeDims) { // A row in the empty bucket carries `null` here (#3839) and yields no