|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #20727 — the four drill-through sidecars are set on the declared |
| 5 | + * `AnalyticsResult`, with nothing in between. |
| 6 | + * |
| 7 | + * `AnalyticsResult` (`@objectstack/spec/contracts`) declares `dimensionFields`, |
| 8 | + * `drillRawRows`, `drillRawTotals` and `drillRanges` (#20700). The service once |
| 9 | + * set them through a module-private augmentation of that type; it now sets the |
| 10 | + * declared members directly. That retirement moved no byte of any answer, and |
| 11 | + * this file holds that line. |
| 12 | + * |
| 13 | + * The fixture is the one answer that carries all four at once: a grouping by |
| 14 | + * an equality-drillable dimension (`stage`) beside a month-bucketed date |
| 15 | + * dimension (`close_date`), with a `totals` selection (a per-stage subtotal |
| 16 | + * and the grand total). The answer is read as `AnalyticsResult` with no cast, |
| 17 | + * so each member read below compiles only because the contract declares it. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, it, expect } from 'vitest'; |
| 21 | +import { DatasetSchema } from '@objectstack/spec/ui'; |
| 22 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 23 | +import type { AnalyticsResult } from '@objectstack/spec/contracts'; |
| 24 | +import { AnalyticsService } from '../analytics-service.js'; |
| 25 | + |
| 26 | +const CTX = { tenantId: 'org_A' } as ExecutionContext; |
| 27 | + |
| 28 | +const DATASET = DatasetSchema.parse({ |
| 29 | + name: 'pipe_drill', |
| 30 | + label: 'Pipe', |
| 31 | + object: 'opportunity', |
| 32 | + include: [], |
| 33 | + dimensions: [ |
| 34 | + { name: 'stage', field: 'stage', type: 'string' }, |
| 35 | + { name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' }, |
| 36 | + ], |
| 37 | + measures: [{ name: 'cnt', aggregate: 'count' }], |
| 38 | +}); |
| 39 | + |
| 40 | +/** The grouped field names of one `executeAggregate` call (a date bucket arrives as `{ field, dateGranularity }`). */ |
| 41 | +function groupedFields(groupBy: unknown[] | undefined): string[] { |
| 42 | + return (groupBy ?? []).map((g) => (typeof g === 'string' ? g : (g as { field: string }).field)); |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Granularity bucketing runs through the ObjectQL aggregate path, so the driver |
| 47 | + * answers already-bucketed rows: the main grid (stage × month), the per-stage |
| 48 | + * subtotal, and the grand total, told apart by what each call groups by. |
| 49 | + */ |
| 50 | +function drillService(): AnalyticsService { |
| 51 | + return new AnalyticsService({ |
| 52 | + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), |
| 53 | + executeAggregate: async (_object, { groupBy }) => { |
| 54 | + const grouped = groupedFields(groupBy); |
| 55 | + if (grouped.includes('close_date')) { |
| 56 | + return [ |
| 57 | + { stage: 'qualification', close_date: '2026-06', cnt: 3 }, |
| 58 | + { stage: 'won', close_date: '2026-07', cnt: 2 }, |
| 59 | + ]; |
| 60 | + } |
| 61 | + if (grouped.includes('stage')) { |
| 62 | + return [ |
| 63 | + { stage: 'qualification', cnt: 3 }, |
| 64 | + { stage: 'won', cnt: 2 }, |
| 65 | + ]; |
| 66 | + } |
| 67 | + return [{ cnt: 5 }]; |
| 68 | + }, |
| 69 | + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), |
| 70 | + }); |
| 71 | +} |
| 72 | + |
| 73 | +const DRILL_SIDECARS = ['dimensionFields', 'drillRawRows', 'drillRawTotals', 'drillRanges']; |
| 74 | + |
| 75 | +describe('the drill-through sidecars are emitted on the declared AnalyticsResult (#20727)', () => { |
| 76 | + it('a grouped, date-bucketed answer with totals carries all four, byte for byte', async () => { |
| 77 | + const answer: AnalyticsResult = await drillService().queryDataset( |
| 78 | + DATASET, |
| 79 | + { dimensions: ['stage', 'close_date'], measures: ['cnt'], totals: { groupings: [['stage'], []] } }, |
| 80 | + CTX, |
| 81 | + ); |
| 82 | + |
| 83 | + // All four are present, in the order the service sets them. |
| 84 | + expect(Object.keys(answer).filter((k) => DRILL_SIDECARS.includes(k))).toEqual(DRILL_SIDECARS); |
| 85 | + |
| 86 | + const sidecars = { |
| 87 | + dimensionFields: answer.dimensionFields, |
| 88 | + drillRawRows: answer.drillRawRows, |
| 89 | + drillRawTotals: answer.drillRawTotals, |
| 90 | + drillRanges: answer.drillRanges, |
| 91 | + }; |
| 92 | + expect(JSON.stringify(sidecars)).toBe( |
| 93 | + '{"dimensionFields":{"stage":"stage"},' + |
| 94 | + '"drillRawRows":[{"stage":"qualification"},{"stage":"won"}],' + |
| 95 | + '"drillRawTotals":[[{"stage":"qualification"},{"stage":"won"}],[{}]],' + |
| 96 | + '"drillRanges":[' + |
| 97 | + '{"close_date":{"field":"close_date","gte":"2026-06-01","lt":"2026-07-01"}},' + |
| 98 | + '{"close_date":{"field":"close_date","gte":"2026-07-01","lt":"2026-08-01"}}]}', |
| 99 | + ); |
| 100 | + }); |
| 101 | +}); |
0 commit comments