Skip to main content

keelson_core/clause/
group_by.rs

1use crate::expr::{Expr, IntoExpr, IntoExprList};
2use crate::writer::{Expression, SqlWriter};
3
4/// `GROUP BY [DISTINCT] a, b [WITH ROLLUP]`
5///
6/// ```text
7/// GROUP BY [ ALL | DISTINCT ] grouping_element [, ...]      -- PostgreSQL 17
8/// GROUP BY expr [, ...] [WITH ROLLUP]                       -- MySQL 8.4
9/// ```
10///
11/// `ALL` is the default and writing it adds nothing, so only `DISTINCT` is
12/// representable.
13#[derive(Debug, Clone, Default)]
14pub struct GroupBy {
15    /// The grouping elements. A [`GroupingSet`] is one of these.
16    pub groups: Vec<Expr>,
17    /// PostgreSQL's `GROUP BY DISTINCT`, which de-duplicates the grouping sets a
18    /// `CUBE` or `ROLLUP` expands to.
19    pub distinct: bool,
20    /// MySQL's `WITH ROLLUP`.
21    pub with: Option<GroupByWith>,
22}
23
24impl GroupBy {
25    /// Replace the grouping elements.
26    pub fn set_groups(&mut self, groups: impl IntoExprList) {
27        self.groups = groups.into_expr_list();
28    }
29
30    /// Append one grouping element.
31    pub fn append_group(&mut self, group: impl IntoExpr) {
32        self.groups.push(group.into_expr());
33    }
34
35    /// Whether the clause is absent.
36    pub fn is_empty(&self) -> bool {
37        self.groups.is_empty()
38    }
39}
40
41impl Expression for GroupBy {
42    fn write_sql(&self, w: &mut SqlWriter<'_>) {
43        // The group list gates the whole clause: `GROUP BY DISTINCT` and
44        // `WITH ROLLUP` are modifiers of a list, and neither is SQL without one.
45        if self.groups.is_empty() {
46            return;
47        }
48
49        w.push_str("GROUP BY ");
50        if self.distinct {
51            w.push_str("DISTINCT ");
52        }
53        w.write_slice(&self.groups, "", ", ", "");
54
55        if let Some(with) = &self.with {
56            w.push_str(" WITH ");
57            w.push_str(with.as_str());
58        }
59    }
60}
61
62/// A statement with a `GROUP BY` clause.
63pub trait HasGroupBy {
64    /// The `GROUP BY` clause to modify.
65    fn group_by_mut(&mut self) -> &mut GroupBy;
66}
67
68impl HasGroupBy for GroupBy {
69    fn group_by_mut(&mut self) -> &mut GroupBy {
70        self
71    }
72}
73
74/// MySQL's trailing `WITH …` modifier.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum GroupByWith {
77    /// `WITH ROLLUP`.
78    Rollup,
79    /// `WITH CUBE`.
80    Cube,
81}
82
83impl GroupByWith {
84    /// The keyword, as written.
85    pub fn as_str(self) -> &'static str {
86        match self {
87            GroupByWith::Rollup => "ROLLUP",
88            GroupByWith::Cube => "CUBE",
89        }
90    }
91}
92
93/// One grouping element that is itself a set: `ROLLUP (a, b)`, `CUBE (a, b)`,
94/// `GROUPING SETS ((a), (b), ())`.
95///
96/// From PostgreSQL 17's `grouping_element`. Meant to be put into
97/// [`GroupBy::groups`] beside plain expressions.
98#[derive(Debug, Clone, Default)]
99pub struct GroupingSet {
100    /// Which kind of set.
101    pub kind: GroupingSetKind,
102    /// The elements. For `GROUPING SETS` these are themselves usually
103    /// [`Expr::Group`](crate::expr::Expr::Group)s.
104    pub groups: Vec<Expr>,
105}
106
107impl GroupingSet {
108    /// A grouping element of `kind` over `groups`.
109    pub fn new(kind: GroupingSetKind, groups: impl IntoExprList) -> Self {
110        GroupingSet {
111            kind,
112            groups: groups.into_expr_list(),
113        }
114    }
115
116    /// Whether there is nothing to group.
117    pub fn is_empty(&self) -> bool {
118        self.groups.is_empty()
119    }
120}
121
122impl Expression for GroupingSet {
123    fn write_sql(&self, w: &mut SqlWriter<'_>) {
124        // Unlike bob, a keyword with no list is not written: `ROLLUP` alone is a
125        // syntax error, and `GROUPING SETS (())` — the one legal empty form — is
126        // written by giving it one empty `Expr::Group`.
127        if self.groups.is_empty() {
128            return;
129        }
130        w.push_str(self.kind.keyword());
131        w.write_slice(&self.groups, " (", ", ", ")");
132    }
133}
134
135/// Which set a [`GroupingSet`] expands to.
136#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
137pub enum GroupingSetKind {
138    /// `GROUPING SETS (…)` — the sets are listed explicitly.
139    #[default]
140    GroupingSets,
141    /// `CUBE (…)` — every subset.
142    Cube,
143    /// `ROLLUP (…)` — every prefix.
144    Rollup,
145}
146
147impl GroupingSetKind {
148    /// The keyword, as written.
149    pub fn keyword(self) -> &'static str {
150        match self {
151            GroupingSetKind::GroupingSets => "GROUPING SETS",
152            GroupingSetKind::Cube => "CUBE",
153            GroupingSetKind::Rollup => "ROLLUP",
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use keelson_sqlcheck::testing::assert_frag_sql;
161
162    use super::*;
163    use crate::dialect::testing::Numbered;
164    use crate::expr::quote;
165    use crate::writer::build;
166
167    /// A `GROUP BY` needs a statement whose select list it agrees with.
168    const FRAME: &str = r#"SELECT "status", count(*) FROM posts {}"#;
169
170    fn sql(e: &impl Expression) -> String {
171        build(&Numbered, e).expect("render").0
172    }
173
174    #[test]
175    fn no_groups_means_no_clause_even_with_modifiers() {
176        let g = GroupBy {
177            distinct: true,
178            with: Some(GroupByWith::Rollup),
179            ..GroupBy::default()
180        };
181        // Framed against a statement that is still legal without it: with no
182        // grouping element there is no clause, modifiers or not.
183        assert_frag_sql("SELECT count(*) FROM posts {}", &sql(&g), "");
184        assert!(g.is_empty());
185    }
186
187    #[test]
188    fn the_group_list_is_comma_separated() {
189        let mut g = GroupBy::default();
190        g.append_group(quote("status"));
191        // An ordinal is a grouping element too; 1 is the frame's first output
192        // column, which is `status` again.
193        g.append_group("1");
194        assert_frag_sql(FRAME, &sql(&g), r#"GROUP BY "status", 1"#);
195    }
196
197    #[test]
198    fn distinct_and_with_wrap_the_group_list() {
199        let mut g = GroupBy::default();
200        g.append_group(quote("status"));
201        g.append_group("1");
202        g.distinct = true;
203        g.with = Some(GroupByWith::Cube);
204        // Not framed: `WITH CUBE` is a trailing modifier no PostgreSQL grammar has
205        // (it spells the same idea `GROUP BY CUBE (...)`, which is the case below),
206        // so the psql judge cannot see this shape at all. The dialect that has it
207        // is checked in its own crate; here only the rendering is pinned.
208        assert_eq!(
209            build(&Numbered, &g).unwrap().0,
210            r#"GROUP BY DISTINCT "status", 1 WITH CUBE"#
211        );
212    }
213
214    #[test]
215    fn a_grouping_set_is_one_grouping_element() {
216        // PostgreSQL 17 grouping_element:
217        //   ROLLUP ( { expression | ( expression [, ...] ) } [, ...] )
218        let mut g = GroupBy::default();
219        g.append_group(Expr::custom(GroupingSet::new(
220            GroupingSetKind::Rollup,
221            (quote("status"), quote("user_id")),
222        )));
223        assert_frag_sql(
224            "SELECT count(*) FROM posts {}",
225            &sql(&g),
226            r#"GROUP BY ROLLUP ("status", "user_id")"#,
227        );
228    }
229
230    #[test]
231    fn grouping_sets_hold_row_groups_including_the_empty_one() {
232        // GROUPING SETS ( ( ) ) is the legal way to ask for the grand total. Note
233        // that the empty set is `Expr::raw("()")` and *not* `Expr::group(())`,
234        // which renders `(NULL)` — a one-column set over the constant NULL, which
235        // is a different query. That trap is `Expr`'s, but this is where a caller
236        // walks into it.
237        let set = GroupingSet::new(
238            GroupingSetKind::GroupingSets,
239            (
240                Expr::group(quote("status")),
241                Expr::group((quote("status"), quote("user_id"))),
242                Expr::raw("()"),
243            ),
244        );
245        assert_frag_sql(
246            "SELECT count(*) FROM posts GROUP BY {}",
247            &sql(&set),
248            r#"GROUPING SETS (("status"), ("status", "user_id"), ())"#,
249        );
250    }
251
252    #[test]
253    fn an_empty_grouping_set_writes_nothing() {
254        // Not framed: a grouping element that writes nothing leaves `GROUP BY`
255        // dangling, which is the point — there is no statement to judge.
256        assert_eq!(build(&Numbered, &GroupingSet::default()).unwrap().0, "");
257        assert!(GroupingSet::default().is_empty());
258        assert_eq!(GroupingSetKind::Cube.keyword(), "CUBE");
259    }
260}