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    #[must_use]
134    pub fn wrap_in_data_envelope(projected: Value, query_name: &str) -> Value {
135        json!({
136            "data": {
137                query_name: projected
138            }
139        })
140    }
141
142    /// Project a single aggregate result (no GROUP BY).
143    ///
144    /// When there's no GROUP BY, the result is a single object, not an array.
145    ///
146    /// # Errors
147    ///
148    /// Currently infallible; reserved for future extension (e.g., type coercion failures).
149    ///
150    /// # Example
151    ///
152    /// ```no_run
153    /// // Requires: an AggregationPlan built from compiled schema metadata.
154    /// // See: tests/integration/ for runnable examples.
155    /// use std::collections::HashMap;
156    /// use serde_json::json;
157    /// # use fraiseql_core::runtime::AggregationProjector;
158    ///
159    /// let mut row = HashMap::new();
160    /// row.insert("count".to_string(), json!(100));
161    /// row.insert("revenue_sum".to_string(), json!(5000.0));
162    /// // let result = AggregationProjector::project_single(row, &plan)?;
163    /// // result: {"count": 100, "revenue_sum": 5000.0}
164    /// ```
165    pub fn project_single(row: HashMap<String, Value>, _plan: &AggregationPlan) -> Result<Value> {
166        // Convert HashMap to JSON object
167        let mut obj = serde_json::Map::new();
168        for (key, value) in row {
169            obj.insert(key, value);
170        }
171        Ok(Value::Object(obj))
172    }
173}