Skip to content

Commit 52c9f0b

Browse files
committed
feat: support measure aliases in analytics (field_type pattern)
1 parent 9c40ba9 commit 52c9f0b

4 files changed

Lines changed: 82 additions & 7 deletions

File tree

packages/objectql/src/engine.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,6 +1564,14 @@ export class ObjectQL implements IDataEngine {
15641564
aggregations: query.aggregations,
15651565
};
15661566

1567+
// Prefer driver.aggregate() when available — driver.find() in many
1568+
// drivers (e.g. driver-sql) does not honor `groupBy` / `aggregations`
1569+
// and would silently return ungrouped raw rows. Fall back to find()
1570+
// for drivers that handle aggregations through their query AST.
1571+
const drv = driver as any;
1572+
if (typeof drv.aggregate === 'function') {
1573+
return drv.aggregate(object, ast);
1574+
}
15671575
return driver.find(object, ast);
15681576
});
15691577

packages/plugins/driver-memory/src/memory-analytics.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,35 @@ describe('MemoryAnalyticsService', () => {
264264
expect(result.rows[1]['orders.customer']).toBe('Charlie');
265265
});
266266

267+
it('should resolve `${field}_${type}` aliases to canonical measures', async () => {
268+
// Cubes commonly define measures keyed by field name (e.g. measure
269+
// 'amount' of type 'sum'). Clients that build measure names from
270+
// (field, function) pairs send 'amount_sum' — the resolver should
271+
// accept that alias and produce the same aggregate value.
272+
const aliasCube: Cube = {
273+
name: 'opps',
274+
title: 'Opps',
275+
sql: 'orders',
276+
measures: {
277+
amount: { name: 'amount', label: 'Amount', type: 'sum', sql: 'amount' },
278+
},
279+
dimensions: {
280+
status: { name: 'status', label: 'Status', type: 'string', sql: 'status' },
281+
},
282+
};
283+
const aliasService = new MemoryAnalyticsService({ driver, cubes: [aliasCube] });
284+
285+
const aliased = await aliasService.query({
286+
cube: 'opps',
287+
measures: ['amount_sum'],
288+
dimensions: ['status'],
289+
});
290+
291+
const completed = aliased.rows.find(r => r.status === 'completed');
292+
expect(completed).toBeDefined();
293+
expect(completed!['amount_sum']).toBe(600); // 100 + 200 + 300
294+
});
295+
267296
it('should throw error for unknown cube', async () => {
268297
await expect(async () => {
269298
await service.query({

packages/plugins/driver-memory/src/memory-analytics.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,25 @@ export class MemoryAnalyticsService implements IAnalyticsService {
363363
private resolveMeasure(cube: Cube, measureName: string) {
364364
const parts = measureName.split('.');
365365
const fieldName = parts.length > 1 ? parts[1] : parts[0];
366-
return cube.measures[fieldName];
366+
const direct = cube.measures[fieldName];
367+
if (direct) return direct;
368+
369+
// Accept `${field}_${type}` aliases (e.g. 'amount_sum') for measures whose
370+
// canonical name is just `${field}` (e.g. measure 'amount' of type 'sum').
371+
// This matches the convention used by the data-objectstack adapter and
372+
// other clients that build measure names from (field, function) pairs.
373+
const aggTypes = ['count', 'sum', 'avg', 'min', 'max', 'count_distinct'];
374+
for (const type of aggTypes) {
375+
const suffix = `_${type}`;
376+
if (fieldName.endsWith(suffix)) {
377+
const baseField = fieldName.slice(0, -suffix.length);
378+
const candidate = cube.measures[baseField];
379+
if (candidate && candidate.type === type) {
380+
return candidate;
381+
}
382+
}
383+
}
384+
return undefined;
367385
}
368386

369387
private resolveDimension(cube: Cube, dimensionName: string) {

packages/services/service-analytics/src/strategies/objectql-strategy.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,32 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
130130

131131
private resolveMeasureAggregation(cube: Cube, measureName: string): { field: string; method: string } {
132132
const fieldName = measureName.includes('.') ? measureName.split('.')[1] : measureName;
133-
const measure = cube.measures[fieldName];
134-
if (!measure) return { field: '*', method: 'count' };
135-
return {
136-
field: measure.sql.replace(/^\$/, ''),
137-
method: measure.type === 'count_distinct' ? 'count_distinct' : measure.type,
138-
};
133+
const direct = cube.measures[fieldName];
134+
if (direct) {
135+
return {
136+
field: direct.sql.replace(/^\$/, ''),
137+
method: direct.type === 'count_distinct' ? 'count_distinct' : direct.type,
138+
};
139+
}
140+
// Accept `${field}_${type}` aliases (e.g. 'amount_sum') for measures whose
141+
// canonical name is just `${field}` (e.g. measure 'amount' of type 'sum').
142+
// This matches the convention used by clients that build measure names
143+
// from (field, function) pairs (e.g. the data-objectstack adapter).
144+
const aggTypes = ['count', 'sum', 'avg', 'min', 'max', 'count_distinct'];
145+
for (const type of aggTypes) {
146+
const suffix = `_${type}`;
147+
if (fieldName.endsWith(suffix)) {
148+
const baseField = fieldName.slice(0, -suffix.length);
149+
const candidate = cube.measures[baseField];
150+
if (candidate && candidate.type === type) {
151+
return {
152+
field: candidate.sql.replace(/^\$/, ''),
153+
method: candidate.type === 'count_distinct' ? 'count_distinct' : candidate.type,
154+
};
155+
}
156+
}
157+
}
158+
return { field: '*', method: 'count' };
139159
}
140160

141161
private convertFilter(operator: string, values?: string[]): unknown {

0 commit comments

Comments
 (0)