From 67b01c7a78a4b61a2336d917eb4f662ef9808e2c Mon Sep 17 00:00:00 2001 From: Cora Sutton Date: Sat, 12 Sep 2026 01:01:43 +0000 Subject: [PATCH] DuckDB dialect: support % on LIMIT, like `LIMIT 5% OFFSET 20` --- src/ast/query.rs | 16 +- src/ast/spans.rs | 3 + src/dialect/duckdb.rs | 4 + src/dialect/mod.rs | 5 + src/parser/mod.rs | 85 +++++++++- tests/sqlparser_duckdb.rs | 334 +++++++++++++++++++++++++++++++++++++- 6 files changed, 442 insertions(+), 5 deletions(-) diff --git a/src/ast/query.rs b/src/ast/query.rs index 296e4e8ca6..0e2ddb1ce0 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -44,7 +44,7 @@ pub struct Query { pub body: Box, /// ORDER BY pub order_by: Option, - /// `LIMIT ... OFFSET ... | LIMIT , ` + /// `LIMIT ... OFFSET ... | LIMIT , | LIMIT % [OFFSET ...]` pub limit_clause: Option, /// `FETCH { FIRST | NEXT } [ PERCENT ] { ROW | ROWS } | { ONLY | WITH TIES }` pub fetch: Option, @@ -3124,6 +3124,13 @@ pub enum LimitClause { /// The limit expression. limit: Expr, }, + /// Percentage limit syntax: `LIMIT % [OFFSET ]`. + Percent { + /// Percentage quantity. + limit: Expr, + /// Optional `OFFSET` expression. + offset: Option, + }, } impl fmt::Display for LimitClause { @@ -3149,6 +3156,13 @@ impl fmt::Display for LimitClause { LimitClause::OffsetCommaLimit { offset, limit } => { write!(f, " LIMIT {offset}, {limit}") } + LimitClause::Percent { limit, offset } => { + write!(f, " LIMIT {limit}%")?; + if let Some(offset) = offset { + write!(f, " {offset}")?; + } + Ok(()) + } } } } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index cb06b78990..87de4d1c3d 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -153,6 +153,9 @@ impl Spanned for LimitClause { .chain(limit_by.iter().map(|i| i.span())), ), LimitClause::OffsetCommaLimit { offset, limit } => offset.span().union(&limit.span()), + LimitClause::Percent { limit, offset } => { + union_spans(core::iter::once(limit.span()).chain(offset.as_ref().map(|i| i.span()))) + } } } } diff --git a/src/dialect/duckdb.rs b/src/dialect/duckdb.rs index 2e3673bc4c..2e750b61cb 100644 --- a/src/dialect/duckdb.rs +++ b/src/dialect/duckdb.rs @@ -142,4 +142,8 @@ impl Dialect for DuckDbDialect { fn supports_numeric_literal_underscores(&self) -> bool { true } + + fn supports_limit_percent(&self) -> bool { + true + } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index ff83a4da61..d22c55df54 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -608,6 +608,11 @@ pub trait Dialect: Debug + Any { false } + /// Supports `LIMIT %`. + fn supports_limit_percent(&self) -> bool { + false + } + /// Returns true if the dialect supports concatenating of string literal /// Example: `SELECT 'Hello ' "world" => SELECT 'Hello world'` fn supports_string_literal_concatenation(&self) -> bool { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5edc437145..6c84ee0f96 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -357,6 +357,11 @@ pub struct Parser<'a> { failed_derived_table_factor_positions: BTreeSet, } +enum ParsedLimit { + Rows(Option), + Percent(Expr), +} + /// Copy marker for a [`ParserError`] cached by the `parse_prefix` failure /// memoization, so the caches hold no strings. #[derive(Debug, Clone, Copy)] @@ -1390,6 +1395,10 @@ impl<'a> Parser<'a> { self.parse_subexpr(self.dialect.prec_unknown()) } + fn parse_expr_until(&mut self, terminator: fn(&Self) -> bool) -> Result { + self.parse_subexpr_inner(self.dialect.prec_unknown(), terminator) + } + /// Parse expression with optional alias and order by. pub fn parse_expr_with_alias_and_order_by( &mut self, @@ -1411,8 +1420,16 @@ impl<'a> Parser<'a> { } /// Parse tokens until the precedence changes. - #[cfg_attr(feature = "recursive-protection", recursive::recursive)] pub fn parse_subexpr(&mut self, precedence: u8) -> Result { + self.parse_subexpr_inner(precedence, |_| false) + } + + #[cfg_attr(feature = "recursive-protection", recursive::recursive)] + fn parse_subexpr_inner( + &mut self, + precedence: u8, + terminator: fn(&Self) -> bool, + ) -> Result { let _guard = self.recursion_counter.try_decrease()?; debug!("parsing expr"); let mut expr = self.parse_prefix()?; @@ -1431,6 +1448,11 @@ impl<'a> Parser<'a> { debug!("prefix: {expr:?}"); loop { + // Recursive operands and nested expressions keep their own boundaries. + if terminator(self) { + break; + } + let next_precedence = self.get_next_precedence()?; debug!("next precedence: {next_precedence:?}"); @@ -13573,7 +13595,15 @@ impl<'a> Parser<'a> { }; let (limit, limit_by) = if self.parse_keyword(Keyword::LIMIT) { - let expr = self.parse_limit()?; + let expr = match self.parse_limit_quantity()? { + ParsedLimit::Rows(expr) => expr, + ParsedLimit::Percent(limit) => { + if offset.is_none() && self.parse_keyword(Keyword::OFFSET) { + offset = Some(self.parse_offset()?); + } + return Ok(Some(LimitClause::Percent { limit, offset })); + } + }; if self.dialect.supports_limit_comma() && offset.is_none() @@ -13617,6 +13647,57 @@ impl<'a> Parser<'a> { } } + fn parse_limit_quantity(&mut self) -> Result { + if !self.dialect.supports_limit_percent() { + return self.parse_limit().map(ParsedLimit::Rows); + } + + if self.parse_keyword(Keyword::ALL) { + return Ok(ParsedLimit::Rows(None)); + } + + let limit = self.parse_expr_until(Parser::at_limit_percent_suffix)?; + if !self.consume_token(&Token::Mod) { + return Ok(ParsedLimit::Rows(Some(limit))); + } + + if matches!( + &limit, + Expr::Value(value) if matches!(&value.value, Value::Number(_, true)) + ) { + return self.expected_ref("an expression", self.peek_token_ref()); + } + + Ok(ParsedLimit::Percent(limit)) + } + + fn at_limit_percent_suffix(&self) -> bool { + if self.peek_token_ref().token != Token::Mod { + return false; + } + + match &self.peek_nth_token_ref(1).token { + Token::EOF | Token::SemiColon | Token::RParen => true, + Token::Word(word) => match word.keyword { + Keyword::OFFSET | Keyword::RETURNING => true, + Keyword::ON => matches!( + &self.peek_nth_token_ref(2).token, + Token::Word(next) if next.keyword == Keyword::CONFLICT + ), + Keyword::WITH => match &self.peek_nth_token_ref(2).token { + Token::Word(next) if next.keyword == Keyword::DATA => true, + Token::Word(next) if next.keyword == Keyword::NO => matches!( + &self.peek_nth_token_ref(3).token, + Token::Word(data) if data.keyword == Keyword::DATA + ), + _ => false, + }, + _ => false, + }, + _ => false, + } + } + /// Parse a table object for insertion /// e.g. `some_database.some_table` or `FUNCTION some_table_func(...)` pub fn parse_table_object(&mut self) -> Result { diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index a338ef7a82..b4e3c3dcf8 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -19,12 +19,12 @@ mod test_utils; use helpers::attached_token::AttachedToken; -use sqlparser::tokenizer::Span; +use sqlparser::tokenizer::{Location, Span}; use test_utils::*; use sqlparser::ast::*; use sqlparser::dialect::{DuckDbDialect, GenericDialect}; -use sqlparser::parser::ParserError; +use sqlparser::parser::{Parser, ParserError}; fn duckdb() -> TestedDialects { TestedDialects::new(vec![Box::new(DuckDbDialect {})]) @@ -910,3 +910,333 @@ 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 test_limit_percent_round_trip_and_ast() { + duckdb().one_statement_parses_to( + "SELECT n FROM (VALUES (1), (2), (3), (4)) AS t(n) ORDER BY n LIMIT 50%", + "SELECT n FROM (VALUES (1), (2), (3), (4)) AS t (n) ORDER BY n LIMIT 50%", + ); + + let query = duckdb().verified_query("SELECT 1 LIMIT 25% OFFSET 1"); + assert_eq!( + query.limit_clause, + Some(LimitClause::Percent { + limit: Expr::value(number("25")), + offset: Some(Offset { + value: Expr::value(number("1")), + rows: OffsetRows::None, + }), + }) + ); + + duckdb().one_statement_parses_to("SELECT 1 OFFSET 1 LIMIT 25%", "SELECT 1 LIMIT 25% OFFSET 1"); + duckdb().verified_stmt("SELECT * FROM (SELECT 1 LIMIT 25%) AS t"); + duckdb().statements_parse_to( + "SELECT 1 LIMIT 25%; SELECT 2", + "SELECT 1 LIMIT 25%; SELECT 2", + ); +} + +#[test] +fn test_limit_percent_walkthrough_examples() { + for (sql, canonical, limit, offset) in [ + ( + "SELECT 1 LIMIT 10 * 5% OFFSET 2", + "SELECT 1 LIMIT 10 * 5% OFFSET 2", + Expr::BinaryOp { + left: Box::new(Expr::value(number("10"))), + op: BinaryOperator::Multiply, + right: Box::new(Expr::value(number("5"))), + }, + Some(Offset { + value: Expr::value(number("2")), + rows: OffsetRows::None, + }), + ), + ( + "SELECT 1 LIMIT 25% OFFSET 2", + "SELECT 1 LIMIT 25% OFFSET 2", + Expr::value(number("25")), + Some(Offset { + value: Expr::value(number("2")), + rows: OffsetRows::None, + }), + ), + ( + "SELECT 1 OFFSET 2 LIMIT 25%", + "SELECT 1 LIMIT 25% OFFSET 2", + Expr::value(number("25")), + Some(Offset { + value: Expr::value(number("2")), + rows: OffsetRows::None, + }), + ), + ( + "SELECT 1 OFFSET 5 LIMIT 10%", + "SELECT 1 LIMIT 10% OFFSET 5", + Expr::value(number("10")), + Some(Offset { + value: Expr::value(number("5")), + rows: OffsetRows::None, + }), + ), + ] { + let query = duckdb().verified_query_with_canonical(sql, canonical); + assert_eq!( + query.limit_clause, + Some(LimitClause::Percent { limit, offset }), + "{sql}", + ); + } +} + +#[test] +fn test_limit_percent_expression_quantities() { + for sql in [ + "SELECT 1 LIMIT ?%", + "SELECT 1 LIMIT $1%", + "SELECT 1 LIMIT -25%", + "SELECT 1 LIMIT +25%", + "SELECT 1 LIMIT (25)%", + "SELECT 1 LIMIT (10 + 15)%", + "SELECT 1 LIMIT (10 % 3)%", + "SELECT 1 LIMIT 10 * 5%", + "SELECT 1 LIMIT 10 % 3%", + "SELECT 1 LIMIT abs(10 % 3)%", + "SELECT 1 LIMIT CAST(25 AS INTEGER)%", + ] { + duckdb().verified_stmt(sql); + } +} + +#[test] +fn test_limit_percent_preserves_modulo() { + duckdb_and_generic().verified_stmt("SELECT 5 % 2"); + + let query = duckdb_and_generic().verified_query("SELECT 1 LIMIT 5 % 2"); + assert_eq!( + query.limit_clause, + Some(LimitClause::LimitOffset { + limit: Some(Expr::BinaryOp { + left: Box::new(Expr::value(number("5"))), + op: BinaryOperator::Modulo, + right: Box::new(Expr::value(number("2"))), + }), + offset: None, + limit_by: vec![], + }) + ); + + duckdb_and_generic().verified_stmt("SELECT 1 LIMIT (5 % 2)"); + + for sql in [ + "SELECT 1 LIMIT ? % 2", + "SELECT 1 LIMIT $1 % 2", + "SELECT 1 LIMIT -25 % 2", + "SELECT 1 LIMIT (25) % 2", + "SELECT 1 LIMIT 10 + 15 % 2", + "SELECT 1 LIMIT CAST(25 AS INTEGER) % 2", + ] { + duckdb_and_generic().verified_stmt(sql); + } +} + +#[test] +fn test_limit_percent_rejects_malformed_syntax() { + for (sql, expected) in [ + ( + "SELECT 1 LIMIT %", + ParserError::ParserError("Expected: an expression, found: %".to_string()), + ), + ( + "SELECT 1 LIMIT ALL%", + ParserError::ParserError("Expected: end of statement, found: %".to_string()), + ), + ( + "SELECT 1 LIMIT 25%%", + ParserError::ParserError("Expected: an expression, found: %".to_string()), + ), + ( + "SELECT 1 LIMIT 25L%", + ParserError::ParserError("Expected: an expression, found: EOF".to_string()), + ), + ( + "SELECT 1 LIMIT 25 PERCENT", + ParserError::ParserError("Expected: end of statement, found: PERCENT".to_string()), + ), + ( + "SELECT 1 LIMIT 5%, 10", + ParserError::ParserError("Expected: an expression, found: ,".to_string()), + ), + ( + "SELECT 1 LIMIT 5%, 10%", + ParserError::ParserError("Expected: an expression, found: ,".to_string()), + ), + ] { + assert_eq!(duckdb().parse_sql_statements(sql).unwrap_err(), expected); + } +} + +#[test] +fn test_limit_percent_is_duckdb_only() { + assert_eq!( + TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("SELECT 1 LIMIT ALL%") + .unwrap_err(), + ParserError::ParserError("Expected: end of statement, found: %".to_string()) + ); + + assert_eq!( + TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("SELECT 1 LIMIT 25%") + .unwrap_err(), + ParserError::ParserError("Expected: an expression, found: EOF".to_string()) + ); +} + +#[test] +fn test_limit_percent_rejects_unparenthesized_addition() { + for sql in [ + "SELECT 1 LIMIT 10 + 15%", + "SELECT 1 LIMIT 10 - 5%", + "SELECT 1 LIMIT 10 + 5 * 2%", + ] { + assert_eq!( + duckdb().parse_sql_statements(sql).unwrap_err(), + ParserError::ParserError("Expected: an expression, found: EOF".to_string()), + "{sql}", + ); + } +} + +#[test] +fn test_limit_percent_boundary_is_local_to_quantity() { + for sql in [ + "SELECT 1 LIMIT (25%)%", + "SELECT 1 LIMIT abs(25%)%", + "SELECT 1 LIMIT (SELECT 25%)%", + ] { + assert_eq!( + duckdb().parse_sql_statements(sql).unwrap_err(), + ParserError::ParserError("Expected: an expression, found: )".to_string()), + "{sql}", + ); + } + + duckdb().verified_stmt("SELECT 1 LIMIT (SELECT 25 LIMIT 5%)%"); + duckdb().verified_stmt("SELECT 1 LIMIT (SELECT 25 % 2 LIMIT 5%)% OFFSET 3 % 2"); + duckdb().statements_parse_to( + "SELECT 1 LIMIT 25%; SELECT 5 % 2", + "SELECT 1 LIMIT 25%; SELECT 5 % 2", + ); + + let mut parser = Parser::new(&DuckDbDialect {}) + .try_with_sql("SELECT 1 LIMIT abs(25%)%") + .unwrap(); + assert!(parser.parse_statements().is_err()); + let statements = parser + .try_with_sql("SELECT 5 % 2") + .unwrap() + .parse_statements() + .unwrap(); + assert_eq!(statements[0], duckdb().verified_stmt("SELECT 5 % 2")); +} + +#[test] +fn test_limit_percent_operator_ast() { + for (quantity, op, right) in [ + ("10 * 5", BinaryOperator::Multiply, "5"), + ("10 % 3", BinaryOperator::Modulo, "3"), + ] { + let query = duckdb().verified_query(&format!("SELECT 1 LIMIT {quantity}%")); + assert_eq!( + query.limit_clause, + Some(LimitClause::Percent { + limit: Expr::BinaryOp { + left: Box::new(Expr::value(number("10"))), + op, + right: Box::new(Expr::value(number(right))), + }, + offset: None, + }), + ); + } +} + +#[test] +fn test_limit_percent_respects_recursion_limit() { + for quantity in [ + format!("{}1{}", "(".repeat(64), ")".repeat(64)), + format!("{}1", "+".repeat(64)), + ] { + for suffix in ["", "%"] { + assert_eq!( + duckdb() + .with_recursion_limit(20) + .parse_sql_statements(&format!("SELECT 1 LIMIT {quantity}{suffix}")) + .unwrap_err(), + ParserError::RecursionLimitExceeded, + ); + } + } + duckdb() + .with_recursion_limit(20) + .verified_stmt("SELECT 1 LIMIT (10 + 15)%"); +} + +#[test] +fn test_limit_percent_insert_returning() { + duckdb().verified_stmt("INSERT INTO t SELECT 1 LIMIT 100 RETURNING *"); + duckdb().verified_stmt("INSERT INTO t SELECT 1 LIMIT 100% RETURNING *"); +} + +#[test] +fn test_limit_percent_insert_on_conflict() { + duckdb().verified_stmt("INSERT INTO t SELECT 1 LIMIT 25 ON CONFLICT DO NOTHING"); + duckdb().verified_stmt("INSERT INTO t SELECT 1 LIMIT 25% ON CONFLICT DO NOTHING"); +} + +#[test] +fn test_limit_percent_ctas_with_data() { + for with_data in ["WITH DATA", "WITH NO DATA"] { + duckdb().verified_stmt(&format!("CREATE TABLE t AS SELECT 1 LIMIT 25 {with_data}")); + duckdb().verified_stmt(&format!("CREATE TABLE t AS SELECT 1 LIMIT 25% {with_data}")); + } +} + +#[test] +fn test_limit_percent_continuation_keywords_remain_modulo_operands() { + for keyword in ["returning", "on", "with"] { + let query = duckdb().verified_query(&format!(r#"SELECT 1 LIMIT 5 % "{keyword}""#)); + assert_eq!( + query.limit_clause, + Some(LimitClause::LimitOffset { + limit: Some(Expr::BinaryOp { + left: Box::new(Expr::value(number("5"))), + op: BinaryOperator::Modulo, + right: Box::new(Expr::Identifier(Ident::with_quote('"', keyword))), + }), + offset: None, + limit_by: vec![], + }), + ); + } +} + +#[test] +fn test_limit_percent_spans() { + for (sql, end_column) in [ + ("SELECT 1 LIMIT 25%", 18), + ("SELECT 1 LIMIT 25% OFFSET 3", 28), + ] { + let statements = Parser::parse_sql(&DuckDbDialect {}, sql).unwrap(); + let Statement::Query(query) = &statements[0] else { + panic!("expected query"); + }; + assert_eq!( + query.limit_clause.as_ref().unwrap().span(), + Span::new(Location::new(1, 16), Location::new(1, end_column)), + ); + } +}