Skip to content

Commit bb317b4

Browse files
Copilothotlong
andcommitted
fix: send measures as string array in aggregate() to match backend analytics API format
The backend MemoryAnalyticsService.resolveMeasure() expects measure names as strings (e.g. 'amount_sum', 'count') and calls .split('.') on them. Sending object arrays ({field, function}) caused TypeError: t.split is not a function. Changes: - Convert measures from [{field, function}] to string format: 'count' for count aggregation, '${field}_${function}' for others (e.g. 'amount_sum') - Send empty dimensions array when groupBy is '_all' (single-bucket) - Map response measure keys back to original field names for consumers - Add 10 unit tests covering payload format, response mapping, and fallback Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/7e803790-d91b-4542-886e-743490f0dfc6 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 32814a7 commit bb317b4

3 files changed

Lines changed: 252 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- **Analytics aggregate measures format** (`@object-ui/data-objectstack`): Fixed `aggregate()` method to send `measures` as string array (`['amount_sum']`, `['count']`) instead of object array (`[{ field, function }]`). The backend `MemoryAnalyticsService.resolveMeasure()` expects strings and calls `.split('.')`, causing `TypeError: t.split is not a function` when receiving objects. Also fixed `dimensions` to send an empty array when `groupBy` is `'_all'` (single-bucket aggregation), and added response mapping to rename measure keys (e.g. `amount_sum`) back to the original field name (`amount`) for consumer compatibility.
1213
- **Fields SSR build** (`@object-ui/fields`): Added `@object-ui/i18n` to Vite `external` in `vite.config.ts` and converted to regex-based externalization pattern (consistent with `@object-ui/components`) to prevent `react-i18next` CJS code from being bundled. Fixes `"dynamic usage of require is not supported"` error during Next.js SSR prerendering of `/docs/components/basic/text`.
1314
- **Console build** (`@object-ui/console`): Added missing `@object-ui/plugin-chatbot` devDependency that caused `TS2307: Cannot find module '@object-ui/plugin-chatbot'` during build.
1415
- **Site SSR build** (`@object-ui/site`): Added `@object-ui/i18n` to `transpilePackages` in `next.config.mjs` to fix "dynamic usage of require is not supported" error when prerendering the tooltip docs page. The i18n package is a transitive dependency of `@object-ui/react` and its `react-i18next` dependency requires transpilation for Turbopack SSR compatibility.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, beforeEach, vi } from 'vitest';
10+
import { ObjectStackAdapter } from './index';
11+
12+
/**
13+
* Tests for ObjectStackAdapter.aggregate() — verifies that the analytics
14+
* query payload uses the correct string-based measure/dimension format
15+
* expected by the backend analytics service (MemoryAnalyticsService).
16+
*
17+
* See: https://github.com/objectstack-ai/objectui/issues (measures format bug)
18+
*/
19+
describe('ObjectStackAdapter aggregate()', () => {
20+
let adapter: ObjectStackAdapter;
21+
let mockAnalyticsQuery: ReturnType<typeof vi.fn>;
22+
23+
beforeEach(() => {
24+
mockAnalyticsQuery = vi.fn().mockResolvedValue({ data: [] });
25+
26+
adapter = new ObjectStackAdapter({
27+
baseUrl: 'http://localhost:3000',
28+
autoReconnect: false,
29+
});
30+
31+
// Inject mock client and mark as connected to bypass connect()
32+
(adapter as any).client = {
33+
data: {
34+
find: vi.fn().mockResolvedValue({ records: [], total: 0 }),
35+
},
36+
analytics: {
37+
query: mockAnalyticsQuery,
38+
},
39+
connect: vi.fn().mockResolvedValue(undefined),
40+
discover: vi.fn().mockResolvedValue({ status: 'ok' }),
41+
};
42+
(adapter as any).connected = true;
43+
});
44+
45+
it('should send measures as string array with field_function format for sum', async () => {
46+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
47+
48+
await adapter.aggregate('opportunity', {
49+
field: 'amount',
50+
function: 'sum',
51+
groupBy: 'stage',
52+
});
53+
54+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
55+
cube: 'opportunity',
56+
measures: ['amount_sum'],
57+
dimensions: ['stage'],
58+
});
59+
});
60+
61+
it('should send measures as ["count"] for count aggregation', async () => {
62+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
63+
64+
await adapter.aggregate('opportunity', {
65+
field: 'amount',
66+
function: 'count',
67+
groupBy: 'stage',
68+
});
69+
70+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
71+
cube: 'opportunity',
72+
measures: ['count'],
73+
dimensions: ['stage'],
74+
});
75+
});
76+
77+
it('should send measures as string for avg aggregation', async () => {
78+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
79+
80+
await adapter.aggregate('opportunity', {
81+
field: 'amount',
82+
function: 'avg',
83+
groupBy: 'stage',
84+
});
85+
86+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
87+
cube: 'opportunity',
88+
measures: ['amount_avg'],
89+
dimensions: ['stage'],
90+
});
91+
});
92+
93+
it('should send empty dimensions when groupBy is _all', async () => {
94+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
95+
96+
await adapter.aggregate('opportunity', {
97+
field: 'amount',
98+
function: 'sum',
99+
groupBy: '_all',
100+
});
101+
102+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
103+
cube: 'opportunity',
104+
measures: ['amount_sum'],
105+
dimensions: [],
106+
});
107+
});
108+
109+
it('should include filters in payload when provided', async () => {
110+
const filter = [{ member: 'stage', operator: 'equals', values: ['Closed Won'] }];
111+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
112+
113+
await adapter.aggregate('opportunity', {
114+
field: 'amount',
115+
function: 'sum',
116+
groupBy: 'stage',
117+
filter,
118+
});
119+
120+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
121+
cube: 'opportunity',
122+
measures: ['amount_sum'],
123+
dimensions: ['stage'],
124+
filters: filter,
125+
});
126+
});
127+
128+
it('should map measure key back to field name in response', async () => {
129+
mockAnalyticsQuery.mockResolvedValue({
130+
data: [
131+
{ stage: 'Prospect', amount_sum: 300 },
132+
{ stage: 'Closed Won', amount_sum: 500 },
133+
],
134+
});
135+
136+
const result = await adapter.aggregate('opportunity', {
137+
field: 'amount',
138+
function: 'sum',
139+
groupBy: 'stage',
140+
});
141+
142+
expect(result).toEqual([
143+
{ stage: 'Prospect', amount: 300 },
144+
{ stage: 'Closed Won', amount: 500 },
145+
]);
146+
});
147+
148+
it('should map count measure back to field name in response', async () => {
149+
mockAnalyticsQuery.mockResolvedValue({
150+
data: [
151+
{ stage: 'Prospect', count: 5 },
152+
{ stage: 'Closed Won', count: 3 },
153+
],
154+
});
155+
156+
const result = await adapter.aggregate('opportunity', {
157+
field: 'amount',
158+
function: 'count',
159+
groupBy: 'stage',
160+
});
161+
162+
expect(result).toEqual([
163+
{ stage: 'Prospect', amount: 5 },
164+
{ stage: 'Closed Won', amount: 3 },
165+
]);
166+
});
167+
168+
it('should handle direct array response from analytics', async () => {
169+
mockAnalyticsQuery.mockResolvedValue([
170+
{ stage: 'Prospect', amount_sum: 300 },
171+
]);
172+
173+
const result = await adapter.aggregate('opportunity', {
174+
field: 'amount',
175+
function: 'sum',
176+
groupBy: 'stage',
177+
});
178+
179+
expect(result).toEqual([
180+
{ stage: 'Prospect', amount: 300 },
181+
]);
182+
});
183+
184+
it('should handle results wrapper in response', async () => {
185+
mockAnalyticsQuery.mockResolvedValue({
186+
results: [
187+
{ stage: 'Prospect', amount_avg: 150 },
188+
],
189+
});
190+
191+
const result = await adapter.aggregate('opportunity', {
192+
field: 'amount',
193+
function: 'avg',
194+
groupBy: 'stage',
195+
});
196+
197+
expect(result).toEqual([
198+
{ stage: 'Prospect', amount: 150 },
199+
]);
200+
});
201+
202+
it('should fall back to client-side aggregation when analytics endpoint fails', async () => {
203+
mockAnalyticsQuery.mockRejectedValue(new Error('Analytics not available'));
204+
205+
// Mock find() to return records for client-side aggregation
206+
(adapter as any).client.data.find = vi.fn().mockResolvedValue({
207+
records: [
208+
{ stage: 'Prospect', amount: 100 },
209+
{ stage: 'Prospect', amount: 200 },
210+
{ stage: 'Closed Won', amount: 500 },
211+
],
212+
total: 3,
213+
});
214+
215+
const result = await adapter.aggregate('opportunity', {
216+
field: 'amount',
217+
function: 'sum',
218+
groupBy: 'stage',
219+
});
220+
221+
expect(result).toHaveLength(2);
222+
expect(result.find((r: any) => r.stage === 'Prospect')?.amount).toBe(300);
223+
expect(result.find((r: any) => r.stage === 'Closed Won')?.amount).toBe(500);
224+
});
225+
});

