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}