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}