rusty_promql_parser/parser/
aggregation.rs

1//! Aggregation grouping clause parsing for PromQL.
2//!
3//! This module handles parsing the grouping clauses used with aggregation operators:
4//!
5//! - `by (label1, label2)` - Group by specific labels, dropping all others
6//! - `without (label1, label2)` - Drop specific labels, keeping all others
7//!
8//! # Supported Aggregation Operators
9//!
10//! These operators support grouping clauses:
11//! `sum`, `avg`, `count`, `min`, `max`, `group`, `stddev`, `stdvar`,
12//! `topk`, `bottomk`, `count_values`, `quantile`, `limitk`, `limit_ratio`
13//!
14//! # Examples
15//!
16//! ```rust
17//! use rusty_promql_parser::parser::aggregation::{grouping, GroupingAction};
18//!
19//! let (rest, g) = grouping("by (job, instance)").unwrap();
20//! assert!(rest.is_empty());
21//! assert_eq!(g.action, GroupingAction::By);
22//! assert_eq!(g.labels, vec!["job", "instance"]);
23//!
24//! let (rest, g) = grouping("without (instance)").unwrap();
25//! assert!(rest.is_empty());
26//! assert_eq!(g.action, GroupingAction::Without);
27//! ```
28
29use std::fmt;
30
31use nom::{
32    IResult, Parser, branch::alt, bytes::complete::tag_no_case, character::complete::char,
33    multi::separated_list0, sequence::delimited,
34};
35
36use crate::lexer::{identifier::label_name, whitespace::ws_opt};
37
38/// The action for aggregation grouping: `by` or `without`.
39///
40/// - [`GroupingAction::By`]: Group results by the specified labels only
41/// - [`GroupingAction::Without`]: Group results by all labels except those specified
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum GroupingAction {
44    /// Group by specific labels, dropping all others.
45    ///
46    /// Example: `sum by (job) (http_requests)` groups by `job` label only.
47    By,
48    /// Drop specific labels, keeping all others.
49    ///
50    /// Example: `sum without (instance) (http_requests)` keeps all labels except `instance`.
51    Without,
52}
53
54impl fmt::Display for GroupingAction {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            GroupingAction::By => write!(f, "by"),
58            GroupingAction::Without => write!(f, "without"),
59        }
60    }
61}
62
63/// Grouping clause for aggregation expressions.
64///
65/// Specifies how to group results when aggregating across time series.
66///
67/// # Example
68///
69/// ```rust
70/// use rusty_promql_parser::parser::aggregation::{Grouping, GroupingAction};
71///
72/// let g = Grouping {
73///     action: GroupingAction::By,
74///     labels: vec!["job".to_string(), "instance".to_string()],
75/// };
76/// assert_eq!(g.to_string(), "by (job, instance)");
77/// ```
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Grouping {
80    /// The grouping action (by or without).
81    pub action: GroupingAction,
82    /// The label names to group by/without.
83    pub labels: Vec<String>,
84}
85
86impl fmt::Display for Grouping {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        write!(f, "{} (", self.action)?;
89        for (i, label) in self.labels.iter().enumerate() {
90            if i > 0 {
91                write!(f, ", ")?;
92            }
93            write!(f, "{}", label)?;
94        }
95        write!(f, ")")
96    }
97}
98
99/// Parse a grouping clause: `by (label1, label2)` or `without (label1, label2)`
100///
101/// # Examples
102///
103/// ```
104/// use rusty_promql_parser::parser::aggregation::{grouping, GroupingAction};
105///
106/// let (rest, g) = grouping("by (job, instance)").unwrap();
107/// assert!(rest.is_empty());
108/// assert_eq!(g.action, GroupingAction::By);
109/// assert_eq!(g.labels, vec!["job", "instance"]);
110///
111/// let (rest, g) = grouping("without (job)").unwrap();
112/// assert!(rest.is_empty());
113/// assert_eq!(g.action, GroupingAction::Without);
114/// ```
115pub fn grouping(input: &str) -> IResult<&str, Grouping> {
116    (
117        // Parse the action (by or without)
118        alt((
119            tag_no_case("by").map(|_| GroupingAction::By),
120            tag_no_case("without").map(|_| GroupingAction::Without),
121        )),
122        // Parse: ws "(" ws labels ws ")"
123        delimited(
124            (ws_opt, char('('), ws_opt),
125            separated_list0((ws_opt, char(','), ws_opt), label_name.map(String::from)),
126            (ws_opt, char(')')),
127        ),
128    )
129        .map(|(action, labels)| Grouping { action, labels })
130        .parse(input)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_grouping_by() {
139        let (rest, g) = grouping("by (job)").unwrap();
140        assert!(rest.is_empty());
141        assert_eq!(g.action, GroupingAction::By);
142        assert_eq!(g.labels, vec!["job"]);
143    }
144
145    #[test]
146    fn test_grouping_without() {
147        let (rest, g) = grouping("without (instance)").unwrap();
148        assert!(rest.is_empty());
149        assert_eq!(g.action, GroupingAction::Without);
150        assert_eq!(g.labels, vec!["instance"]);
151    }
152
153    #[test]
154    fn test_grouping_multiple_labels() {
155        let (rest, g) = grouping("by (job, instance, method)").unwrap();
156        assert!(rest.is_empty());
157        assert_eq!(g.labels, vec!["job", "instance", "method"]);
158    }
159
160    #[test]
161    fn test_grouping_empty() {
162        let (rest, g) = grouping("by ()").unwrap();
163        assert!(rest.is_empty());
164        assert!(g.labels.is_empty());
165    }
166
167    #[test]
168    fn test_grouping_case_insensitive() {
169        let (rest, g) = grouping("BY (job)").unwrap();
170        assert!(rest.is_empty());
171        assert_eq!(g.action, GroupingAction::By);
172
173        let (rest, g) = grouping("WITHOUT (job)").unwrap();
174        assert!(rest.is_empty());
175        assert_eq!(g.action, GroupingAction::Without);
176    }
177
178    #[test]
179    fn test_grouping_display() {
180        let g = Grouping {
181            action: GroupingAction::By,
182            labels: vec!["job".to_string(), "instance".to_string()],
183        };
184        assert_eq!(format!("{}", g), "by (job, instance)");
185
186        let g = Grouping {
187            action: GroupingAction::Without,
188            labels: vec!["job".to_string()],
189        };
190        assert_eq!(format!("{}", g), "without (job)");
191    }
192}