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