@@ -22,8 +22,8 @@ pub struct AggregateArgs {
2222 pub query : String ,
2323 pub from : String ,
2424 pub to : String ,
25- pub compute : String ,
26- pub group_by : Option < String > ,
25+ pub compute : Vec < String > ,
26+ pub group_by : Vec < String > ,
2727 pub limit : i32 ,
2828 pub storage : Option < String > ,
2929}
@@ -56,6 +56,39 @@ fn parse_storage_tier(storage: Option<String>) -> Result<Option<LogsStorageTier>
5656 }
5757}
5858
59+ /// Split a comma-separated compute string into individual compute expressions,
60+ /// respecting parentheses so that `percentile(@duration, 95)` is not split.
61+ pub fn split_compute_args ( input : & str ) -> Vec < String > {
62+ let mut result = Vec :: new ( ) ;
63+ let mut current = String :: new ( ) ;
64+ let mut depth = 0u32 ;
65+ for ch in input. chars ( ) {
66+ match ch {
67+ '(' => {
68+ depth += 1 ;
69+ current. push ( ch) ;
70+ }
71+ ')' => {
72+ depth = depth. saturating_sub ( 1 ) ;
73+ current. push ( ch) ;
74+ }
75+ ',' if depth == 0 => {
76+ let trimmed = current. trim ( ) . to_string ( ) ;
77+ if !trimmed. is_empty ( ) {
78+ result. push ( trimmed) ;
79+ }
80+ current. clear ( ) ;
81+ }
82+ _ => current. push ( ch) ,
83+ }
84+ }
85+ let trimmed = current. trim ( ) . to_string ( ) ;
86+ if !trimmed. is_empty ( ) {
87+ result. push ( trimmed) ;
88+ }
89+ result
90+ }
91+
5992fn parse_compute_raw ( input : & str ) -> Result < ( String , Option < String > ) > {
6093 let input = input. trim ( ) ;
6194 if input. is_empty ( ) {
@@ -110,12 +143,11 @@ fn build_aggregate_body(
110143 query : String ,
111144 from_ms : i64 ,
112145 to_ms : i64 ,
113- compute : String ,
114- group_by : Option < String > ,
146+ computes : Vec < String > ,
147+ group_bys : Vec < String > ,
115148 limit : i32 ,
116149 storage : Option < String > ,
117150) -> Result < serde_json:: Value > {
118- let ( aggregation, metric) = parse_compute_raw ( & compute) ?;
119151 let storage_tier = normalize_storage_tier ( storage) ?;
120152
121153 let mut filter = serde_json:: json!( {
@@ -127,22 +159,35 @@ fn build_aggregate_body(
127159 filter[ "storage_tier" ] = serde_json:: Value :: String ( tier) ;
128160 }
129161
130- let mut compute_obj = serde_json:: json!( { "aggregation" : aggregation } ) ;
131- if let Some ( metric) = metric {
132- compute_obj[ "metric" ] = serde_json:: Value :: String ( metric) ;
133- }
162+ let compute_arr: Vec < serde_json:: Value > = computes
163+ . iter ( )
164+ . map ( |c| {
165+ let ( aggregation, metric) = parse_compute_raw ( c) ?;
166+ let mut obj = serde_json:: json!( { "aggregation" : aggregation } ) ;
167+ if let Some ( m) = metric {
168+ obj[ "metric" ] = serde_json:: Value :: String ( m) ;
169+ }
170+ Ok ( obj)
171+ } )
172+ . collect :: < Result < Vec < _ > > > ( ) ?;
134173
135174 let mut body = serde_json:: json!( {
136175 "filter" : filter,
137- "compute" : [ compute_obj ]
176+ "compute" : compute_arr
138177 } ) ;
139178
140- if let Some ( facet) = group_by {
141- let mut group_by_obj = serde_json:: json!( { "facet" : facet } ) ;
142- if limit > 0 {
143- group_by_obj[ "limit" ] = serde_json:: json!( limit) ;
144- }
145- body[ "group_by" ] = serde_json:: json!( [ group_by_obj] ) ;
179+ if !group_bys. is_empty ( ) {
180+ let group_by_arr: Vec < serde_json:: Value > = group_bys
181+ . iter ( )
182+ . map ( |facet| {
183+ let mut obj = serde_json:: json!( { "facet" : facet } ) ;
184+ if limit > 0 {
185+ obj[ "limit" ] = serde_json:: json!( limit) ;
186+ }
187+ obj
188+ } )
189+ . collect ( ) ;
190+ body[ "group_by" ] = serde_json:: json!( group_by_arr) ;
146191 }
147192
148193 Ok ( body)
@@ -285,11 +330,14 @@ pub async fn aggregate(cfg: &Config, args: AggregateArgs) -> Result<()> {
285330 query,
286331 from,
287332 to,
288- compute,
333+ mut compute,
289334 group_by,
290335 limit,
291336 storage,
292337 } = args;
338+ if compute. is_empty ( ) {
339+ compute. push ( "count" . into ( ) ) ;
340+ }
293341 let from_ms = util:: parse_time_to_unix_millis ( & from) ?;
294342 let to_ms = util:: parse_time_to_unix_millis ( & to) ?;
295343 let body = build_aggregate_body ( query, from_ms, to_ms, compute, group_by, limit, storage) ?;
@@ -304,11 +352,14 @@ pub async fn aggregate(cfg: &Config, args: AggregateArgs) -> Result<()> {
304352 query,
305353 from,
306354 to,
307- compute,
355+ mut compute,
308356 group_by,
309357 limit,
310358 storage,
311359 } = args;
360+ if compute. is_empty ( ) {
361+ compute. push ( "count" . into ( ) ) ;
362+ }
312363 let from_ms = util:: parse_time_to_unix_millis ( & from) ?;
313364 let to_ms = util:: parse_time_to_unix_millis ( & to) ?;
314365 let body = build_aggregate_body ( query, from_ms, to_ms, compute, group_by, limit, storage) ?;
@@ -626,7 +677,7 @@ mod tests {
626677
627678 #[ test]
628679 fn test_parse_compute_unsupported_percentile ( ) {
629- assert ! ( parse_compute_raw( "percentile(@duration, 50 )" ) . is_err( ) ) ;
680+ assert ! ( parse_compute_raw( "percentile(@duration, 42 )" ) . is_err( ) ) ;
630681 }
631682
632683 #[ test]
@@ -652,8 +703,8 @@ mod tests {
652703 "service:web" . into ( ) ,
653704 1 ,
654705 2 ,
655- "avg(@duration)" . into ( ) ,
656- Some ( "service" . into ( ) ) ,
706+ vec ! [ "avg(@duration)" . into( ) ] ,
707+ vec ! [ "service" . into( ) ] ,
657708 3 ,
658709 Some ( "flex" . into ( ) ) ,
659710 )
@@ -682,7 +733,8 @@ mod tests {
682733
683734 #[ test]
684735 fn test_build_aggregate_body_omits_group_by_for_plain_count ( ) {
685- let body = build_aggregate_body ( "*" . into ( ) , 1 , 2 , "count" . into ( ) , None , 10 , None ) . unwrap ( ) ;
736+ let body =
737+ build_aggregate_body ( "*" . into ( ) , 1 , 2 , vec ! [ "count" . into( ) ] , vec ! [ ] , 10 , None ) . unwrap ( ) ;
686738
687739 assert_eq ! (
688740 body,
@@ -698,4 +750,102 @@ mod tests {
698750 } )
699751 ) ;
700752 }
753+
754+ #[ test]
755+ fn test_build_aggregate_body_multiple_computes ( ) {
756+ let body = build_aggregate_body (
757+ "*" . into ( ) ,
758+ 1 ,
759+ 2 ,
760+ vec ! [
761+ "count" . into( ) ,
762+ "avg(@duration)" . into( ) ,
763+ "percentile(@duration, 95)" . into( ) ,
764+ ] ,
765+ vec ! [ ] ,
766+ 10 ,
767+ None ,
768+ )
769+ . unwrap ( ) ;
770+
771+ assert_eq ! (
772+ body,
773+ serde_json:: json!( {
774+ "filter" : {
775+ "query" : "*" ,
776+ "from" : "1" ,
777+ "to" : "2"
778+ } ,
779+ "compute" : [
780+ { "aggregation" : "count" } ,
781+ { "aggregation" : "avg" , "metric" : "@duration" } ,
782+ { "aggregation" : "pc95" , "metric" : "@duration" }
783+ ]
784+ } )
785+ ) ;
786+ }
787+
788+ #[ test]
789+ fn test_build_aggregate_body_multiple_group_bys ( ) {
790+ let body = build_aggregate_body (
791+ "*" . into ( ) ,
792+ 1 ,
793+ 2 ,
794+ vec ! [ "count" . into( ) ] ,
795+ vec ! [ "service" . into( ) , "status" . into( ) ] ,
796+ 5 ,
797+ None ,
798+ )
799+ . unwrap ( ) ;
800+
801+ assert_eq ! (
802+ body,
803+ serde_json:: json!( {
804+ "filter" : {
805+ "query" : "*" ,
806+ "from" : "1" ,
807+ "to" : "2"
808+ } ,
809+ "compute" : [ { "aggregation" : "count" } ] ,
810+ "group_by" : [
811+ { "facet" : "service" , "limit" : 5 } ,
812+ { "facet" : "status" , "limit" : 5 }
813+ ]
814+ } )
815+ ) ;
816+ }
817+
818+ #[ test]
819+ fn test_split_compute_args_single ( ) {
820+ assert_eq ! ( split_compute_args( "count" ) , vec![ "count" ] ) ;
821+ }
822+
823+ #[ test]
824+ fn test_split_compute_args_multiple ( ) {
825+ assert_eq ! (
826+ split_compute_args( "count,avg(@duration),max(@duration)" ) ,
827+ vec![ "count" , "avg(@duration)" , "max(@duration)" ]
828+ ) ;
829+ }
830+
831+ #[ test]
832+ fn test_split_compute_args_preserves_parens_with_comma ( ) {
833+ assert_eq ! (
834+ split_compute_args( "count,percentile(@duration, 95)" ) ,
835+ vec![ "count" , "percentile(@duration, 95)" ]
836+ ) ;
837+ }
838+
839+ #[ test]
840+ fn test_split_compute_args_trims_whitespace ( ) {
841+ assert_eq ! (
842+ split_compute_args( " count , avg(@duration) " ) ,
843+ vec![ "count" , "avg(@duration)" ]
844+ ) ;
845+ }
846+
847+ #[ test]
848+ fn test_split_compute_args_empty ( ) {
849+ assert ! ( split_compute_args( "" ) . is_empty( ) ) ;
850+ }
701851}
0 commit comments