Skip to content

Commit 83132ec

Browse files
committed
feat(protocol): implement analytics query and metadata generation for cubes
1 parent 33f4b00 commit 83132ec

2 files changed

Lines changed: 186 additions & 9 deletions

File tree

packages/objectql/src/engine.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -608,10 +608,22 @@ export class ObjectQL implements IDataEngine {
608608
object = this.resolveObjectName(object);
609609
const driver = this.getDriver(object);
610610
this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query);
611-
// Driver needs support for raw aggregation or mapped aggregation
612-
// For now, if driver supports 'execute', we might pass it down, or we need to add 'aggregate' to DriverInterface
613-
// In this version, we'll assume driver might handle it via special 'find' or throw not implemented
614-
throw new Error('Aggregate not yet fully implemented in ObjectQL->Driver mapping');
611+
612+
// Build a QueryAST with groupBy and aggregations, delegate to driver.find()
613+
// Drivers that support aggregation (e.g. InMemoryDriver) handle groupBy/aggregations
614+
// in their find() implementation via performAggregation().
615+
const ast: QueryAST = {
616+
object,
617+
where: query.filter,
618+
groupBy: query.groupBy,
619+
aggregations: query.aggregations?.map(agg => ({
620+
function: agg.method,
621+
field: agg.field,
622+
alias: agg.alias || `${agg.method}_${agg.field || 'all'}`,
623+
})),
624+
};
625+
626+
return driver.find(object, ast);
615627
}
616628

617629
async execute(command: any, options?: Record<string, any>): Promise<any> {

packages/objectql/src/protocol.ts

Lines changed: 170 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
4343
search: false,
4444
websockets: false,
4545
files: true,
46-
analytics: false,
46+
analytics: true,
4747
ai: false,
4848
workflow: false,
4949
notifications: false,
@@ -394,12 +394,177 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
394394
} as BatchUpdateResponse;
395395
}
396396

