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
Original file line number Diff line number Diff line change
@@ -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"}}]}',
);
});
});
55 changes: 4 additions & 51 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/**
* 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<Record<string, unknown>>;
/**
* 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<Array<Record<string, unknown>>>;
/**
* #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<Record<string, { field: string; gte: string; lt: string }>>;
};

/**
* [#5717] Does this error carry an ADR-0112 envelope — i.e. did its PRODUCER
* already classify it?
Expand Down Expand Up @@ -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<string, unknown> = {};
for (const d of drillDims) raw[d.name] = row[d.name];
return raw;
Expand All @@ -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<string, unknown> = {};
Expand Down Expand Up @@ -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<string, { field: string; gte: string; lt: string }> = {};
for (const { d, granularity, instant } of rangeDims) {
// A row in the empty bucket carries `null` here (#3839) and yields no
Expand Down
Loading