use crate::pg::accumulator::SqlAccumulator;
use crate::query::order::Direction;
#[derive(Debug, Clone, Default)]
pub struct WindowSpec {
pub(crate) partition_by: Vec<&'static str>,
pub(crate) order_by: Vec<(&'static str, Direction)>,
pub(crate) frame: Option<Frame>,
}
#[derive(Debug, Clone)]
pub struct Frame {
pub(crate) kind: FrameKind,
pub(crate) start: FrameBound,
pub(crate) end: FrameBound,
pub(crate) exclude: Option<FrameExclude>,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum FrameKind {
Rows,
Range,
Groups,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum FrameBound {
UnboundedPreceding,
Preceding(u64),
CurrentRow,
Following(u64),
UnboundedFollowing,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum FrameExclude {
CurrentRow,
Group,
Ties,
NoOthers,
}
pub struct WindowBuilder(WindowSpec);
impl Default for WindowBuilder {
fn default() -> Self {
Self::new()
}
}
impl WindowBuilder {
pub fn new() -> Self {
Self(WindowSpec::default())
}
pub fn partition_by<M, V, S>(mut self, f: S) -> Self
where
M: crate::model::Model,
S: crate::query::field::IntoSqlField<M, V>,
{
self.0.partition_by.push(f.into_sql_field().column());
self
}
pub fn order_by<M, V, S>(mut self, f: S) -> Self
where
M: crate::model::Model,
S: crate::query::field::IntoSqlField<M, V>,
{
self.0
.order_by
.push((f.into_sql_field().column(), Direction::Asc));
self
}
pub fn order_by_desc<M, V, S>(mut self, f: S) -> Self
where
M: crate::model::Model,
S: crate::query::field::IntoSqlField<M, V>,
{
self.0
.order_by
.push((f.into_sql_field().column(), Direction::Desc));
self
}
pub fn rows(mut self, start: FrameBound, end: FrameBound) -> Self {
self.0.frame = Some(Frame {
kind: FrameKind::Rows,
start,
end,
exclude: None,
});
self
}
pub fn range(mut self, start: FrameBound, end: FrameBound) -> Self {
self.0.frame = Some(Frame {
kind: FrameKind::Range,
start,
end,
exclude: None,
});
self
}
pub fn groups(mut self, start: FrameBound, end: FrameBound) -> Self {
self.0.frame = Some(Frame {
kind: FrameKind::Groups,
start,
end,
exclude: None,
});
self
}
pub fn exclude(mut self, ex: FrameExclude) -> Self {
if let Some(f) = self.0.frame.as_mut() {
f.exclude = Some(ex);
}
self
}
pub(crate) fn build(self) -> WindowSpec {
self.0
}
}
impl WindowSpec {
pub(crate) fn is_pair_qualified(&self) -> bool {
let is_pair_prefixed = |s: &str| s.starts_with("l.") || s.starts_with("r.");
self.partition_by.iter().all(|c| is_pair_prefixed(c))
&& self.order_by.iter().all(|(c, _)| is_pair_prefixed(c))
}
pub(crate) fn emit(&self, acc: &mut SqlAccumulator) {
acc.push_sql(" OVER (");
let mut spacer = false;
if !self.partition_by.is_empty() {
acc.push_sql("PARTITION BY ");
for (i, c) in self.partition_by.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_sql(c);
}
spacer = true;
}
if !self.order_by.is_empty() {
if spacer {
acc.push_sql(" ");
}
acc.push_sql("ORDER BY ");
for (i, (c, d)) in self.order_by.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_sql(c);
acc.push_sql(match d {
Direction::Asc => " ASC",
Direction::Desc => " DESC",
});
}
spacer = true;
}
if let Some(frame) = &self.frame {
if spacer {
acc.push_sql(" ");
}
acc.push_sql(match frame.kind {
FrameKind::Rows => "ROWS BETWEEN ",
FrameKind::Range => "RANGE BETWEEN ",
FrameKind::Groups => "GROUPS BETWEEN ",
});
emit_bound(acc, frame.start);
acc.push_sql(" AND ");
emit_bound(acc, frame.end);
if let Some(ex) = frame.exclude {
acc.push_sql(match ex {
FrameExclude::CurrentRow => " EXCLUDE CURRENT ROW",
FrameExclude::Group => " EXCLUDE GROUP",
FrameExclude::Ties => " EXCLUDE TIES",
FrameExclude::NoOthers => " EXCLUDE NO OTHERS",
});
}
}
acc.push_sql(")");
}
}
fn emit_bound(acc: &mut SqlAccumulator, bound: FrameBound) {
match bound {
FrameBound::UnboundedPreceding => acc.push_sql("UNBOUNDED PRECEDING"),
FrameBound::Preceding(n) => {
acc.push_bind(n as i64);
acc.push_sql(" PRECEDING");
}
FrameBound::CurrentRow => acc.push_sql("CURRENT ROW"),
FrameBound::Following(n) => {
acc.push_bind(n as i64);
acc.push_sql(" FOLLOWING");
}
FrameBound::UnboundedFollowing => acc.push_sql("UNBOUNDED FOLLOWING"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pg::accumulator::SqlAccumulator;
fn emit(spec: &WindowSpec) -> String {
let mut acc = SqlAccumulator::new("");
spec.emit(&mut acc);
acc.sql().to_string()
}
#[test]
fn empty_spec_emits_over_parens() {
let spec = WindowSpec::default();
assert_eq!(emit(&spec), " OVER ()");
}
#[test]
fn partition_by_single_column() {
let spec = WindowSpec {
partition_by: vec!["org_id"],
..Default::default()
};
assert_eq!(emit(&spec), " OVER (PARTITION BY org_id)");
}
#[test]
fn partition_by_two_columns() {
let spec = WindowSpec {
partition_by: vec!["org_id", "department_id"],
..Default::default()
};
assert_eq!(emit(&spec), " OVER (PARTITION BY org_id, department_id)");
}
#[test]
fn order_by_asc() {
let spec = WindowSpec {
order_by: vec![("created_at", Direction::Asc)],
..Default::default()
};
assert_eq!(emit(&spec), " OVER (ORDER BY created_at ASC)");
}
#[test]
fn order_by_desc() {
let spec = WindowSpec {
order_by: vec![("amount", Direction::Desc)],
..Default::default()
};
assert_eq!(emit(&spec), " OVER (ORDER BY amount DESC)");
}
#[test]
fn order_by_asc_and_desc() {
let spec = WindowSpec {
order_by: vec![("created_at", Direction::Asc), ("amount", Direction::Desc)],
..Default::default()
};
assert_eq!(emit(&spec), " OVER (ORDER BY created_at ASC, amount DESC)");
}
#[test]
fn partition_and_order_by_separated_by_space() {
let spec = WindowSpec {
partition_by: vec!["org_id"],
order_by: vec![("created_at", Direction::Asc)],
..Default::default()
};
assert_eq!(
emit(&spec),
" OVER (PARTITION BY org_id ORDER BY created_at ASC)"
);
}
#[test]
fn rows_unbounded_preceding_to_current_row() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Rows,
start: FrameBound::UnboundedPreceding,
end: FrameBound::CurrentRow,
exclude: None,
}),
..Default::default()
};
assert_eq!(
emit(&spec),
" OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)"
);
}
#[test]
fn rows_preceding_n_binds_as_parameter() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Rows,
start: FrameBound::Preceding(3),
end: FrameBound::CurrentRow,
exclude: None,
}),
..Default::default()
};
let mut acc = SqlAccumulator::new("");
spec.emit(&mut acc);
let sql = acc.sql().to_string();
assert!(
sql.contains("ROWS BETWEEN $1 PRECEDING AND CURRENT ROW"),
"got: {sql}"
);
}
#[test]
fn rows_current_row_to_following_n_binds_as_parameter() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Rows,
start: FrameBound::CurrentRow,
end: FrameBound::Following(2),
exclude: None,
}),
..Default::default()
};
let mut acc = SqlAccumulator::new("");
spec.emit(&mut acc);
let sql = acc.sql().to_string();
assert!(
sql.contains("ROWS BETWEEN CURRENT ROW AND $1 FOLLOWING"),
"got: {sql}"
);
}
#[test]
fn range_unbounded_both_sides() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Range,
start: FrameBound::UnboundedPreceding,
end: FrameBound::UnboundedFollowing,
exclude: None,
}),
..Default::default()
};
assert_eq!(
emit(&spec),
" OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)"
);
}
#[test]
fn groups_current_row_to_following_n() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Groups,
start: FrameBound::CurrentRow,
end: FrameBound::Following(1),
exclude: None,
}),
..Default::default()
};
let mut acc = SqlAccumulator::new("");
spec.emit(&mut acc);
let sql = acc.sql().to_string();
assert!(
sql.contains("GROUPS BETWEEN CURRENT ROW AND $1 FOLLOWING"),
"got: {sql}"
);
}
#[test]
fn exclude_current_row() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Rows,
start: FrameBound::UnboundedPreceding,
end: FrameBound::CurrentRow,
exclude: Some(FrameExclude::CurrentRow),
}),
..Default::default()
};
let sql = emit(&spec);
assert!(sql.contains("EXCLUDE CURRENT ROW"), "got: {sql}");
}
#[test]
fn exclude_group() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Rows,
start: FrameBound::UnboundedPreceding,
end: FrameBound::UnboundedFollowing,
exclude: Some(FrameExclude::Group),
}),
..Default::default()
};
let sql = emit(&spec);
assert!(sql.contains("EXCLUDE GROUP"), "got: {sql}");
}
#[test]
fn exclude_ties() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Rows,
start: FrameBound::UnboundedPreceding,
end: FrameBound::UnboundedFollowing,
exclude: Some(FrameExclude::Ties),
}),
..Default::default()
};
let sql = emit(&spec);
assert!(sql.contains("EXCLUDE TIES"), "got: {sql}");
}
#[test]
fn exclude_no_others() {
let spec = WindowSpec {
frame: Some(Frame {
kind: FrameKind::Rows,
start: FrameBound::UnboundedPreceding,
end: FrameBound::UnboundedFollowing,
exclude: Some(FrameExclude::NoOthers),
}),
..Default::default()
};
let sql = emit(&spec);
assert!(sql.contains("EXCLUDE NO OTHERS"), "got: {sql}");
}
#[test]
fn full_composition_partition_order_rows_exclude() {
let spec = WindowSpec {
partition_by: vec!["org_id"],
order_by: vec![("created_at", Direction::Asc)],
frame: Some(Frame {
kind: FrameKind::Rows,
start: FrameBound::Preceding(3),
end: FrameBound::CurrentRow,
exclude: Some(FrameExclude::Ties),
}),
};
let mut acc = SqlAccumulator::new("");
spec.emit(&mut acc);
let sql = acc.sql().to_string();
assert!(
sql.contains("PARTITION BY org_id"),
"missing PARTITION BY: {sql}"
);
assert!(
sql.contains("ORDER BY created_at ASC"),
"missing ORDER BY: {sql}"
);
assert!(
sql.contains("ROWS BETWEEN $1 PRECEDING AND CURRENT ROW"),
"missing ROWS frame: {sql}"
);
assert!(sql.contains("EXCLUDE TIES"), "missing EXCLUDE: {sql}");
}
#[test]
fn is_pair_qualified_true_for_empty_spec() {
let spec = WindowSpec::default();
assert!(
spec.is_pair_qualified(),
"empty WindowSpec must be vacuously pair-qualified"
);
}
#[test]
fn is_pair_qualified_true_for_left_qualified_partition() {
let spec = WindowSpec {
partition_by: vec!["l.id"],
..Default::default()
};
assert!(
spec.is_pair_qualified(),
"`PARTITION BY l.id` must be pair-qualified"
);
}
#[test]
fn is_pair_qualified_true_for_right_qualified_partition() {
let spec = WindowSpec {
partition_by: vec!["r.id"],
..Default::default()
};
assert!(
spec.is_pair_qualified(),
"`PARTITION BY r.id` must be pair-qualified"
);
}
#[test]
fn is_pair_qualified_true_for_mixed_sides() {
let spec = WindowSpec {
partition_by: vec!["l.id", "r.name"],
order_by: vec![("l.score", Direction::Desc)],
..Default::default()
};
assert!(
spec.is_pair_qualified(),
"mixed l./r. qualifiers across partition+order must be pair-qualified"
);
}
#[test]
fn is_pair_qualified_false_for_bare_partition() {
let spec = WindowSpec {
partition_by: vec!["id"],
..Default::default()
};
assert!(
!spec.is_pair_qualified(),
"bare `PARTITION BY id` must NOT be pair-qualified — ambiguous in self-joins"
);
}
#[test]
fn is_pair_qualified_false_for_bare_order_by() {
let spec = WindowSpec {
order_by: vec![("score", Direction::Desc)],
..Default::default()
};
assert!(
!spec.is_pair_qualified(),
"bare `ORDER BY score DESC` must NOT be pair-qualified"
);
}
#[test]
fn is_pair_qualified_false_when_any_entry_is_bare() {
let spec = WindowSpec {
partition_by: vec!["l.id", "name"],
order_by: vec![("r.score", Direction::Desc)],
..Default::default()
};
assert!(
!spec.is_pair_qualified(),
"any single bare column must disqualify the whole spec"
);
}
#[test]
fn is_pair_qualified_false_for_unrelated_prefix() {
let spec = WindowSpec {
partition_by: vec!["la.path_count"],
..Default::default()
};
assert!(
!spec.is_pair_qualified(),
"`la.` prefix (closure-pair alias) is not a pair-window-qualified prefix"
);
}
}