397-
async analyticsQuery(_request: any): Promise<any> {
398-
throw new Error('analyticsQuery requires plugin-analytics service. Install and register a plugin that provides the "analytics" service.');
397+
async analyticsQuery(request: any): Promise<any> {
398+
// Map AnalyticsQuery (cube-style) to engine aggregation.
399+
// cube name maps to object name; measures → aggregations; dimensions → groupBy.
400+
const { query, cube } = request;
401+
const object = cube;
402+
403+
// Build groupBy from dimensions
404+
const groupBy = query.dimensions || [];
405+
406+
// Build aggregations from measures
407+
// Measures can be simple field names like "count" or "field_name.sum"
408+
// Or cube-defined measure names. We support: field.function or just function(field).
409+
const aggregations: Array<{ field: string; method: string; alias: string }> = [];
410+
if (query.measures) {
411+
for (const measure of query.measures) {
412+
// Support formats: "count", "amount.sum", "revenue.avg"
413+
if (measure === 'count' || measure === 'count_all') {
414+
aggregations.push({ field: '*', method: 'count', alias: 'count' });
415+
} else if (measure.includes('.')) {
416+
const [field, method] = measure.split('.');
417+
aggregations.push({ field, method, alias: `${field}_${method}` });
418+
} else {
419+
// Treat as count of the field
420+
aggregations.push({ field: measure, method: 'sum', alias: measure });
421+
}
422+
}
423+
}
424+
425+
// Build filter from analytics filters
426+
let filter: any = undefined;
427+
if (query.filters && query.filters.length > 0) {
428+
const conditions: any[] = query.filters.map((f: any) => {
429+
const op = this.mapAnalyticsOperator(f.operator);
430+
if (f.values && f.values.length === 1) {
431+
return { [f.member]: { [op]: f.values[0] } };
432+
} else if (f.values && f.values.length > 1) {
433+
return { [f.member]: { $in: f.values } };
434+
}
435+
return { [f.member]: { [op]: true } };
436+
});
437+
filter = conditions.length === 1 ? conditions[0] : { $and: conditions };
438+
}
439+
440+
// Execute via engine.aggregate (which delegates to driver.find with groupBy/aggregations)
441+
const rows = await this.engine.aggregate(object, {
442+
filter,
443+
groupBy: groupBy.length > 0 ? groupBy : undefined,
444+
aggregations: aggregations.length > 0
445+
? aggregations.map(a => ({ field: a.field, method: a.method as any, alias: a.alias }))
446+
: [{ field: '*', method: 'count' as any, alias: 'count' }],
447+
});
448+
449+
// Build field metadata
450+
const fields = [
451+
...groupBy.map((d: string) => ({ name: d, type: 'string' })),
452+
...aggregations.map(a => ({ name: a.alias, type: 'number' })),
453+
];
454+
455+
return {
456+
success: true,
457+
data: {
458+
rows,
459+
fields,
460+
},
461+
};
399462
}
400463

401-
async getAnalyticsMeta(_request: any): Promise<any> {
402-
throw new Error('getAnalyticsMeta requires plugin-analytics service. Install and register a plugin that provides the "analytics" service.');
464+
async getAnalyticsMeta(request: any): Promise<any> {
465+
// Auto-generate cube metadata from registered objects in SchemaRegistry.
466+
// Each object becomes a cube; number fields → measures; other fields → dimensions.
467+
const objects = SchemaRegistry.listItems('object');
468+
const cubeFilter = request?.cube;
469+
470+
const cubes: any[] = [];
471+
for (const obj of objects) {
472+
const schema = obj as any;
473+
if (cubeFilter && schema.name !== cubeFilter) continue;
474+
475+
const measures: Record<string, any> = {};
476+
const dimensions: Record<string, any> = {};
477+
const fields = schema.fields || {};
478+
479+
// Always add a count measure
480+
measures['count'] = {
481+
name: 'count',
482+
label: 'Count',
483+
type: 'count',
484+
sql: '*',
485+
};
486+
487+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
488+
const fd = fieldDef as any;
489+
const fieldType = fd.type || 'text';
490+
491+
if (['number', 'currency', 'percent'].includes(fieldType)) {
492+
// Numeric fields become both measures and dimensions
493+
measures[`${fieldName}_sum`] = {
494+
name: `${fieldName}_sum`,
495+
label: `${fd.label || fieldName} (Sum)`,
496+
type: 'sum',
497+
sql: fieldName,
498+
};
499+
measures[`${fieldName}_avg`] = {
500+
name: `${fieldName}_avg`,
501+
label: `${fd.label || fieldName} (Avg)`,
502+
type: 'avg',
503+
sql: fieldName,
504+
};
505+
dimensions[fieldName] = {
506+
name: fieldName,
507+
label: fd.label || fieldName,
508+
type: 'number',
509+
sql: fieldName,
510+
};
511+
} else if (['date', 'datetime'].includes(fieldType)) {
512+
dimensions[fieldName] = {
513+
name: fieldName,
514+
label: fd.label || fieldName,
515+
type: 'time',
516+
sql: fieldName,
517+
granularities: ['day', 'week', 'month', 'quarter', 'year'],
518+
};
519+
} else if (['boolean'].includes(fieldType)) {
520+
dimensions[fieldName] = {
521+
name: fieldName,
522+
label: fd.label || fieldName,
523+
type: 'boolean',
524+
sql: fieldName,
525+
};
526+
} else {
527+
// text, select, lookup, etc. → dimension
528+
dimensions[fieldName] = {
529+
name: fieldName,
530+
label: fd.label || fieldName,
531+
type: 'string',
532+
sql: fieldName,
533+
};
534+
}
535+
}
536+
537+
cubes.push({
538+
name: schema.name,
539+
title: schema.label || schema.name,
540+
description: schema.description,
541+
sql: schema.name,
542+
measures,
543+
dimensions,
544+
public: true,
545+
});
546+
}
547+
548+
return {
549+
success: true,
550+
data: { cubes },
551+
};
552+
}
553+
554+
private mapAnalyticsOperator(op: string): string {
555+
const map: Record<string, string> = {
556+
equals: '$eq',
557+
notEquals: '$ne',
558+
contains: '$contains',
559+
notContains: '$notContains',
560+
gt: '$gt',
561+
gte: '$gte',
562+
lt: '$lt',
563+
lte: '$lte',
564+
set: '$ne',
565+
notSet: '$eq',
566+
};
567+
return map[op] || '$eq';
403568
}
404569

405570
async triggerAutomation(_request: any): Promise<any> {

0 commit comments

Comments
 (0)