Skip to main content

keelson_core/clause/
frame.rs

1use crate::expr::{Expr, IntoExpr};
2use crate::writer::{Expression, SqlWriter};
3
4/// A window frame: which rows around the current one the window function sees.
5///
6/// From PostgreSQL 17,
7/// <https://www.postgresql.org/docs/17/sql-expressions.html#SYNTAX-WINDOW-FUNCTIONS>:
8///
9/// ```text
10/// { RANGE | ROWS | GROUPS } frame_start [ frame_exclusion ]
11/// { RANGE | ROWS | GROUPS } BETWEEN frame_start AND frame_end [ frame_exclusion ]
12///
13/// frame_start, frame_end:
14///     UNBOUNDED PRECEDING | offset PRECEDING | CURRENT ROW
15///   | offset FOLLOWING    | UNBOUNDED FOLLOWING
16/// frame_exclusion:
17///     EXCLUDE CURRENT ROW | EXCLUDE GROUP | EXCLUDE TIES | EXCLUDE NO OTHERS
18/// ```
19///
20/// Two defaults from that grammar are baked in, because they are what the keywords
21/// mean rather than what this library prefers:
22///
23/// - the mode defaults to `RANGE`, and
24/// - `frame_start` defaults to `UNBOUNDED PRECEDING`.
25///
26/// So setting only [`exclusion`](Self::exclusion) still renders a complete frame,
27/// `RANGE UNBOUNDED PRECEDING EXCLUDE TIES`. **`BETWEEN` appears exactly when
28/// there is an end bound** — that is the whole difference between the two
29/// productions, and writing it without one is a syntax error.
30///
31/// bob carries a separate `Defined bool` that its setters flip, which can disagree
32/// with the fields. Here "defined" is derived: a frame is absent when all four
33/// parts are, which is also what makes [`Frame::default()`](Default) render
34/// nothing.
35#[derive(Debug, Clone, Default)]
36pub struct Frame {
37    /// What the offsets count in. `None` renders as `RANGE`.
38    pub mode: Option<FrameMode>,
39    /// The start bound. `None` renders as `UNBOUNDED PRECEDING`.
40    pub start: Option<Expr>,
41    /// The end bound. Its presence is what turns the frame into a `BETWEEN`.
42    pub end: Option<Expr>,
43    /// Rows to leave out of the frame even though they are inside it.
44    pub exclusion: Option<FrameExclusion>,
45}
46
47impl Frame {
48    /// A frame in `mode`, from `UNBOUNDED PRECEDING`.
49    pub fn new(mode: FrameMode) -> Self {
50        Frame {
51            mode: Some(mode),
52            ..Frame::default()
53        }
54    }
55
56    /// Set the mode.
57    pub fn set_mode(&mut self, mode: FrameMode) {
58        self.mode = Some(mode);
59    }
60
61    /// Set the start bound.
62    pub fn set_start(&mut self, start: impl IntoExpr) {
63        self.start = Some(start.into_expr());
64    }
65
66    /// Set the end bound, making the frame a `BETWEEN`.
67    pub fn set_end(&mut self, end: impl IntoExpr) {
68        self.end = Some(end.into_expr());
69    }
70
71    /// Set the exclusion.
72    pub fn set_exclusion(&mut self, exclusion: FrameExclusion) {
73        self.exclusion = Some(exclusion);
74    }
75
76    /// Whether no part of the frame was set, so that nothing will be written.
77    pub fn is_empty(&self) -> bool {
78        self.mode.is_none()
79            && self.start.is_none()
80            && self.end.is_none()
81            && self.exclusion.is_none()
82    }
83}
84
85impl Expression for Frame {
86    fn write_sql(&self, w: &mut SqlWriter<'_>) {
87        if self.is_empty() {
88            return;
89        }
90
91        w.push_str(self.mode.unwrap_or(FrameMode::Range).as_str());
92        w.push_str(" ");
93
94        if self.end.is_some() {
95            w.push_str("BETWEEN ");
96        }
97
98        match &self.start {
99            Some(start) => w.write_expr(start),
100            None => w.push_str("UNBOUNDED PRECEDING"),
101        }
102
103        if let Some(end) = &self.end {
104            w.push_str(" AND ");
105            w.write_expr(end);
106        }
107
108        if let Some(exclusion) = &self.exclusion {
109            w.push_str(" EXCLUDE ");
110            w.push_str(exclusion.as_str());
111        }
112    }
113}
114
115/// Anything with a window frame — a [`Window`](super::Window) definition.
116pub trait HasFrame {
117    /// The frame to modify.
118    fn frame_mut(&mut self) -> &mut Frame;
119}
120
121impl HasFrame for Frame {
122    fn frame_mut(&mut self) -> &mut Frame {
123        self
124    }
125}
126
127/// What a frame's offsets count in.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum FrameMode {
130    /// `RANGE` — offsets are values compared against the `ORDER BY` key. The
131    /// default in the grammar.
132    Range,
133    /// `ROWS` — offsets are row counts.
134    Rows,
135    /// `GROUPS` — offsets are counts of peer groups.
136    Groups,
137}
138
139impl FrameMode {
140    /// The keyword, as written.
141    pub fn as_str(self) -> &'static str {
142        match self {
143            FrameMode::Range => "RANGE",
144            FrameMode::Rows => "ROWS",
145            FrameMode::Groups => "GROUPS",
146        }
147    }
148}
149
150/// Which rows a frame leaves out despite their being inside it.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum FrameExclusion {
153    /// `EXCLUDE NO OTHERS` — the default; excludes nothing.
154    NoOthers,
155    /// `EXCLUDE CURRENT ROW`.
156    CurrentRow,
157    /// `EXCLUDE GROUP` — the current row and all its peers.
158    Group,
159    /// `EXCLUDE TIES` — the current row's peers, but not the row itself.
160    Ties,
161}
162
163impl FrameExclusion {
164    /// The keyword, as written after `EXCLUDE`.
165    pub fn as_str(self) -> &'static str {
166        match self {
167            FrameExclusion::NoOthers => "NO OTHERS",
168            FrameExclusion::CurrentRow => "CURRENT ROW",
169            FrameExclusion::Group => "GROUP",
170            FrameExclusion::Ties => "TIES",
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use keelson_sqlcheck::testing::assert_frag_sql;
178
179    use super::*;
180    use crate::dialect::testing::Numbered;
181    use crate::expr::arg;
182    use crate::value::Value;
183    use crate::writer::build;
184
185    /// A frame clause is the tail of a window definition. The frame carries an
186    /// `ORDER BY` because `GROUPS` mode and the `EXCLUDE` variants need one.
187    const FRAME: &str = r#"SELECT count(*) OVER (ORDER BY "id" {}) FROM users"#;
188
189    fn sql(f: &Frame) -> String {
190        build(&Numbered, f).expect("render").0
191    }
192
193    #[test]
194    fn an_untouched_frame_writes_nothing() {
195        assert_frag_sql(FRAME, &sql(&Frame::default()), "");
196        assert!(Frame::default().is_empty());
197    }
198
199    #[test]
200    fn a_mode_alone_gets_the_grammars_default_start() {
201        // `ROWS` is not a frame_clause on its own; frame_start is mandatory and
202        // UNBOUNDED PRECEDING is what it defaults to.
203        assert_frag_sql(
204            FRAME,
205            &sql(&Frame::new(FrameMode::Rows)),
206            "ROWS UNBOUNDED PRECEDING",
207        );
208    }
209
210    #[test]
211    fn an_exclusion_alone_still_produces_a_complete_frame() {
212        // Both defaults apply at once: RANGE, and UNBOUNDED PRECEDING.
213        let mut f = Frame::default();
214        f.set_exclusion(FrameExclusion::Ties);
215        assert!(!f.is_empty());
216        assert_frag_sql(FRAME, &sql(&f), "RANGE UNBOUNDED PRECEDING EXCLUDE TIES");
217    }
218
219    #[test]
220    fn a_start_alone_is_the_single_bound_form_with_no_between() {
221        // The first production: no BETWEEN, no AND.
222        let mut f = Frame::new(FrameMode::Rows);
223        f.set_start("CURRENT ROW");
224        assert_frag_sql(FRAME, &sql(&f), "ROWS CURRENT ROW");
225    }
226
227    #[test]
228    fn an_end_bound_is_what_introduces_between() {
229        // The second production. The end alone still means BETWEEN, with the
230        // default start.
231        let mut f = Frame::new(FrameMode::Rows);
232        f.set_end("CURRENT ROW");
233        assert_frag_sql(
234            FRAME,
235            &sql(&f),
236            "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
237        );
238
239        f.set_start("UNBOUNDED PRECEDING");
240        assert_frag_sql(
241            FRAME,
242            &sql(&f),
243            "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW",
244        );
245    }
246
247    #[test]
248    fn bounds_may_bind_arguments_and_are_numbered_left_to_right() {
249        // `offset PRECEDING` with a bound offset. bob adds the wrong length to
250        // `start` for the end bound and re-uses the start's index; there is one
251        // counter here, so it cannot happen.
252        let mut f = Frame::new(FrameMode::Groups);
253        f.set_start(Expr::join((arg(1i32), Expr::raw("PRECEDING"))));
254        f.set_end(Expr::join((arg(2i32), Expr::raw("FOLLOWING"))));
255        f.set_exclusion(FrameExclusion::CurrentRow);
256
257        let (rendered, args) = build(&Numbered, &f).unwrap();
258        assert_frag_sql(
259            FRAME,
260            &rendered,
261            "GROUPS BETWEEN $1 PRECEDING AND $2 FOLLOWING EXCLUDE CURRENT ROW",
262        );
263        assert_eq!(args, vec![Value::I32(1), Value::I32(2)]);
264    }
265
266    #[test]
267    fn every_mode_and_exclusion_has_its_spelling() {
268        for (mode, keyword) in [
269            (FrameMode::Range, "RANGE"),
270            (FrameMode::Rows, "ROWS"),
271            (FrameMode::Groups, "GROUPS"),
272        ] {
273            assert_frag_sql(
274                FRAME,
275                &sql(&Frame::new(mode)),
276                &format!("{keyword} UNBOUNDED PRECEDING"),
277            );
278        }
279
280        for (exclusion, keyword) in [
281            (FrameExclusion::NoOthers, "NO OTHERS"),
282            (FrameExclusion::CurrentRow, "CURRENT ROW"),
283            (FrameExclusion::Group, "GROUP"),
284            (FrameExclusion::Ties, "TIES"),
285        ] {
286            let mut f = Frame::new(FrameMode::Rows);
287            f.set_exclusion(exclusion);
288            assert_frag_sql(
289                FRAME,
290                &sql(&f),
291                &format!("ROWS UNBOUNDED PRECEDING EXCLUDE {keyword}"),
292            );
293        }
294    }
295}