From 9aa96a47b503b99aa7db4905c092dbddb9abbd01 Mon Sep 17 00:00:00 2001 From: Cora Sutton Date: Sat, 12 Sep 2026 17:47:28 +0000 Subject: [PATCH] DuckDB: Support window-frame EXCLUDE --- src/ast/mod.rs | 58 +++++-- src/dialect/duckdb.rs | 4 + src/dialect/generic.rs | 4 + src/dialect/mod.rs | 5 + src/dialect/postgresql.rs | 4 + src/dialect/sqlite.rs | 4 + src/parser/mod.rs | 43 +++++ tests/sqlparser_common.rs | 331 ++++++++++++++++++++++++++++++++++++++ tests/sqlparser_duckdb.rs | 70 ++++++++ 9 files changed, 513 insertions(+), 10 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 20058b83ab..4a7288c094 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -2347,15 +2347,7 @@ impl fmt::Display for WindowSpec { if !is_first { SpaceOrNewline.fmt(f)?; } - if let Some(end_bound) = &window_frame.end_bound { - write!( - f, - "{} BETWEEN {} AND {}", - window_frame.units, window_frame.start_bound, end_bound - )?; - } else { - write!(f, "{} {}", window_frame.units, window_frame.start_bound)?; - } + window_frame.fmt(f)?; } Ok(()) } @@ -2378,7 +2370,26 @@ 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 + /// Rows excluded from the window frame. + pub exclusion: Option, +} + +impl fmt::Display for WindowFrame { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if let Some(end_bound) = &self.end_bound { + write!( + f, + "{} BETWEEN {} AND {}", + self.units, self.start_bound, end_bound + )?; + } else { + write!(f, "{} {}", self.units, self.start_bound)?; + } + if let Some(exclusion) = &self.exclusion { + write!(f, " EXCLUDE {exclusion}")?; + } + Ok(()) + } } impl Default for WindowFrame { @@ -2390,6 +2401,7 @@ impl Default for WindowFrame { units: WindowFrameUnits::Range, start_bound: WindowFrameBound::Preceding(None), end_bound: None, + exclusion: None, } } } @@ -2417,6 +2429,32 @@ impl fmt::Display for WindowFrameUnits { } } +/// Rows excluded 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 { + /// `CURRENT ROW`. + CurrentRow, + /// `GROUP`. + Group, + /// `TIES`. + Ties, + /// `NO OTHERS`. + NoOthers, +} + +impl fmt::Display for WindowFrameExclusion { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + WindowFrameExclusion::CurrentRow => "CURRENT ROW", + WindowFrameExclusion::Group => "GROUP", + WindowFrameExclusion::Ties => "TIES", + WindowFrameExclusion::NoOthers => "NO OTHERS", + }) + } +} + /// Specifies Ignore / Respect NULL within window functions. /// For example /// `FIRST_VALUE(column2) IGNORE NULLS OVER (PARTITION BY column1)` diff --git a/src/dialect/duckdb.rs b/src/dialect/duckdb.rs index 2e3673bc4c..029f04c013 100644 --- a/src/dialect/duckdb.rs +++ b/src/dialect/duckdb.rs @@ -45,6 +45,10 @@ impl Dialect for DuckDbDialect { true } + fn supports_window_frame_exclusion(&self) -> bool { + true + } + fn supports_group_by_expr(&self) -> bool { true } diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index d408cb181a..7a9af1a180 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -45,6 +45,10 @@ impl Dialect for GenericDialect { true } + fn supports_window_frame_exclusion(&self) -> bool { + true + } + fn supports_partition_by_after_order_by(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index ff83a4da61..341d91c014 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -341,6 +341,11 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports `EXCLUDE` in window frames. + fn supports_window_frame_exclusion(&self) -> bool { + false + } + /// Returns true if the dialect supports `ARRAY_AGG() [WITHIN GROUP (ORDER BY)]` expressions. /// Otherwise, the dialect should expect an `ORDER BY` without the `WITHIN GROUP` clause, e.g. [`ANSI`] /// diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index 3bec6ceba3..8c86ab3b05 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -97,6 +97,10 @@ impl Dialect for PostgreSqlDialect { true } + fn supports_window_frame_exclusion(&self) -> bool { + true + } + fn is_reserved_for_identifier(&self, kw: Keyword) -> bool { if matches!(kw, Keyword::INTERVAL) { false diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs index d549c75076..c5e866176f 100644 --- a/src/dialect/sqlite.rs +++ b/src/dialect/sqlite.rs @@ -58,6 +58,10 @@ impl Dialect for SQLiteDialect { true } + fn supports_window_frame_exclusion(&self) -> bool { + true + } + fn supports_start_transaction_modifier(&self) -> bool { true } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5edc437145..a1fe1d82e0 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -2693,13 +2693,56 @@ 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 the exclusion that follows `EXCLUDE` in a window frame. + pub fn parse_window_frame_exclusion(&mut self) -> Result { + match self.parse_one_of_keywords(&[ + Keyword::CURRENT, + Keyword::GROUP, + Keyword::TIES, + Keyword::NO, + ]) { + Some(Keyword::CURRENT) => { + self.expect_keyword_is(Keyword::ROW)?; + Ok(WindowFrameExclusion::CurrentRow) + } + Some(Keyword::GROUP) => Ok(WindowFrameExclusion::Group), + Some(Keyword::TIES) => Ok(WindowFrameExclusion::Ties), + Some(Keyword::NO) => { + let is_others = matches!( + &self.peek_token_ref().token, + Token::Word(word) + if word.quote_style.is_none() + && word.value.eq_ignore_ascii_case("OTHERS") + ); + if is_others { + self.advance_token(); + Ok(WindowFrameExclusion::NoOthers) + } else { + self.expected_ref("OTHERS", self.peek_token_ref()) + } + } + _ => self.expected_ref( + "CURRENT ROW, GROUP, TIES, or NO OTHERS", + self.peek_token_ref(), + ), + } + } + /// 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_common.rs b/tests/sqlparser_common.rs index 29b060a82d..2dae352f0d 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -20015,3 +20015,334 @@ fn parse_function_arg_call_chain_no_exponential_blowup() { rx.recv_timeout(Duration::from_secs(5)) .expect("parser should reject this quickly, not loop exponentially"); } + +fn window_spec_from_projection(projection: &SelectItem) -> &WindowSpec { + let Expr::Function(Function { + over: Some(WindowType::WindowSpec(window)), + .. + }) = expr_from_projection(projection) + else { + panic!("Expected an inline window function"); + }; + window +} + +#[test] +fn parse_window_frame_exclusion_in_explicit_dialects() { + let dialects = TestedDialects::new(vec![ + Box::new(DuckDbDialect {}), + Box::new(PostgreSqlDialect {}), + Box::new(SQLiteDialect {}), + Box::new(GenericDialect {}), + ]); + for dialect in &dialects.dialects { + assert!(dialect.supports_window_frame_exclusion(), "{dialect:?}"); + } + dialects.verified_stmt("SELECT sum(n) OVER (ROWS UNBOUNDED PRECEDING EXCLUDE TIES) FROM t"); +} + +#[test] +fn parse_window_frame_exclusion_matrix() { + assert_eq!(None, WindowFrame::default().exclusion); + let dialects = all_dialects_where(|d| d.supports_window_frame_exclusion()); + for (units_sql, units) in [ + ("ROWS", WindowFrameUnits::Rows), + ("RANGE", WindowFrameUnits::Range), + ("GROUPS", WindowFrameUnits::Groups), + ] { + for (bounds_sql, start_bound, end_bound) in [ + ( + "UNBOUNDED PRECEDING", + WindowFrameBound::Preceding(None), + None, + ), + ( + "BETWEEN 1 PRECEDING AND 1 FOLLOWING", + WindowFrameBound::Preceding(Some(Box::new(Expr::value(number("1"))))), + Some(WindowFrameBound::Following(Some(Box::new(Expr::value( + number("1"), + ))))), + ), + ( + "BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW", + WindowFrameBound::Preceding(None), + Some(WindowFrameBound::CurrentRow), + ), + ] { + for (exclusion_sql, exclusion) in [ + ("", None), + ( + " EXCLUDE CURRENT ROW", + Some(WindowFrameExclusion::CurrentRow), + ), + (" EXCLUDE GROUP", Some(WindowFrameExclusion::Group)), + (" EXCLUDE TIES", Some(WindowFrameExclusion::Ties)), + (" EXCLUDE NO OTHERS", Some(WindowFrameExclusion::NoOthers)), + ] { + let expected = WindowFrame { + units, + start_bound: start_bound.clone(), + end_bound: end_bound.clone(), + exclusion, + }; + let frame_sql = format!("{units_sql} {bounds_sql}{exclusion_sql}"); + assert_eq!(expected.to_string(), frame_sql); + assert_eq!(format!("{expected:#}"), frame_sql); + for (prefix, pretty_prefix) in [("", ""), ("ORDER BY n ", "ORDER BY n\n")] { + let spec_sql = format!("{prefix}{frame_sql}"); + let sql = format!("SELECT sum(n) OVER ({spec_sql}) FROM t"); + let select = dialects.verified_only_select(&sql); + let window = window_spec_from_projection(only(&select.projection)); + assert_eq!(Some(&expected), window.window_frame.as_ref(), "{sql}"); + assert_eq!(window.to_string(), spec_sql); + assert_eq!(format!("{window:#}"), format!("{pretty_prefix}{frame_sql}")); + } + + let sql = + format!("SELECT sum(n) OVER w FROM t WINDOW w AS (ORDER BY n {frame_sql})"); + let select = dialects.verified_only_select(&sql); + let NamedWindowDefinition(name, NamedWindowExpr::WindowSpec(window)) = + only(&select.named_window) + else { + panic!("Expected a named window specification"); + }; + assert_eq!(&Ident::new("w"), name); + assert_eq!(Some(&expected), window.window_frame.as_ref(), "{sql}"); + let Expr::Function(function) = expr_from_projection(only(&select.projection)) + else { + panic!("Expected a window function"); + }; + assert_eq!( + Some(WindowType::NamedWindow(Ident::new("w"))), + function.over + ); + } + } + } +} + +#[test] +fn parse_window_frame_exclusion_scenarios() { + let dialects = all_dialects_where(|d| d.supports_window_frame_exclusion()); + dialects.one_statement_parses_to( + "SELECT sum(n) OVER (rows unbounded preceding exclude no oThErS) FROM t", + "SELECT sum(n) OVER (ROWS UNBOUNDED PRECEDING EXCLUDE NO OTHERS) FROM t", + ); + for sql in [ + "SELECT sum(n) OVER (ORDER BY n ROWS BETWEEN (1 + 2) PRECEDING AND (2 * 3) FOLLOWING EXCLUDE GROUP) FROM t", + "SELECT sum(n) OVER (ORDER BY n ROWS UNBOUNDED PRECEDING EXCLUDE TIES), avg(n) OVER (ORDER BY n RANGE CURRENT ROW), (SELECT max(m) OVER (ORDER BY m GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING EXCLUDE GROUP) FROM u) FROM t", + ] { + dialects.verified_stmt(sql); + } + for function in ["first_value(n)", "last_value(n)", "nth_value(n, 2)"] { + dialects.verified_stmt(&format!("SELECT {function} OVER (ORDER BY n ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) FROM t")); + } +} + +#[test] +fn reject_malformed_window_frame_exclusions() { + let dialects = all_dialects_where(|d| d.supports_window_frame_exclusion()); + for (spec, expected) in [ + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE", + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: )", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE CURRENT", + "Expected: ROW, found: )", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE NO", + "Expected: OTHERS, found: )", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE OTHER", + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: OTHER", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE NO OTHER", + "Expected: OTHERS, found: OTHER", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE \"CURRENT\" ROW", + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: \"CURRENT\"", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE CURRENT \"ROW\"", + "Expected: ROW, found: \"ROW\"", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE \"GROUP\"", + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: \"GROUP\"", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE \"TIES\"", + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: \"TIES\"", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE \"NO\" OTHERS", + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: \"NO\"", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE NO \"OTHERS\"", + "Expected: OTHERS, found: \"OTHERS\"", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE NO 'OTHERS'", + "Expected: OTHERS, found: 'OTHERS'", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE TIES EXCLUDE GROUP", + "Expected: ), found: EXCLUDE", + ), + ( + "ORDER BY n EXCLUDE TIES", + "Expected: ROWS, RANGE, GROUPS, found: EXCLUDE", + ), + ( + "EXCLUDE CURRENT ROW", + "Expected: ROWS, RANGE, GROUPS, found: EXCLUDE", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE /* comment */ CURRENT /* comment */ TIES", + "Expected: ROW, found: TIES", + ), + ( + r#"ROWS UNBOUNDED PRECEDING EXCLUDE -- comment +NO /* comment */ "OTHERS""#, + "Expected: OTHERS, found: \"OTHERS\"", + ), + ( + "ROWS UNBOUNDED PRECEDING EXCLUDE /* comment */ \"GROUP\"", + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: \"GROUP\"", + ), + ( + r#"ROWS UNBOUNDED PRECEDING EXCLUDE -- comment +TIES /* comment */ EXCLUDE GROUP"#, + "Expected: ), found: EXCLUDE", + ), + ] { + for sql in [ + format!("SELECT sum(n) OVER ({spec}) FROM t"), + format!("SELECT sum(n) OVER w FROM t WINDOW w AS ({spec})"), + ] { + assert_eq!( + ParserError::ParserError(expected.to_owned()), + dialects.parse_sql_statements(&sql).unwrap_err(), + "{sql}" + ); + } + } +} + +#[test] +fn reject_window_frame_exclusions_in_unsupported_dialects() { + let dialects = all_dialects_where(|d| !d.supports_window_frame_exclusion()); + for exclusion in ["CURRENT ROW", "GROUP", "TIES", "NO OTHERS"] { + let sql = + format!("SELECT sum(n) OVER (ROWS UNBOUNDED PRECEDING EXCLUDE {exclusion}) FROM t"); + assert_eq!( + ParserError::ParserError("Expected: ), found: EXCLUDE".to_owned()), + dialects.parse_sql_statements(&sql).unwrap_err(), + "{sql}" + ); + } +} + +#[test] +fn parse_named_window_called_others() { + let dialects = all_dialects_except(|d| d.is_table_alias(&Keyword::WINDOW, &mut Parser::new(d))); + dialects.one_statement_parses_to( + "SELECT sum(n) OVER (others) FROM (VALUES (1), (2)) t(n) WINDOW others AS (ORDER BY n)", + "SELECT sum(n) OVER (others) FROM (VALUES (1), (2)) t (n) WINDOW others AS (ORDER BY n)", + ); +} + +#[test] +fn parse_window_frame_exclusion_comments() { + let dialects = all_dialects_where(|d| d.supports_window_frame_exclusion()); + let unsupported = all_dialects_where(|d| !d.supports_window_frame_exclusion()); + for (exclusion_sql, exclusion) in [ + ("CURRENT ROW", WindowFrameExclusion::CurrentRow), + ("GROUP", WindowFrameExclusion::Group), + ("TIES", WindowFrameExclusion::Ties), + ("NO OTHERS", WindowFrameExclusion::NoOthers), + ] { + let expected = WindowFrame { + units: WindowFrameUnits::Rows, + end_bound: None, + exclusion: Some(exclusion), + ..WindowFrame::default() + }; + for separator in [ + "/* comment */", + r#"-- comment +"#, + ] { + let commented = format!( + "EXCLUDE{separator}{}", + exclusion_sql.replace(' ', separator) + ); + for (prefix, suffix) in [ + ("SELECT sum(n) OVER (ROWS UNBOUNDED PRECEDING", ") FROM t"), + ( + "SELECT sum(n) OVER w FROM t WINDOW w AS (ROWS UNBOUNDED PRECEDING", + ")", + ), + ] { + let sql = format!("{prefix}{separator}{commented}{separator}{suffix}"); + let canonical = format!("{prefix} EXCLUDE {exclusion_sql}{suffix}"); + let select = dialects.verified_only_select_with_canonical(&sql, &canonical); + let frame = match select.named_window.as_slice() { + [] => window_spec_from_projection(only(&select.projection)) + .window_frame + .as_ref() + .expect("Expected a frame"), + [NamedWindowDefinition(_, NamedWindowExpr::WindowSpec(window))] => { + window.window_frame.as_ref().expect("Expected a frame") + } + _ => panic!("Expected one named window specification"), + }; + assert_eq!(&expected, frame, "{sql}"); + if select.named_window.is_empty() { + assert_eq!( + ParserError::ParserError("Expected: ), found: EXCLUDE".to_owned()), + unsupported.parse_sql_statements(&sql).unwrap_err(), + "{sql}" + ); + } + } + } + } +} + +#[test] +fn reject_window_frame_exclusion_at_eof() { + let dialects = all_dialects_where(|d| d.supports_window_frame_exclusion()); + for (exclusion, expected) in [ + ( + "EXCLUDE", + "Expected: CURRENT ROW, GROUP, TIES, or NO OTHERS, found: EOF", + ), + ("EXCLUDE CURRENT", "Expected: ROW, found: EOF"), + ("EXCLUDE NO", "Expected: OTHERS, found: EOF"), + ("EXCLUDE CURRENT ROW", "Expected: ), found: EOF"), + ("EXCLUDE GROUP", "Expected: ), found: EOF"), + ("EXCLUDE TIES", "Expected: ), found: EOF"), + ("EXCLUDE NO OTHERS", "Expected: ), found: EOF"), + ] { + for ending in ["", " /* trailing comment */", " -- trailing comment"] { + for prefix in [ + "SELECT sum(n) OVER (ROWS UNBOUNDED PRECEDING", + "SELECT sum(n) OVER w FROM t WINDOW w AS (ROWS UNBOUNDED PRECEDING", + ] { + let sql = format!("{prefix} {exclusion}{ending}"); + assert_eq!( + ParserError::ParserError(expected.to_owned()), + dialects.parse_sql_statements(&sql).unwrap_err(), + "{sql}" + ); + } + } + } +} diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index a338ef7a82..640af55003 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -910,3 +910,73 @@ fn test_duckdb_lambda_function() { let sql_transform = "SELECT list_transform([1, 2, 3], lambda x : x * 2)"; duckdb().verified_stmt(sql_transform); } + +#[test] +fn parse_duckdb_window_frame_exclusion_inventory_examples() { + for (sql, canonical) in [ + ( + r#"SELECT n, + sum(n) OVER ( + ORDER BY n + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + EXCLUDE TIES + ) AS running_sum +FROM (VALUES (1), (1), (2)) AS t(n) +ORDER BY n;"#, + "SELECT n, sum(n) OVER (ORDER BY n ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) AS running_sum FROM (VALUES (1), (1), (2)) AS t (n) ORDER BY n", + ), + ( + r#"SELECT n, + sum(n) OVER ( + ORDER BY n + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + EXCLUDE CURRENT ROW + ) AS other_sum +FROM (VALUES (1), (2), (3)) AS t(n) +ORDER BY n;"#, + "SELECT n, sum(n) OVER (ORDER BY n ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) AS other_sum FROM (VALUES (1), (2), (3)) AS t (n) ORDER BY n", + ), + ] { + duckdb().one_statement_parses_to(sql, canonical); + } +} + +#[test] +fn parse_duckdb_window_frame_exclusion_documentation_example() { + let select = duckdb().verified_only_select_with_canonical( + r#"SELECT + event, + date, + athlete, + avg(time) OVER w AS recent, +FROM results +WINDOW w AS ( + PARTITION BY event + ORDER BY date + RANGE BETWEEN INTERVAL 10 DAYS PRECEDING AND INTERVAL 10 DAYS FOLLOWING + EXCLUDE CURRENT ROW +) +ORDER BY event, date, athlete;"#, + "SELECT event, date, athlete, avg(time) OVER w AS recent FROM results WINDOW w AS (PARTITION BY event ORDER BY date RANGE BETWEEN INTERVAL 10 DAYS PRECEDING AND INTERVAL 10 DAYS FOLLOWING EXCLUDE CURRENT ROW) ORDER BY event, date, athlete", + ); + let interval = Box::new(Expr::Interval(Interval { + value: Box::new(Expr::value(number("10"))), + leading_field: Some(DateTimeField::Days), + leading_precision: None, + last_field: None, + fractional_seconds_precision: None, + })); + let NamedWindowDefinition(_, NamedWindowExpr::WindowSpec(window)) = only(&select.named_window) + else { + panic!("Expected a named window specification"); + }; + assert_eq!( + Some(WindowFrame { + units: WindowFrameUnits::Range, + start_bound: WindowFrameBound::Preceding(Some(interval.clone())), + end_bound: Some(WindowFrameBound::Following(Some(interval))), + exclusion: Some(WindowFrameExclusion::CurrentRow), + }), + window.window_frame + ); +}