packages/data-objectstack/src/index.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -829,20 +829,40 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
829829
await this.connect();
830830

831831
try {
832+
// Build measure name in the format expected by the backend analytics
833+
// service (memory-analytics / cube). For 'count' the measure key is
834+
// simply 'count'; for other aggregation functions it follows the
835+
// convention `${field}_${function}` (e.g. 'amount_sum').
836+
const measureName = params.function === 'count'
837+
? 'count'
838+
: `${params.field}_${params.function}`;
839+
832840
const payload: Record<string, unknown> = {
833841
cube: resource,
834-
measures: [{ field: params.field, function: params.function }],
835-
dimensions: [params.groupBy],
842+
measures: [measureName],
843+
// When groupBy is '_all' no dimensions are needed (single-bucket).
844+
dimensions: params.groupBy && params.groupBy !== '_all' ? [params.groupBy] : [],
836845
};
837846
if (params.filter) {
838847
payload.filters = params.filter;
839848
}
840849

841850
const data = await this.client.analytics.query(payload);
842-
if (Array.isArray(data)) return data;
843-
if (data?.data && Array.isArray(data.data)) return data.data;
844-
if (data?.results && Array.isArray(data.results)) return data.results;
845-
return [];
851+
const rawRows: any[] = Array.isArray(data) ? data
852+
: data?.data && Array.isArray(data.data) ? data.data
853+
: data?.results && Array.isArray(data.results) ? data.results
854+
: [];
855+
856+
// Map measure keys back to the original field name so that consumers
857+
// (ObjectChart, DashboardRenderer, etc.) can access values by field name.
858+
return rawRows.map((row: any) => {
859+
const mapped = { ...row };
860+
if (measureName !== params.field && measureName in mapped) {
861+
mapped[params.field] = mapped[measureName];
862+
delete mapped[measureName];
863+
}
864+
return mapped;
865+
});
846866
} catch {
847867
// If the analytics endpoint is not available, fall back to
848868
// find() + client-side aggregation

0 commit comments

Comments
 (0)