use crate::pg::accumulator::SqlAccumulator;
use crate::query::order::Direction;
#[derive(Clone, Debug)]
pub(crate) enum WindowTerm {
Column(&'static str),
Expr {
node: Box<crate::expr::node::ExprNode>,
alias: &'static str,
},
}
#[derive(Debug, Clone, Default)]
pub struct WindowSpec {
pub(crate) partition_by: Vec<WindowTerm>,
pub(crate) order_by: Vec<(WindowTerm, 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(WindowTerm::Column(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((
WindowTerm::Column(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((
WindowTerm::Column(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_term_pair_qualified = |term: &WindowTerm| match term {
WindowTerm::Column(s) => s.starts_with("l.") || s.starts_with("r."),
WindowTerm::Expr { node, .. } => is_allowed_window_expr_node(node),
};
self.partition_by.iter().all(is_term_pair_qualified)
&& self
.order_by
.iter()
.all(|(term, _)| is_term_pair_qualified(term))
}
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, term) in self.partition_by.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
match term {
WindowTerm::Column(c) => acc.push_sql(c),
WindowTerm::Expr { node, alias } => crate::expr::sql::emit_expr(
acc,
node,
crate::query::portable::SqlEmitContext::joined(alias),
)
.expect(
"WindowTerm::Expr nodes are validated by \
is_allowed_window_expr_node at the pair-qualified gate \
before emit — reaching this arm with an Err indicates \
the gate and emitter are out of sync",
),
}
}
spacer = true;
}
if !self.order_by.is_empty() {
if spacer {
acc.push_sql(" ");
}
acc.push_sql("ORDER BY ");
for (i, (term, d)) in self.order_by.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
match term {
WindowTerm::Column(c) => acc.push_sql(c),
WindowTerm::Expr { node, alias } => crate::expr::sql::emit_expr(
acc,
node,
crate::query::portable::SqlEmitContext::joined(alias),
)
.expect(
"WindowTerm::Expr nodes are validated by \
is_allowed_window_expr_node at the pair-qualified gate \
before emit — reaching this arm with an Err indicates \
the gate and emitter are out of sync",
),
}
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"),
}
}
pub(crate) fn is_allowed_window_expr_node(node: &crate::expr::node::ExprNode) -> bool {
use crate::expr::node::ExprNode;
match node {
ExprNode::Field { .. } => true,
ExprNode::Literal(_) | ExprNode::CurrentYear | ExprNode::IntervalLiteral { .. } => true,
ExprNode::Add(lhs, rhs)
| ExprNode::Sub(lhs, rhs)
| ExprNode::Mul(lhs, rhs)
| ExprNode::Div(lhs, rhs)
| ExprNode::And(lhs, rhs)
| ExprNode::Or(lhs, rhs) => {
is_allowed_window_expr_node(lhs) && is_allowed_window_expr_node(rhs)
}
ExprNode::Not(inner) | ExprNode::IsNull(inner) | ExprNode::IsNotNull(inner) => {
is_allowed_window_expr_node(inner)
}
ExprNode::Coalesce(operands) => operands.iter().all(is_allowed_window_expr_node),
ExprNode::Cmp { lhs, rhs, .. } => {
is_allowed_window_expr_node(lhs) && is_allowed_window_expr_node(rhs)
}
ExprNode::Case { arms, otherwise } => {
arms.iter().all(|(cond, val)| {
is_allowed_window_expr_node(cond) && is_allowed_window_expr_node(val)
}) && is_allowed_window_expr_node(otherwise)
}
ExprNode::RawSql(_) => false,
ExprNode::ArrayLength { .. } => false,
ExprNode::TsMatch { .. } | ExprNode::TsRank { .. } | ExprNode::TsRankCd { .. } => false,
ExprNode::Aggregate { .. } | ExprNode::GroupingVariadic { .. } => false,
ExprNode::Exists(_) | ExprNode::Subquery(_) => false,
ExprNode::InSubquery { .. } | ExprNode::QuantifiedSubquery { .. } => false,
ExprNode::OuterRef { .. }
| ExprNode::OuterRefColumn { .. }
| ExprNode::OuterRefAlias { .. }
| ExprNode::Excluded { .. } => false,
#[cfg(feature = "trgm")]
ExprNode::TrgmSimilarTo { .. } | ExprNode::TrgmSimilarityScore { .. } => true,
#[cfg(feature = "spatial")]
ExprNode::Spatial(_) => false,
#[cfg(feature = "spatial")]
ExprNode::RowAggregate { .. } => false,
}
}
#[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![WindowTerm::Column("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![
WindowTerm::Column("org_id"),
WindowTerm::Column("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![(WindowTerm::Column("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![(WindowTerm::Column("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![
(WindowTerm::Column("created_at"), Direction::Asc),
(WindowTerm::Column("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![WindowTerm::Column("org_id")],
order_by: vec![(WindowTerm::Column("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![WindowTerm::Column("org_id")],
order_by: vec![(WindowTerm::Column("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![WindowTerm::Column("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![WindowTerm::Column("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![WindowTerm::Column("l.id"), WindowTerm::Column("r.name")],
order_by: vec![(WindowTerm::Column("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![WindowTerm::Column("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![(WindowTerm::Column("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![WindowTerm::Column("l.id"), WindowTerm::Column("name")],
order_by: vec![(WindowTerm::Column("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![WindowTerm::Column("la.path_count")],
..Default::default()
};
assert!(
!spec.is_pair_qualified(),
"`la.` prefix (closure-pair alias) is not a pair-window-qualified prefix"
);
}
#[test]
fn partition_by_pair_expr_emits_qualified_field() {
use crate::expr::node::ExprNode;
let spec = WindowSpec {
partition_by: vec![crate::expr::window::WindowTerm::Expr {
node: Box::new(ExprNode::Field { column: "score" }),
alias: "l",
}],
..Default::default()
};
assert_eq!(emit(&spec), " OVER (PARTITION BY l.score)");
}
#[test]
fn order_by_pair_expr_desc_emits_qualified_arithmetic() {
use crate::expr::node::ExprNode;
use crate::query::condition::FilterValue;
let spec = WindowSpec {
order_by: vec![(
crate::expr::window::WindowTerm::Expr {
node: Box::new(ExprNode::Mul(
Box::new(ExprNode::Field { column: "score" }),
Box::new(ExprNode::Literal(FilterValue::I32(10))),
)),
alias: "l",
},
Direction::Desc,
)],
..Default::default()
};
assert_eq!(emit(&spec), " OVER (ORDER BY l.score * $1 DESC)");
}
#[test]
fn is_pair_qualified_true_for_expr_term() {
use crate::expr::node::ExprNode;
let spec = WindowSpec {
partition_by: vec![crate::expr::window::WindowTerm::Expr {
node: Box::new(ExprNode::Field { column: "id" }),
alias: "l",
}],
..Default::default()
};
assert!(
spec.is_pair_qualified(),
"Expr term must be pair-qualified by construction"
);
}
#[test]
fn is_pair_qualified_false_when_bare_column_mixed_with_expr() {
use crate::expr::node::ExprNode;
let spec = WindowSpec {
partition_by: vec![
crate::expr::window::WindowTerm::Column("bare_col"),
crate::expr::window::WindowTerm::Expr {
node: Box::new(ExprNode::Field { column: "id" }),
alias: "l",
},
],
..Default::default()
};
assert!(
!spec.is_pair_qualified(),
"bare Column entry poisons the spec"
);
}
#[test]
fn is_pair_qualified_false_for_denied_array_length() {
use crate::expr::node::ExprNode;
let spec = WindowSpec {
partition_by: vec![WindowTerm::Expr {
node: Box::new(ExprNode::ArrayLength { column: "tags" }),
alias: "l",
}],
..Default::default()
};
assert!(
!spec.is_pair_qualified(),
"ArrayLength emits bare column, must not be pair-qualified"
);
}
#[test]
fn is_pair_qualified_false_for_denied_raw_sql() {
use crate::expr::node::ExprNode;
let spec = WindowSpec {
partition_by: vec![WindowTerm::Expr {
node: Box::new(ExprNode::RawSql("custom_expr(col)")),
alias: "l",
}],
..Default::default()
};
assert!(
!spec.is_pair_qualified(),
"RawSql emits verbatim fragment, must not be pair-qualified"
);
}
}