From ac048c7f7b62e07ebbec0cde7281cc95a1f19cea Mon Sep 17 00:00:00 2001 From: Ben Herzberg Date: Tue, 15 Sep 2026 17:39:24 +0300 Subject: [PATCH 1/2] PostgreSQL: Support window frame exclusions --- src/ast/mod.rs | 33 ++++++++++++++++++++++++++++++++- src/dialect/generic.rs | 4 ++++ src/dialect/mod.rs | 7 +++++++ src/dialect/postgresql.rs | 5 +++++ src/parser/mod.rs | 26 ++++++++++++++++++++++++++ tests/sqlparser_postgres.rs | 28 ++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 20058b83ab..bf1c8e8848 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -2356,6 +2356,9 @@ impl fmt::Display for WindowSpec { } else { write!(f, "{} {}", window_frame.units, window_frame.start_bound)?; } + if let Some(exclusion) = &window_frame.exclusion { + write!(f, " {exclusion}")?; + } } Ok(()) } @@ -2378,7 +2381,8 @@ pub struct WindowFrame { /// indicates the shorthand form (e.g. `ROWS 1 PRECEDING`), which must /// behave the same as `end_bound = WindowFrameBound::CurrentRow`. pub end_bound: Option, - // TBD: EXCLUDE + /// The optional exclusion clause for the window frame. + pub exclusion: Option, } impl Default for WindowFrame { @@ -2390,6 +2394,7 @@ impl Default for WindowFrame { units: WindowFrameUnits::Range, start_bound: WindowFrameBound::Preceding(None), end_bound: None, + exclusion: None, } } } @@ -2465,6 +2470,32 @@ impl fmt::Display for WindowFrameBound { } } +/// Specifies rows to exclude from a window frame. +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum WindowFrameExclusion { + /// `EXCLUDE CURRENT ROW` + CurrentRow, + /// `EXCLUDE GROUP` + Group, + /// `EXCLUDE TIES` + Ties, + /// `EXCLUDE NO OTHERS` + NoOthers, +} + +impl fmt::Display for WindowFrameExclusion { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + WindowFrameExclusion::CurrentRow => "EXCLUDE CURRENT ROW", + WindowFrameExclusion::Group => "EXCLUDE GROUP", + WindowFrameExclusion::Ties => "EXCLUDE TIES", + WindowFrameExclusion::NoOthers => "EXCLUDE NO OTHERS", + }) + } +} + #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index d408cb181a..b769e23efc 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -141,6 +141,10 @@ impl Dialect for GenericDialect { true } + fn supports_window_frame_exclusion(&self) -> bool { + true + } + fn supports_limit_comma(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 7c4744c5a7..d532de7875 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1224,6 +1224,13 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports window frame exclusions, e.g. + /// `ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING EXCLUDE TIES`. + /// See . + fn supports_window_frame_exclusion(&self) -> bool { + false + } + /// Returns true if the dialect supports the `LOAD DATA` statement fn supports_load_data(&self) -> bool { false diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index 3bec6ceba3..403f0182e8 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -236,6 +236,11 @@ impl Dialect for PostgreSqlDialect { true } + /// see + fn supports_window_frame_exclusion(&self) -> bool { + true + } + /// see fn supports_factorial_operator(&self) -> bool { true diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 15f135fffa..b32f2d4db5 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -2705,13 +2705,39 @@ impl<'a> Parser<'a> { } else { (self.parse_window_frame_bound()?, None) }; + let exclusion = if self.dialect.supports_window_frame_exclusion() + && self.parse_keyword(Keyword::EXCLUDE) + { + Some(self.parse_window_frame_exclusion()?) + } else { + None + }; Ok(WindowFrame { units, start_bound, end_bound, + exclusion, }) } + /// Parse a window frame exclusion clause following `EXCLUDE`. + pub fn parse_window_frame_exclusion(&mut self) -> Result { + if self.parse_keywords(&[Keyword::CURRENT, Keyword::ROW]) { + Ok(WindowFrameExclusion::CurrentRow) + } else if self.parse_keyword(Keyword::GROUP) { + Ok(WindowFrameExclusion::Group) + } else if self.parse_keyword(Keyword::TIES) { + Ok(WindowFrameExclusion::Ties) + } else if self.parse_keyword(Keyword::NO) + && self.consume_token(&Token::make_word("OTHERS", None)) + { + Ok(WindowFrameExclusion::NoOthers) + } else { + let next_token = self.next_token(); + self.expected("CURRENT ROW, GROUP, TIES, or NO OTHERS", next_token) + } + } + /// Parse a window frame bound: `CURRENT ROW` or ` PRECEDING|FOLLOWING`. pub fn parse_window_frame_bound(&mut self) -> Result { if self.parse_keywords(&[Keyword::CURRENT, Keyword::ROW]) { diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index d71e49b27a..d1852cfe56 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9953,3 +9953,31 @@ fn parse_insert_by_name_keywords_as_table_and_alias() { statement => panic!("Expected INSERT statement, got: {statement:?}"), } } + +#[test] +fn parse_window_frame_exclusion() { + let dialects = pg_and_generic(); + for sql in [ + "SELECT sum(1) OVER (ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING EXCLUDE TIES)", + "SELECT sum(1) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)", + "SELECT sum(1) OVER (ROWS CURRENT ROW EXCLUDE GROUP)", + "SELECT sum(1) OVER (ROWS CURRENT ROW EXCLUDE NO OTHERS)", + ] { + dialects.verified_stmt(sql); + } + + let invalid = "SELECT sum(1) OVER (ROWS CURRENT ROW EXCLUDE ALL)"; + assert_eq!( + pg().parse_sql_statements(invalid).unwrap_err(), + ParserError::ParserError( + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: ALL".to_string() + ) + ); + + let unsupported = all_dialects_where(|d| !d.supports_window_frame_exclusion()); + let sql = "SELECT sum(1) OVER (ROWS CURRENT ROW EXCLUDE TIES)"; + for dialect in unsupported.dialects { + let parser = TestedDialects::new(vec![dialect]); + assert!(parser.parse_sql_statements(sql).is_err()); + } +} From 6aea50ea9e8ebdab2fc6cf4c135fb808c6933a09 Mon Sep 17 00:00:00 2001 From: Ben Herzberg Date: Wed, 16 Sep 2026 10:42:40 +0300 Subject: [PATCH 2/2] Address review: factor EXCLUDE prefix in WindowFrameExclusion Display --- src/ast/mod.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index bf1c8e8848..34735561c9 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -2487,12 +2487,16 @@ pub enum WindowFrameExclusion { impl fmt::Display for WindowFrameExclusion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(match self { - WindowFrameExclusion::CurrentRow => "EXCLUDE CURRENT ROW", - WindowFrameExclusion::Group => "EXCLUDE GROUP", - WindowFrameExclusion::Ties => "EXCLUDE TIES", - WindowFrameExclusion::NoOthers => "EXCLUDE NO OTHERS", - }) + write!( + f, + "EXCLUDE {}", + match self { + WindowFrameExclusion::CurrentRow => "CURRENT ROW", + WindowFrameExclusion::Group => "GROUP", + WindowFrameExclusion::Ties => "TIES", + WindowFrameExclusion::NoOthers => "NO OTHERS", + } + ) } }