Skip to main content

fraiseql_core/runtime/
aggregate_projector.rs

1//! Aggregation Result Projector
2//!
3//! Projects SQL aggregate results to GraphQL JSON responses.
4//!
5//! # SQL Result Format
6//!
7//! SQL returns rows as `Vec<HashMap<String, Value>>`:
8//! ```json
9//! [
10//!   {
11//!     "category": "Electronics",
12//!     "occurred_at_day": "2025-01-01T00:00:00Z",
13//!     "count": 42,
14//!     "revenue_sum": 5280.50,
15//!     "revenue_avg": 125.73
16//!   }
17//! ]
18//! ```
19//!
20//! # GraphQL Response Format
21//!
22//! Projected to GraphQL response:
23//! ```json
24//! {
25//!   "data": {
26//!     "sales_aggregate": [
27//!       {
28//!         "category": "Electronics",
29//!         "occurred_at_day": "2025-01-01T00:00:00Z",
30//!         "count": 42,
31//!         "revenue_sum": 5280.50,
32//!         "revenue_avg": 125.73
33//!       }
34//!     ]
35//!   }
36//! }
37//! ```
38
39use std::collections::HashMap;
40
41use serde_json::{Value, json};
42
43use crate::{compiler::aggregation::AggregationPlan, error::Result};
44
45/// Aggregation result projector
46pub struct AggregationProjector;
47
48impl AggregationProjector {
49    /// Project SQL aggregate results to GraphQL JSON.
50    ///
51    /// # Arguments
52    ///
53    /// * `rows` - SQL result rows as HashMaps
54    /// * `plan` - Aggregation execution plan (for metadata)
55    ///
56    /// # Returns
57    ///
58    /// GraphQL-compatible JSON response
59    ///
60    /// # Example
61    ///
62    /// ```rust,ignore
63    /// let rows = vec![
64    ///     hashmap!{
65    ///         "category" => json!("Electronics"),
66    ///         "count" => json!(42),
67    ///         "revenue_sum" => json!(5280.50)
68    ///     }
69    /// ];
70    ///
71    /// let result = AggregationProjector::project(rows, &plan)?;
72    /// // result: [{"category": "Electronics", "count": 42, "revenue_sum": 5280.50}]
73    /// ```
74    pub fn project(rows: Vec<HashMap<String, Value>>, _plan: &AggregationPlan) -> Result<Value> {
75        // For simple projection: just convert rows to JSON array
76        // Future improvements could include:
77        // - Type coercion (ensure numbers are numbers, not strings)
78        // - Null handling
79        // - Nested object construction
80        // - Date formatting
81
82        let projected_rows: Vec<Value> = rows
83            .into_iter()
84            .map(|row| {
85                // Convert HashMap to JSON object
86                let mut obj = serde_json::Map::new();
87                for (key, value) in row {
88                    obj.insert(key, value);
89                }
90                Value::Object(obj)
91            })
92            .collect();
93
94        Ok(Value::Array(projected_rows))
95    }
96
97    /// Wrap projected results in GraphQL data envelope.
98    ///
99    /// # Arguments
100    ///
101    /// * `projected` - Projected result array
102    /// * `query_name` - GraphQL query field name (e.g., "sales_aggregate")
103    ///
104    /// # Returns
105    ///
106    /// Complete GraphQL response with `{"data": {...}}` wrapper
107    ///
108    /// # Example
109    ///
110    /// ```rust,ignore
111    /// let projected = json!([{"count": 42}]);
112    /// let response = AggregationProjector::wrap_in_data_envelope(projected, "sales_aggregate");
113    /// // response: {"data": {"sales_aggregate": [{"count": 42}]}}
114    /// ```
115    pub fn wrap_in_data_envelope(projected: Value, query_name: &str) -> Value {
116        json!({
117            "data": {
118                query_name: projected
119            }
120        })
121    }
122
123    /// Project a single aggregate result (no GROUP BY).
124    ///
125    /// When there's no GROUP BY, the result is a single object, not an array.
126    ///
127    /// # Example
128    ///
129    /// ```rust,ignore
130    /// let row = hashmap!{"count" => json!(100), "revenue_sum" => json!(5000.0)};
131    /// let result = AggregationProjector::project_single(row, &plan)?;
132    /// // result: {"count": 100, "revenue_sum": 5000.0}
133    /// ```
134    pub fn project_single(row: HashMap<String, Value>, _plan: &AggregationPlan) -> Result<Value> {
135        // Convert HashMap to JSON object
136        let mut obj = serde_json::Map::new();
137        for (key, value) in row {
138            obj.insert(key, value);
139        }
140        Ok(Value::Object(obj))
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::compiler::{
148        aggregate_types::AggregateFunction,
149        aggregation::{
150            AggregateExpression, AggregateSelection, AggregationRequest, GroupByExpression,
151            GroupBySelection,
152        },
153        fact_table::{DimensionColumn, FactTableMetadata, FilterColumn, MeasureColumn, SqlType},
154    };
155
156    fn create_test_plan() -> AggregationPlan {
157        use crate::compiler::fact_table::DimensionPath;
158
159        let metadata = FactTableMetadata {
160            table_name:           "tf_sales".to_string(),
161            measures:             vec![MeasureColumn {
162                name:     "revenue".to_string(),
163                sql_type: SqlType::Decimal,
164                nullable: false,
165            }],
166            dimensions:           DimensionColumn {
167                name:  "dimensions".to_string(),
168                paths: vec![DimensionPath {
169                    name:      "category".to_string(),
170                    json_path: "data->>'category'".to_string(),
171                    data_type: "text".to_string(),
172                }],
173            },
174            denormalized_filters: vec![FilterColumn {
175                name:     "occurred_at".to_string(),
176                sql_type: SqlType::Timestamp,
177                indexed:  true,
178            }],
179            calendar_dimensions:  vec![],
180        };
181
182        let request = AggregationRequest {
183            table_name:   "tf_sales".to_string(),
184            where_clause: None,
185            group_by:     vec![GroupBySelection::Dimension {
186                path:  "category".to_string(),
187                alias: "category".to_string(),
188            }],
189            aggregates:   vec![
190                AggregateSelection::Count {
191                    alias: "count".to_string(),
192                },
193                AggregateSelection::MeasureAggregate {
194                    measure:  "revenue".to_string(),
195                    function: AggregateFunction::Sum,
196                    alias:    "revenue_sum".to_string(),
197                },
198            ],
199            having:       vec![],
200            order_by:     vec![],
201            limit:        None,
202            offset:       None,
203        };
204
205        AggregationPlan {
206            metadata,
207            request,
208            group_by_expressions: vec![GroupByExpression::JsonbPath {
209                jsonb_column: "data".to_string(),
210                path:         "category".to_string(),
211                alias:        "category".to_string(),
212            }],
213            aggregate_expressions: vec![
214                AggregateExpression::Count {
215                    alias: "count".to_string(),
216                },
217                AggregateExpression::MeasureAggregate {
218                    column:   "revenue".to_string(),
219                    function: AggregateFunction::Sum,
220                    alias:    "revenue_sum".to_string(),
221                },
222            ],
223            having_conditions: vec![],
224        }
225    }
226
227    #[test]
228    fn test_project_simple_result() {
229        let plan = create_test_plan();
230        let rows = vec![
231            {
232                let mut row = HashMap::new();
233                row.insert("category".to_string(), json!("Electronics"));
234                row.insert("count".to_string(), json!(42));
235                row.insert("revenue_sum".to_string(), json!(5280.50));
236                row
237            },
238            {
239                let mut row = HashMap::new();
240                row.insert("category".to_string(), json!("Books"));
241                row.insert("count".to_string(), json!(15));
242                row.insert("revenue_sum".to_string(), json!(450.25));
243                row
244            },
245        ];
246
247        let result = AggregationProjector::project(rows, &plan).unwrap();
248
249        assert!(result.is_array());
250        let arr = result.as_array().unwrap();
251        assert_eq!(arr.len(), 2);
252
253        assert_eq!(arr[0]["category"], "Electronics");
254        assert_eq!(arr[0]["count"], 42);
255        assert_eq!(arr[0]["revenue_sum"], 5280.50);
256
257        assert_eq!(arr[1]["category"], "Books");
258        assert_eq!(arr[1]["count"], 15);
259        assert_eq!(arr[1]["revenue_sum"], 450.25);
260    }
261
262    #[test]
263    fn test_project_empty_result() {
264        let plan = create_test_plan();
265        let rows = vec![];
266
267        let result = AggregationProjector::project(rows, &plan).unwrap();
268
269        assert!(result.is_array());
270        let arr = result.as_array().unwrap();
271        assert_eq!(arr.len(), 0);
272    }
273
274    #[test]
275    fn test_wrap_in_data_envelope() {
276        let projected = json!([
277            {"category": "Electronics", "count": 42}
278        ]);
279
280        let response = AggregationProjector::wrap_in_data_envelope(projected, "sales_aggregate");
281
282        assert!(response.get("data").is_some());
283        assert!(response["data"].get("sales_aggregate").is_some());
284        assert!(response["data"]["sales_aggregate"].is_array());
285        assert_eq!(response["data"]["sales_aggregate"][0]["category"], "Electronics");
286    }
287
288    #[test]
289    fn test_project_single() {
290        let plan = create_test_plan();
291        let mut row = HashMap::new();
292        row.insert("count".to_string(), json!(100));
293        row.insert("revenue_sum".to_string(), json!(10000.0));
294
295        let result = AggregationProjector::project_single(row, &plan).unwrap();
296
297        assert!(result.is_object());
298        assert_eq!(result["count"], 100);
299        assert_eq!(result["revenue_sum"], 10000.0);
300    }
301
302    #[test]
303    fn test_project_with_temporal_bucket() {
304        let plan = create_test_plan();
305        let rows = vec![{
306            let mut row = HashMap::new();
307            row.insert("category".to_string(), json!("Electronics"));
308            row.insert("occurred_at_day".to_string(), json!("2025-01-01"));
309            row.insert("count".to_string(), json!(25));
310            row.insert("revenue_sum".to_string(), json!(3000.0));
311            row
312        }];
313
314        let result = AggregationProjector::project(rows, &plan).unwrap();
315
316        assert!(result.is_array());
317        let arr = result.as_array().unwrap();
318        assert_eq!(arr[0]["occurred_at_day"], "2025-01-01");
319    }
320
321    #[test]
322    fn test_project_with_null_values() {
323        let plan = create_test_plan();
324        let rows = vec![{
325            let mut row = HashMap::new();
326            row.insert("category".to_string(), Value::Null);
327            row.insert("count".to_string(), json!(10));
328            row.insert("revenue_sum".to_string(), json!(500.0));
329            row
330        }];
331
332        let result = AggregationProjector::project(rows, &plan).unwrap();
333
334        assert!(result.is_array());
335        let arr = result.as_array().unwrap();
336        assert_eq!(arr[0]["category"], Value::Null);
337        assert_eq!(arr[0]["count"], 10);
338    }
339
340    // ========================================
341    // Advanced Aggregates Projection Tests
342    // ========================================
343
344    #[test]
345    fn test_project_array_agg_result() {
346        let plan = create_test_plan();
347        let rows = vec![{
348            let mut row = HashMap::new();
349            row.insert("category".to_string(), json!("Electronics"));
350            row.insert("count".to_string(), json!(10));
351            // PostgreSQL ARRAY_AGG result
352            row.insert("products".to_string(), json!(["prod_1", "prod_2", "prod_3"]));
353            row
354        }];
355
356        let result = AggregationProjector::project(rows, &plan).unwrap();
357
358        assert!(result.is_array());
359        let arr = result.as_array().unwrap();
360        assert_eq!(arr[0]["category"], "Electronics");
361        assert_eq!(arr[0]["products"], json!(["prod_1", "prod_2", "prod_3"]));
362    }
363
364    #[test]
365    fn test_project_json_agg_result() {
366        let plan = create_test_plan();
367        let rows = vec![{
368            let mut row = HashMap::new();
369            row.insert("category".to_string(), json!("Electronics"));
370            row.insert("count".to_string(), json!(10));
371            // PostgreSQL JSON_AGG result
372            row.insert(
373                "items".to_string(),
374                json!([
375                    {"product": "prod_1", "revenue": 1500},
376                    {"product": "prod_2", "revenue": 1200}
377                ]),
378            );
379            row
380        }];
381
382        let result = AggregationProjector::project(rows, &plan).unwrap();
383
384        assert!(result.is_array());
385        let arr = result.as_array().unwrap();
386        assert_eq!(arr[0]["category"], "Electronics");
387        assert!(arr[0]["items"].is_array());
388        let items = arr[0]["items"].as_array().unwrap();
389        assert_eq!(items.len(), 2);
390        assert_eq!(items[0]["product"], "prod_1");
391        assert_eq!(items[0]["revenue"], 1500);
392    }
393
394    #[test]
395    fn test_project_string_agg_result() {
396        let plan = create_test_plan();
397        let rows = vec![{
398            let mut row = HashMap::new();
399            row.insert("category".to_string(), json!("Electronics"));
400            row.insert("count".to_string(), json!(10));
401            // PostgreSQL STRING_AGG result
402            row.insert("product_names".to_string(), json!("Laptop, Phone, Tablet"));
403            row
404        }];
405
406        let result = AggregationProjector::project(rows, &plan).unwrap();
407
408        assert!(result.is_array());
409        let arr = result.as_array().unwrap();
410        assert_eq!(arr[0]["category"], "Electronics");
411        assert_eq!(arr[0]["product_names"], "Laptop, Phone, Tablet");
412    }
413
414    #[test]
415    fn test_project_bool_agg_result() {
416        let plan = create_test_plan();
417        let rows = vec![{
418            let mut row = HashMap::new();
419            row.insert("category".to_string(), json!("Electronics"));
420            row.insert("count".to_string(), json!(10));
421            // PostgreSQL BOOL_AND result
422            row.insert("all_active".to_string(), json!(true));
423            // PostgreSQL BOOL_OR result
424            row.insert("any_discounted".to_string(), json!(false));
425            row
426        }];
427
428        let result = AggregationProjector::project(rows, &plan).unwrap();
429
430        assert!(result.is_array());
431        let arr = result.as_array().unwrap();
432        assert_eq!(arr[0]["category"], "Electronics");
433        assert_eq!(arr[0]["all_active"], true);
434        assert_eq!(arr[0]["any_discounted"], false);
435    }
436
437    #[test]
438    fn test_project_mixed_aggregates() {
439        let plan = create_test_plan();
440        let rows = vec![{
441            let mut row = HashMap::new();
442            row.insert("category".to_string(), json!("Electronics"));
443            // Basic aggregates
444            row.insert("count".to_string(), json!(42));
445            row.insert("revenue_sum".to_string(), json!(5280.50));
446            row.insert("revenue_avg".to_string(), json!(125.73));
447            // Advanced aggregates
448            row.insert("products".to_string(), json!(["prod_1", "prod_2"]));
449            row.insert("product_names".to_string(), json!("Laptop, Phone"));
450            row.insert("all_active".to_string(), json!(true));
451            row
452        }];
453
454        let result = AggregationProjector::project(rows, &plan).unwrap();
455
456        assert!(result.is_array());
457        let arr = result.as_array().unwrap();
458        // Verify basic aggregates
459        assert_eq!(arr[0]["count"], 42);
460        assert_eq!(arr[0]["revenue_sum"], 5280.50);
461        // Verify advanced aggregates
462        assert_eq!(arr[0]["products"], json!(["prod_1", "prod_2"]));
463        assert_eq!(arr[0]["product_names"], "Laptop, Phone");
464        assert_eq!(arr[0]["all_active"], true);
465    }
466
467    #[test]
468    fn test_project_empty_array_agg() {
469        let plan = create_test_plan();
470        let rows = vec![{
471            let mut row = HashMap::new();
472            row.insert("category".to_string(), json!("Empty"));
473            row.insert("count".to_string(), json!(0));
474            // Empty ARRAY_AGG result (NULL in PostgreSQL, [] in others)
475            row.insert("products".to_string(), Value::Null);
476            row
477        }];
478
479        let result = AggregationProjector::project(rows, &plan).unwrap();
480
481        assert!(result.is_array());
482        let arr = result.as_array().unwrap();
483        assert_eq!(arr[0]["category"], "Empty");
484        assert!(arr[0]["products"].is_null());
485    }
486}