|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `fields[].aggregate` — every measure column of a dataset answer states the |
| 5 | + * aggregate its measure declares, whether or not the author labelled it. |
| 6 | + * |
| 7 | + * `builtinAggregate` answers one question only: "is this header the server's |
| 8 | + * default?". It is absent the moment an author writes a `label`, and every |
| 9 | + * measured showcase widget labels its measure (a `count` named "Tasks"). So on |
| 10 | + * exactly the columns a dashboard draws, the answer said `{ name, type: |
| 11 | + * 'number', label }` and nothing else, and a chart over a count drew 0.75 / |
| 12 | + * 1.5 / 2.25 axis ticks because it could not tell the count from a sum. |
| 13 | + * |
| 14 | + * The aggregate is part of the dataset's own authored measure, so the ADR-0021 |
| 15 | + * column-description seam (`enrichResultColumns`) states it from there, like |
| 16 | + * `label` / `format` / `currency` / `percentScale`. That seam serves both paths |
| 17 | + * that produce a dataset answer, the live engine query and the ADR-0037 P3 |
| 18 | + * draft-data preview, so each pin below is asserted on both. |
| 19 | + * |
| 20 | + * Reverse verification, direction predicted BEFORE running: deleting the one |
| 21 | + * producer line that writes `f.aggregate` turns every positive `aggregate` |
| 22 | + * assertion RED on both paths and leaves the absence assertions and every |
| 23 | + * `builtinAggregate` assertion GREEN. Deleting only its `!m.derived` guard |
| 24 | + * turns the stray-aggregate case RED and nothing else. |
| 25 | + */ |
| 26 | + |
| 27 | +import { describe, it, expect } from 'vitest'; |
| 28 | +import { DatasetSchema } from '@objectstack/spec/ui'; |
| 29 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 30 | +import { AnalyticsService } from '../analytics-service.js'; |
| 31 | + |
| 32 | +interface Task extends Record<string, unknown> { |
| 33 | + status: string; |
| 34 | + amount: number; |
| 35 | + due_on: string; |
| 36 | +} |
| 37 | + |
| 38 | +const ROWS: Task[] = [ |
| 39 | + { status: 'open', amount: 100, due_on: '2026-02-03' }, |
| 40 | + { status: 'open', amount: 50, due_on: '2026-02-10' }, |
| 41 | + { status: 'done', amount: 25, due_on: '2026-02-15' }, |
| 42 | + { status: 'done', amount: 10, due_on: '2026-01-20' }, |
| 43 | +]; |
| 44 | + |
| 45 | +const DATASET = DatasetSchema.parse({ |
| 46 | + name: 'task_ds', |
| 47 | + label: 'Tasks', |
| 48 | + object: 'task', |
| 49 | + dimensions: [ |
| 50 | + { name: 'status', field: 'status', type: 'string', label: 'Status' }, |
| 51 | + { name: 'due_on', field: 'due_on', type: 'date', label: 'Due' }, |
| 52 | + ], |
| 53 | + measures: [ |
| 54 | + // The showcase shape: a `count` the author named. |
| 55 | + { name: 'task_count', aggregate: 'count', label: 'Tasks' }, |
| 56 | + // The built-in default: no label, so `builtinAggregate` too. |
| 57 | + { name: 'count', aggregate: 'count' }, |
| 58 | + // A `sum` over a currency field. |
| 59 | + { name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount', format: '$0,0' }, |
| 60 | + // A derived measure has no single aggregate. |
| 61 | + { name: 'open_share', derived: { op: 'ratio', of: ['task_count', 'count'] }, label: 'Share' }, |
| 62 | + ], |
| 63 | +}); |
| 64 | + |
| 65 | +const sourceFieldMeta = (object: string, field: string) => { |
| 66 | + if (object !== 'task') return undefined; |
| 67 | + if (field === 'amount') return { type: 'currency' }; |
| 68 | + if (field === 'due_on') return { type: 'date' }; |
| 69 | + return undefined; |
| 70 | +}; |
| 71 | + |
| 72 | +const CTX = { tenantId: 'org_A', currency: 'USD' } as ExecutionContext; |
| 73 | + |
| 74 | +/** Enough of a GROUP BY for this fixture: the LIVE path's engine. */ |
| 75 | +function evaluateAggregate(opts: { groupBy?: unknown; aggregations?: unknown }) { |
| 76 | + const groupBy = (opts.groupBy ?? []) as Array<string | { field: string }>; |
| 77 | + const aggs = (opts.aggregations ?? []) as Array<{ field: string; method: string; alias: string }>; |
| 78 | + const buckets = new Map<string, { key: Record<string, unknown>; rows: Task[] }>(); |
| 79 | + for (const r of ROWS) { |
| 80 | + const key: Record<string, unknown> = {}; |
| 81 | + for (const g of groupBy) { |
| 82 | + const f = typeof g === 'string' ? g : g.field; |
| 83 | + key[f] = r[f]; |
| 84 | + } |
| 85 | + const id = JSON.stringify(Object.values(key)); |
| 86 | + let b = buckets.get(id); |
| 87 | + if (!b) { b = { key, rows: [] }; buckets.set(id, b); } |
| 88 | + b.rows.push(r); |
| 89 | + } |
| 90 | + return [...buckets.values()].map(({ key, rows }) => { |
| 91 | + const row: Record<string, unknown> = { ...key }; |
| 92 | + for (const a of aggs) { |
| 93 | + row[a.alias] = a.method === 'sum' |
| 94 | + ? rows.reduce((s, r) => s + Number(r[a.field] ?? 0), 0) |
| 95 | + : rows.length; |
| 96 | + } |
| 97 | + return row; |
| 98 | + }); |
| 99 | +} |
| 100 | + |
| 101 | +/** Two services that differ ONLY in whether a pending seed draft exists. */ |
| 102 | +function svc(preview: boolean) { |
| 103 | + return new AnalyticsService({ |
| 104 | + sourceFieldMeta, |
| 105 | + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), |
| 106 | + executeAggregate: async (_object: string, options: Record<string, unknown>) => evaluateAggregate(options), |
| 107 | + ...(preview ? { draftRowsResolver: async () => ROWS as Record<string, unknown>[] } : {}), |
| 108 | + }); |
| 109 | +} |
| 110 | + |
| 111 | +type Field = Awaited<ReturnType<AnalyticsService['queryDataset']>>['fields'][number]; |
| 112 | +const byName = (fields: Field[]) => Object.fromEntries(fields.map((f) => [f.name, f])); |
| 113 | + |
| 114 | +async function bothPaths(dataset = DATASET, selection: Record<string, unknown> = { |
| 115 | + dimensions: ['status'], |
| 116 | + measures: ['task_count', 'count', 'total_amount', 'open_share'], |
| 117 | +}) { |
| 118 | + const live = await svc(false).queryDataset(dataset, selection as never, CTX); |
| 119 | + const preview = await svc(true).queryDataset(dataset, selection as never, CTX, { previewDrafts: true }); |
| 120 | + return { live: byName(live.fields), preview: byName(preview.fields) }; |
| 121 | +} |
| 122 | + |
| 123 | +describe('fields[].aggregate — a dataset answer states each measure column\'s aggregate', () => { |
| 124 | + it('a labelled `count` measure states `count`, and an unlabelled one still carries builtinAggregate', async () => { |
| 125 | + const { live, preview } = await bothPaths(); |
| 126 | + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { |
| 127 | + // The showcase column: the author's label, and now the aggregate beside it. |
| 128 | + expect(fields.task_count?.label, path).toBe('Tasks'); |
| 129 | + expect(fields.task_count?.aggregate, path).toBe('count'); |
| 130 | + // `builtinAggregate` keeps its label-only meaning: absent under a label… |
| 131 | + expect(fields.task_count?.builtinAggregate, path).toBeUndefined(); |
| 132 | + // …and present, beside the new member, on the label-less default. |
| 133 | + expect(fields.count?.builtinAggregate, path).toBe('count'); |
| 134 | + expect(fields.count?.aggregate, path).toBe('count'); |
| 135 | + expect(fields.count?.label, path).toBeUndefined(); |
| 136 | + } |
| 137 | + }); |
| 138 | + |
| 139 | + it('a `sum` over a currency field states `sum`', async () => { |
| 140 | + const { live, preview } = await bothPaths(); |
| 141 | + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { |
| 142 | + expect(fields.total_amount?.aggregate, path).toBe('sum'); |
| 143 | + // The column's other descriptors are untouched by the new member. |
| 144 | + expect(fields.total_amount?.currency, path).toBe('USD'); |
| 145 | + expect(fields.total_amount?.format, path).toBe('$0,0'); |
| 146 | + expect(fields.total_amount?.type, path).toBe('number'); |
| 147 | + } |
| 148 | + }); |
| 149 | + |
| 150 | + it('the preview path states the same as the live path, column for column', async () => { |
| 151 | + const { live, preview } = await bothPaths(); |
| 152 | + expect(Object.keys(preview).sort()).toEqual(Object.keys(live).sort()); |
| 153 | + for (const name of Object.keys(live)) { |
| 154 | + expect(preview[name]?.aggregate, `column "${name}"`).toBe(live[name]?.aggregate); |
| 155 | + } |
| 156 | + }); |
| 157 | + |
| 158 | + it('is absent on a dimension column and on a derived measure, on both paths', async () => { |
| 159 | + const { live, preview } = await bothPaths(); |
| 160 | + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { |
| 161 | + expect(fields.status, path).toBeDefined(); |
| 162 | + expect(fields.status?.aggregate, path).toBeUndefined(); |
| 163 | + expect(fields.open_share, path).toBeDefined(); |
| 164 | + expect(fields.open_share?.aggregate, path).toBeUndefined(); |
| 165 | + } |
| 166 | + }); |
| 167 | + |
| 168 | + it('a derived measure that also declares a stray `aggregate` states none: the compiler ignores it', async () => { |
| 169 | + const stray = DatasetSchema.parse({ |
| 170 | + ...DATASET, |
| 171 | + name: 'task_ds_stray', |
| 172 | + measures: [ |
| 173 | + { name: 'task_count', aggregate: 'count', label: 'Tasks' }, |
| 174 | + { name: 'count', aggregate: 'count' }, |
| 175 | + { name: 'open_share', derived: { op: 'ratio', of: ['task_count', 'count'] }, aggregate: 'sum', label: 'Share' }, |
| 176 | + ], |
| 177 | + }); |
| 178 | + const { live, preview } = await bothPaths(stray, { dimensions: ['status'], measures: ['task_count', 'count', 'open_share'] }); |
| 179 | + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { |
| 180 | + expect(fields.open_share, path).toBeDefined(); |
| 181 | + expect(fields.open_share?.aggregate, path).toBeUndefined(); |
| 182 | + // Control: the base measures beside it are still described. |
| 183 | + expect(fields.task_count?.aggregate, path).toBe('count'); |
| 184 | + } |
| 185 | + }); |
| 186 | + |
| 187 | + it('a `__compare` column states its measure\'s aggregate, on both paths', async () => { |
| 188 | + const selection = { |
| 189 | + dimensions: ['status'], |
| 190 | + measures: ['task_count', 'total_amount'], |
| 191 | + timeDimensions: [{ dimension: 'due_on', dateRange: ['2026-02-01', '2026-02-28'] }], |
| 192 | + compareTo: { kind: 'previousPeriod', dimension: 'due_on' }, |
| 193 | + }; |
| 194 | + const { live, preview } = await bothPaths(DATASET, selection); |
| 195 | + for (const [path, fields] of [['live', live], ['preview', preview]] as const) { |
| 196 | + expect(fields.task_count__compare, path).toBeDefined(); |
| 197 | + expect(fields.task_count__compare?.aggregate, path).toBe('count'); |
| 198 | + expect(fields.total_amount__compare?.aggregate, path).toBe('sum'); |
| 199 | + } |
| 200 | + }); |
| 201 | +}); |
| 202 | + |
| 203 | +describe('fields[].aggregate — absent on a cube query answer', () => { |
| 204 | + it('`query()` (POST /analytics/query) never passes through the dataset column seam, so it states none', async () => { |
| 205 | + const service = new AnalyticsService({ |
| 206 | + cubes: [{ |
| 207 | + name: 'task_cube', |
| 208 | + title: 'Tasks', |
| 209 | + sql: 'task', |
| 210 | + measures: { task_count: { label: 'Tasks', type: 'count', sql: '*' } }, |
| 211 | + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, |
| 212 | + } as never], |
| 213 | + sourceFieldMeta, |
| 214 | + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), |
| 215 | + executeAggregate: async (_object: string, options: Record<string, unknown>) => evaluateAggregate(options), |
| 216 | + }); |
| 217 | + const result = await service.query( |
| 218 | + { cube: 'task_cube', measures: ['task_count'], dimensions: ['status'] }, |
| 219 | + CTX, |
| 220 | + ); |
| 221 | + const measure = result.fields.find((f) => f.name === 'task_count'); |
| 222 | + expect(measure).toBeDefined(); |
| 223 | + expect(measure?.aggregate).toBeUndefined(); |
| 224 | + }); |
| 225 | +}); |
0 commit comments