Skip to main content

oxirs_gql/
aggregation.rs

1//! Aggregation support for GraphQL queries
2//!
3//! This module provides comprehensive aggregation capabilities:
4//! - COUNT, SUM, AVG, MIN, MAX aggregations
5//! - GROUP BY with multiple fields
6//! - HAVING clauses for filtered aggregations
7//! - Nested aggregations
8//! - DISTINCT support
9
10use anyhow::{anyhow, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::fmt;
14
15/// Aggregation function type
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum AggregateFunction {
18    /// Count of results
19    Count,
20    /// Sum of numeric values
21    Sum,
22    /// Average of numeric values
23    Avg,
24    /// Minimum value
25    Min,
26    /// Maximum value
27    Max,
28    /// Group concatenation (string aggregation)
29    GroupConcat,
30    /// Sample value (arbitrary value from group)
31    Sample,
32}
33
34impl fmt::Display for AggregateFunction {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            AggregateFunction::Count => write!(f, "COUNT"),
38            AggregateFunction::Sum => write!(f, "SUM"),
39            AggregateFunction::Avg => write!(f, "AVG"),
40            AggregateFunction::Min => write!(f, "MIN"),
41            AggregateFunction::Max => write!(f, "MAX"),
42            AggregateFunction::GroupConcat => write!(f, "GROUP_CONCAT"),
43            AggregateFunction::Sample => write!(f, "SAMPLE"),
44        }
45    }
46}
47
48/// Aggregation specification
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Aggregation {
51    /// Aggregation function
52    pub function: AggregateFunction,
53    /// Field to aggregate
54    pub field: String,
55    /// Alias for the result
56    pub alias: String,
57    /// Whether to use DISTINCT
58    pub distinct: bool,
59    /// Separator for GROUP_CONCAT (default: " ")
60    pub separator: Option<String>,
61}
62
63impl Aggregation {
64    pub fn new(function: AggregateFunction, field: String, alias: String) -> Self {
65        Self {
66            function,
67            field,
68            alias,
69            distinct: false,
70            separator: None,
71        }
72    }
73
74    pub fn with_distinct(mut self) -> Self {
75        self.distinct = true;
76        self
77    }
78
79    pub fn with_separator(mut self, separator: String) -> Self {
80        self.separator = Some(separator);
81        self
82    }
83
84    /// Convert to SPARQL aggregation expression
85    pub fn to_sparql_expression(&self) -> String {
86        let distinct_prefix = if self.distinct { "DISTINCT " } else { "" };
87        let var = format!("?{}", self.field);
88
89        match self.function {
90            AggregateFunction::Count => {
91                if self.field == "*" {
92                    format!("(COUNT({}*) AS ?{})", distinct_prefix, self.alias)
93                } else {
94                    format!("(COUNT({}{}) AS ?{})", distinct_prefix, var, self.alias)
95                }
96            }
97            AggregateFunction::Sum => {
98                format!("(SUM({}{}) AS ?{})", distinct_prefix, var, self.alias)
99            }
100            AggregateFunction::Avg => {
101                format!("(AVG({}{}) AS ?{})", distinct_prefix, var, self.alias)
102            }
103            AggregateFunction::Min => {
104                format!("(MIN({}) AS ?{})", var, self.alias)
105            }
106            AggregateFunction::Max => {
107                format!("(MAX({}) AS ?{})", var, self.alias)
108            }
109            AggregateFunction::GroupConcat => {
110                let separator = self.separator.as_deref().unwrap_or(" ");
111                format!(
112                    "(GROUP_CONCAT({}{}; SEPARATOR=\"{}\") AS ?{})",
113                    distinct_prefix, var, separator, self.alias
114                )
115            }
116            AggregateFunction::Sample => {
117                format!("(SAMPLE({}) AS ?{})", var, self.alias)
118            }
119        }
120    }
121}
122
123/// GROUP BY specification
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct GroupBy {
126    /// Fields to group by
127    pub fields: Vec<String>,
128}
129
130impl GroupBy {
131    pub fn new(fields: Vec<String>) -> Self {
132        Self { fields }
133    }
134
135    /// Convert to SPARQL GROUP BY clause
136    pub fn to_sparql_clause(&self) -> String {
137        let vars: Vec<String> = self.fields.iter().map(|f| format!("?{}", f)).collect();
138        format!("GROUP BY {}", vars.join(" "))
139    }
140}
141
142/// HAVING clause condition
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct HavingCondition {
145    /// Aggregation to test
146    pub aggregation: Aggregation,
147    /// Comparison operator
148    pub operator: String,
149    /// Value to compare against
150    pub value: serde_json::Value,
151}
152
153impl HavingCondition {
154    pub fn new(aggregation: Aggregation, operator: String, value: serde_json::Value) -> Self {
155        Self {
156            aggregation,
157            operator,
158            value,
159        }
160    }
161
162    /// Convert to SPARQL HAVING clause
163    pub fn to_sparql_clause(&self) -> Result<String> {
164        let agg_expr = self.aggregation.to_sparql_expression();
165        // Extract the expression part without the alias
166        let expr_part = if let Some(start) = agg_expr.find('(') {
167            if let Some(end) = agg_expr.find(" AS ") {
168                &agg_expr[start..end]
169            } else {
170                return Err(anyhow!("Invalid aggregation expression"));
171            }
172        } else {
173            return Err(anyhow!("Invalid aggregation expression"));
174        };
175
176        let value_str = match &self.value {
177            serde_json::Value::Number(n) => n.to_string(),
178            serde_json::Value::String(s) => format!("\"{}\"", s),
179            serde_json::Value::Bool(b) => b.to_string(),
180            _ => return Err(anyhow!("Unsupported value type for HAVING")),
181        };
182
183        Ok(format!("{} {} {}", expr_part, self.operator, value_str))
184    }
185}
186
187/// Complete aggregation query specification
188#[derive(Debug, Clone)]
189pub struct AggregationQuery {
190    /// List of aggregations to perform
191    pub aggregations: Vec<Aggregation>,
192    /// GROUP BY specification
193    pub group_by: Option<GroupBy>,
194    /// HAVING conditions
195    pub having: Vec<HavingCondition>,
196    /// Additional non-aggregated fields to select
197    pub select_fields: Vec<String>,
198}
199
200impl AggregationQuery {
201    pub fn new() -> Self {
202        Self {
203            aggregations: Vec::new(),
204            group_by: None,
205            having: Vec::new(),
206            select_fields: Vec::new(),
207        }
208    }
209
210    pub fn with_aggregation(mut self, aggregation: Aggregation) -> Self {
211        self.aggregations.push(aggregation);
212        self
213    }
214
215    pub fn with_group_by(mut self, group_by: GroupBy) -> Self {
216        self.group_by = Some(group_by);
217        self
218    }
219
220    pub fn with_having(mut self, having: HavingCondition) -> Self {
221        self.having.push(having);
222        self
223    }
224
225    pub fn with_select_field(mut self, field: String) -> Self {
226        self.select_fields.push(field);
227        self
228    }
229
230    /// Generate SPARQL SELECT clause with aggregations
231    pub fn to_sparql_select(&self) -> String {
232        let mut select_parts = Vec::new();
233
234        // Add non-aggregated fields
235        for field in &self.select_fields {
236            select_parts.push(format!("?{}", field));
237        }
238
239        // Add aggregations
240        for agg in &self.aggregations {
241            select_parts.push(agg.to_sparql_expression());
242        }
243
244        format!("SELECT {}", select_parts.join(" "))
245    }
246
247    /// Generate complete SPARQL aggregation query
248    pub fn to_sparql_query(&self, where_clause: &str) -> Result<String> {
249        let mut query = String::new();
250
251        // SELECT clause
252        query.push_str(&self.to_sparql_select());
253        query.push('\n');
254
255        // WHERE clause
256        query.push_str("WHERE {\n");
257        query.push_str(where_clause);
258        query.push_str("\n}\n");
259
260        // GROUP BY clause
261        if let Some(group_by) = &self.group_by {
262            query.push_str(&group_by.to_sparql_clause());
263            query.push('\n');
264        }
265
266        // HAVING clause
267        if !self.having.is_empty() {
268            let having_conditions: Result<Vec<String>> =
269                self.having.iter().map(|h| h.to_sparql_clause()).collect();
270            let having_conditions = having_conditions?;
271            query.push_str(&format!("HAVING ({})\n", having_conditions.join(" && ")));
272        }
273
274        Ok(query)
275    }
276}
277
278impl Default for AggregationQuery {
279    fn default() -> Self {
280        Self::new()
281    }
282}
283
284/// Result of an aggregation query
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct AggregationResult {
287    /// Field values for grouped fields
288    pub group_values: HashMap<String, serde_json::Value>,
289    /// Aggregation results
290    pub aggregates: HashMap<String, serde_json::Value>,
291}
292
293impl AggregationResult {
294    pub fn new() -> Self {
295        Self {
296            group_values: HashMap::new(),
297            aggregates: HashMap::new(),
298        }
299    }
300
301    pub fn add_group_value(&mut self, field: String, value: serde_json::Value) {
302        self.group_values.insert(field, value);
303    }
304
305    pub fn add_aggregate(&mut self, alias: String, value: serde_json::Value) {
306        self.aggregates.insert(alias, value);
307    }
308}
309
310impl Default for AggregationResult {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316/// Builder for aggregation queries
317pub struct AggregationQueryBuilder {
318    query: AggregationQuery,
319}
320
321impl AggregationQueryBuilder {
322    pub fn new() -> Self {
323        Self {
324            query: AggregationQuery::new(),
325        }
326    }
327
328    /// Add a COUNT aggregation
329    pub fn count(mut self, field: String, alias: String) -> Self {
330        self.query =
331            self.query
332                .with_aggregation(Aggregation::new(AggregateFunction::Count, field, alias));
333        self
334    }
335
336    /// Add a SUM aggregation
337    pub fn sum(mut self, field: String, alias: String) -> Self {
338        self.query =
339            self.query
340                .with_aggregation(Aggregation::new(AggregateFunction::Sum, field, alias));
341        self
342    }
343
344    /// Add an AVG aggregation
345    pub fn avg(mut self, field: String, alias: String) -> Self {
346        self.query =
347            self.query
348                .with_aggregation(Aggregation::new(AggregateFunction::Avg, field, alias));
349        self
350    }
351
352    /// Add a MIN aggregation
353    pub fn min(mut self, field: String, alias: String) -> Self {
354        self.query =
355            self.query
356                .with_aggregation(Aggregation::new(AggregateFunction::Min, field, alias));
357        self
358    }
359
360    /// Add a MAX aggregation
361    pub fn max(mut self, field: String, alias: String) -> Self {
362        self.query =
363            self.query
364                .with_aggregation(Aggregation::new(AggregateFunction::Max, field, alias));
365        self
366    }
367
368    /// Add GROUP BY fields
369    pub fn group_by(mut self, fields: Vec<String>) -> Self {
370        self.query = self.query.with_group_by(GroupBy::new(fields));
371        self
372    }
373
374    /// Add HAVING condition
375    pub fn having(
376        mut self,
377        aggregation: Aggregation,
378        operator: String,
379        value: serde_json::Value,
380    ) -> Self {
381        self.query = self
382            .query
383            .with_having(HavingCondition::new(aggregation, operator, value));
384        self
385    }
386
387    /// Add a field to select (for grouping)
388    pub fn select(mut self, field: String) -> Self {
389        self.query = self.query.with_select_field(field);
390        self
391    }
392
393    /// Build the aggregation query
394    pub fn build(self) -> AggregationQuery {
395        self.query
396    }
397}
398
399impl Default for AggregationQueryBuilder {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_aggregate_function_display() {
411        assert_eq!(AggregateFunction::Count.to_string(), "COUNT");
412        assert_eq!(AggregateFunction::Sum.to_string(), "SUM");
413        assert_eq!(AggregateFunction::Avg.to_string(), "AVG");
414        assert_eq!(AggregateFunction::Min.to_string(), "MIN");
415        assert_eq!(AggregateFunction::Max.to_string(), "MAX");
416    }
417
418    #[test]
419    fn test_aggregation_count() {
420        let agg = Aggregation::new(
421            AggregateFunction::Count,
422            "name".to_string(),
423            "total".to_string(),
424        );
425
426        let sparql = agg.to_sparql_expression();
427        assert!(sparql.contains("COUNT"));
428        assert!(sparql.contains("?name"));
429        assert!(sparql.contains("?total"));
430    }
431
432    #[test]
433    fn test_aggregation_sum() {
434        let agg = Aggregation::new(
435            AggregateFunction::Sum,
436            "price".to_string(),
437            "total_price".to_string(),
438        );
439
440        let sparql = agg.to_sparql_expression();
441        assert!(sparql.contains("SUM"));
442        assert!(sparql.contains("?price"));
443        assert!(sparql.contains("?total_price"));
444    }
445
446    #[test]
447    fn test_aggregation_with_distinct() {
448        let agg = Aggregation::new(
449            AggregateFunction::Count,
450            "category".to_string(),
451            "unique_categories".to_string(),
452        )
453        .with_distinct();
454
455        let sparql = agg.to_sparql_expression();
456        assert!(sparql.contains("DISTINCT"));
457    }
458
459    #[test]
460    fn test_group_by() {
461        let group_by = GroupBy::new(vec!["category".to_string(), "brand".to_string()]);
462
463        let sparql = group_by.to_sparql_clause();
464        assert_eq!(sparql, "GROUP BY ?category ?brand");
465    }
466
467    #[test]
468    fn test_aggregation_query_select() {
469        let query = AggregationQuery::new()
470            .with_select_field("category".to_string())
471            .with_aggregation(Aggregation::new(
472                AggregateFunction::Count,
473                "id".to_string(),
474                "count".to_string(),
475            ))
476            .with_aggregation(Aggregation::new(
477                AggregateFunction::Sum,
478                "price".to_string(),
479                "total".to_string(),
480            ));
481
482        let select = query.to_sparql_select();
483        assert!(select.contains("SELECT"));
484        assert!(select.contains("?category"));
485        assert!(select.contains("COUNT"));
486        assert!(select.contains("SUM"));
487    }
488
489    #[test]
490    fn test_aggregation_query_full() {
491        let query = AggregationQuery::new()
492            .with_select_field("category".to_string())
493            .with_aggregation(Aggregation::new(
494                AggregateFunction::Count,
495                "id".to_string(),
496                "count".to_string(),
497            ))
498            .with_group_by(GroupBy::new(vec!["category".to_string()]));
499
500        let where_clause = "?s rdf:type ?category . ?s ex:id ?id .";
501        let sparql = query.to_sparql_query(where_clause).expect("should succeed");
502
503        assert!(sparql.contains("SELECT"));
504        assert!(sparql.contains("WHERE"));
505        assert!(sparql.contains("GROUP BY"));
506        assert!(sparql.contains("?category"));
507    }
508
509    #[test]
510    fn test_aggregation_query_builder() {
511        let query = AggregationQueryBuilder::new()
512            .select("category".to_string())
513            .count("id".to_string(), "total".to_string())
514            .sum("price".to_string(), "total_price".to_string())
515            .group_by(vec!["category".to_string()])
516            .build();
517
518        assert_eq!(query.select_fields.len(), 1);
519        assert_eq!(query.aggregations.len(), 2);
520        assert!(query.group_by.is_some());
521    }
522
523    #[test]
524    fn test_aggregation_result() {
525        let mut result = AggregationResult::new();
526        result.add_group_value("category".to_string(), serde_json::json!("Books"));
527        result.add_aggregate("count".to_string(), serde_json::json!(42));
528        result.add_aggregate("total_price".to_string(), serde_json::json!(199.99));
529
530        assert_eq!(result.group_values.len(), 1);
531        assert_eq!(result.aggregates.len(), 2);
532    }
533
534    #[test]
535    fn test_having_condition() {
536        let agg = Aggregation::new(
537            AggregateFunction::Count,
538            "id".to_string(),
539            "count".to_string(),
540        );
541        let having = HavingCondition::new(agg, ">".to_string(), serde_json::json!(10));
542
543        let clause = having.to_sparql_clause().expect("should succeed");
544        assert!(clause.contains(">"));
545        assert!(clause.contains("10"));
546    }
547
548    #[test]
549    fn test_group_concat_with_separator() {
550        let agg = Aggregation::new(
551            AggregateFunction::GroupConcat,
552            "name".to_string(),
553            "names".to_string(),
554        )
555        .with_separator(", ".to_string());
556
557        let sparql = agg.to_sparql_expression();
558        assert!(sparql.contains("GROUP_CONCAT"));
559        assert!(sparql.contains("SEPARATOR"));
560        assert!(sparql.contains(", "));
561    }
562
563    #[test]
564    fn test_sample_aggregation() {
565        let agg = Aggregation::new(
566            AggregateFunction::Sample,
567            "value".to_string(),
568            "sample_value".to_string(),
569        );
570
571        let sparql = agg.to_sparql_expression();
572        assert!(sparql.contains("SAMPLE"));
573        assert!(sparql.contains("?value"));
574    }
575}