@@ -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