Skip to content

Preserve MySQL-style LIMIT <offset>, <limit> syntax #1765

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions src/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,18 @@ pub use self::query::{
FormatClause, GroupByExpr, GroupByWithModifier, IdentWithAlias, IlikeSelectItem,
InputFormatClause, Interpolate, InterpolateExpr, Join, JoinConstraint, JoinOperator,
JsonTableColumn, JsonTableColumnErrorHandling, JsonTableNamedColumn, JsonTableNestedColumn,
LateralView, LockClause, LockType, MatchRecognizePattern, MatchRecognizeSymbol, Measure,
NamedWindowDefinition, NamedWindowExpr, NonBlock, Offset, OffsetRows, OpenJsonTableColumn,
OrderBy, OrderByExpr, OrderByKind, OrderByOptions, PivotValueSource, ProjectionSelect, Query,
RenameSelectItem, RepetitionQuantifier, ReplaceSelectElement, ReplaceSelectItem, RowsPerMatch,
Select, SelectFlavor, SelectInto, SelectItem, SelectItemQualifiedWildcardKind, SetExpr,
SetOperator, SetQuantifier, Setting, SymbolDefinition, Table, TableAlias, TableAliasColumnDef,
TableFactor, TableFunctionArgs, TableIndexHintForClause, TableIndexHintType, TableIndexHints,
TableIndexType, TableSample, TableSampleBucket, TableSampleKind, TableSampleMethod,
TableSampleModifier, TableSampleQuantity, TableSampleSeed, TableSampleSeedModifier,
TableSampleUnit, TableVersion, TableWithJoins, Top, TopQuantity, UpdateTableFromKind,
ValueTableMode, Values, WildcardAdditionalOptions, With, WithFill,
LateralView, LimitClause, LockClause, LockType, MatchRecognizePattern, MatchRecognizeSymbol,
Measure, NamedWindowDefinition, NamedWindowExpr, NonBlock, Offset, OffsetRows,
OpenJsonTableColumn, OrderBy, OrderByExpr, OrderByKind, OrderByOptions, PivotValueSource,
ProjectionSelect, Query, RenameSelectItem, RepetitionQuantifier, ReplaceSelectElement,
ReplaceSelectItem, RowsPerMatch, Select, SelectFlavor, SelectInto, SelectItem,
SelectItemQualifiedWildcardKind, SetExpr, SetOperator, SetQuantifier, Setting,
SymbolDefinition, Table, TableAlias, TableAliasColumnDef, TableFactor, TableFunctionArgs,
TableIndexHintForClause, TableIndexHintType, TableIndexHints, TableIndexType, TableSample,
TableSampleBucket, TableSampleKind, TableSampleMethod, TableSampleModifier,
TableSampleQuantity, TableSampleSeed, TableSampleSeedModifier, TableSampleUnit, TableVersion,
TableWithJoins, Top, TopQuantity, UpdateTableFromKind, ValueTableMode, Values,
WildcardAdditionalOptions, With, WithFill,
};

pub use self::trigger::{
Expand Down
73 changes: 57 additions & 16 deletions src/ast/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,8 @@ pub struct Query {
pub body: Box<SetExpr>,
/// ORDER BY
pub order_by: Option<OrderBy>,
/// `LIMIT { <N> | ALL }`
pub limit: Option<Expr>,

/// `LIMIT { <N> } BY { <expr>,<expr>,... } }`
pub limit_by: Vec<Expr>,

/// `OFFSET <N> [ { ROW | ROWS } ]`
pub offset: Option<Offset>,
/// `LIMIT ... OFFSET ... | LIMIT <offset>, <limit>`
pub limit_clause: Option<LimitClause>,
/// `FETCH { FIRST | NEXT } <N> [ PERCENT ] { ROW | ROWS } | { ONLY | WITH TIES }`
pub fetch: Option<Fetch>,
/// `FOR { UPDATE | SHARE } [ OF table_name ] [ SKIP LOCKED | NOWAIT ]`
Expand Down Expand Up @@ -79,14 +73,9 @@ impl fmt::Display for Query {
if let Some(ref order_by) = self.order_by {
write!(f, " {order_by}")?;
}
if let Some(ref limit) = self.limit {
write!(f, " LIMIT {limit}")?;
}
if let Some(ref offset) = self.offset {
write!(f, " {offset}")?;
}
if !self.limit_by.is_empty() {
write!(f, " BY {}", display_separated(&self.limit_by, ", "))?;

if let Some(ref limit_clause) = self.limit_clause {
limit_clause.fmt(f)?;
}
if let Some(ref settings) = self.settings {
write!(f, " SETTINGS {}", display_comma_separated(settings))?;
Expand Down Expand Up @@ -2374,6 +2363,58 @@ impl fmt::Display for OrderByOptions {
}
}

#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))]
pub enum LimitClause {
/// Standard SQL syntax
///
/// `LIMIT <limit> [BY <expr>,<expr>,...] [OFFSET <offset>]`
LimitOffset {
/// `LIMIT { <N> | ALL }`
limit: Option<Expr>,
/// `OFFSET <N> [ { ROW | ROWS } ]`
offset: Option<Offset>,
/// `BY { <expr>,<expr>,... } }`
///
/// [ClickHouse](https://clickhouse.com/docs/sql-reference/statements/select/limit-by)
limit_by: Vec<Expr>,
},
/// [MySQL]-specific syntax; the order of expressions is reversed.
///
/// `LIMIT <offset>, <limit>`
///
/// [MySQL]: https://dev.mysql.com/doc/refman/8.4/en/select.html
OffsetCommaLimit { offset: Expr, limit: Expr },
}

impl fmt::Display for LimitClause {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
LimitClause::LimitOffset {
limit,
limit_by,
offset,
} => {
if let Some(ref limit) = limit {
write!(f, " LIMIT {limit}")?;
}
if let Some(ref offset) = offset {
write!(f, " {offset}")?;
}
if !limit_by.is_empty() {
debug_assert!(limit.is_some());
write!(f, " BY {}", display_separated(limit_by, ", "))?;
}
Ok(())
}
LimitClause::OffsetCommaLimit { offset, limit } => {
write!(f, " LIMIT {}, {}", offset, limit)
}
}
}
}

#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))]
Expand Down
31 changes: 23 additions & 8 deletions src/ast/spans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ use super::{
Function, FunctionArg, FunctionArgExpr, FunctionArgumentClause, FunctionArgumentList,
FunctionArguments, GroupByExpr, HavingBound, IlikeSelectItem, Insert, Interpolate,
InterpolateExpr, Join, JoinConstraint, JoinOperator, JsonPath, JsonPathElem, LateralView,
MatchRecognizePattern, Measure, NamedWindowDefinition, ObjectName, ObjectNamePart, Offset,
OnConflict, OnConflictAction, OnInsert, OrderBy, OrderByExpr, OrderByKind, Partition,
LimitClause, MatchRecognizePattern, Measure, NamedWindowDefinition, ObjectName, ObjectNamePart,
Offset, OnConflict, OnConflictAction, OnInsert, OrderBy, OrderByExpr, OrderByKind, Partition,
PivotValueSource, ProjectionSelect, Query, ReferentialAction, RenameSelectItem,
ReplaceSelectElement, ReplaceSelectItem, Select, SelectInto, SelectItem, SetExpr, SqlOption,
Statement, Subscript, SymbolDefinition, TableAlias, TableAliasColumnDef, TableConstraint,
Expand Down Expand Up @@ -94,9 +94,7 @@ impl Spanned for Query {
with,
body,
order_by,
limit,
limit_by,
offset,
limit_clause,
fetch,
locks: _, // todo
for_clause: _, // todo, mssql specific
Expand All @@ -109,14 +107,31 @@ impl Spanned for Query {
.map(|i| i.span())
.chain(core::iter::once(body.span()))
.chain(order_by.as_ref().map(|i| i.span()))
.chain(limit.as_ref().map(|i| i.span()))
.chain(limit_by.iter().map(|i| i.span()))
.chain(offset.as_ref().map(|i| i.span()))
.chain(limit_clause.as_ref().map(|i| i.span()))
.chain(fetch.as_ref().map(|i| i.span())),
)
}
}

impl Spanned for LimitClause {
fn span(&self) -> Span {
match self {
LimitClause::LimitOffset {
limit,
offset,
limit_by,
} => union_spans(
limit
.iter()
.map(|i| i.span())
.chain(offset.as_ref().map(|i| i.span()))
.chain(limit_by.iter().map(|i| i.span())),
),
LimitClause::OffsetCommaLimit { offset, limit } => offset.span().union(&limit.span()),
}
}
}

impl Spanned for Offset {
fn span(&self) -> Span {
let Offset {
Expand Down
4 changes: 2 additions & 2 deletions src/ast/visitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ where
/// // Remove all select limits in sub-queries
/// visit_expressions_mut(&mut statements, |expr| {
/// if let Expr::Subquery(q) = expr {
/// q.limit = None
/// q.limit_clause = None;
/// }
/// ControlFlow::<()>::Continue(())
/// });
Expand Down Expand Up @@ -647,7 +647,7 @@ where
/// // Remove all select limits in outer statements (not in sub-queries)
/// visit_statements_mut(&mut statements, |stmt| {
/// if let Statement::Query(q) = stmt {
/// q.limit = None
/// q.limit_clause = None;
/// }
/// ControlFlow::<()>::Continue(())
/// });
Expand Down
109 changes: 60 additions & 49 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9490,6 +9490,60 @@ impl<'a> Parser<'a> {
}
}

fn parse_optional_limit_clause(&mut self) -> Result<Option<LimitClause>, ParserError> {
let mut offset = if self.parse_keyword(Keyword::OFFSET) {
Some(self.parse_offset()?)
} else {
None
};

let (limit, limit_by) = if self.parse_keyword(Keyword::LIMIT) {
let expr = self.parse_limit()?;

if self.dialect.supports_limit_comma()
&& offset.is_none()
&& expr.is_some() // ALL not supported with comma
&& self.consume_token(&Token::Comma)
{
let offset = expr.ok_or_else(|| {
ParserError::ParserError(
"Missing offset for LIMIT <offset>, <limit>".to_string(),
)
})?;
return Ok(Some(LimitClause::OffsetCommaLimit {
offset,
limit: self.parse_expr()?,
}));
}

let limit_by = if dialect_of!(self is ClickHouseDialect | GenericDialect)
&& self.parse_keyword(Keyword::BY)
{
Some(self.parse_comma_separated(Parser::parse_expr)?)
} else {
None
};

(Some(expr), limit_by)
} else {
(None, None)
};

if offset.is_none() && limit.is_some() && self.parse_keyword(Keyword::OFFSET) {
offset = Some(self.parse_offset()?);
}

if offset.is_some() || (limit.is_some() && limit != Some(None)) || limit_by.is_some() {
Ok(Some(LimitClause::LimitOffset {
limit: limit.unwrap_or_default(),
offset,
limit_by: limit_by.unwrap_or_default(),
}))
} else {
Ok(None)
}
}

/// 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<TableObject, ParserError> {
Expand Down Expand Up @@ -10230,10 +10284,8 @@ impl<'a> Parser<'a> {
Ok(Query {
with,
body: self.parse_insert_setexpr_boxed()?,
limit: None,
limit_by: vec![],
order_by: None,
offset: None,
limit_clause: None,
fetch: None,
locks: vec![],
for_clause: None,
Expand All @@ -10245,10 +10297,8 @@ impl<'a> Parser<'a> {
Ok(Query {
with,
body: self.parse_update_setexpr_boxed()?,
limit: None,
limit_by: vec![],
order_by: None,
offset: None,
limit_clause: None,
fetch: None,
locks: vec![],
for_clause: None,
Expand All @@ -10260,10 +10310,8 @@ impl<'a> Parser<'a> {
Ok(Query {
with,
body: self.parse_delete_setexpr_boxed()?,
limit: None,
limit_by: vec![],
limit_clause: None,
order_by: None,
offset: None,
fetch: None,
locks: vec![],
for_clause: None,
Expand All @@ -10276,40 +10324,7 @@ impl<'a> Parser<'a> {

let order_by = self.parse_optional_order_by()?;

let mut limit = None;
let mut offset = None;

for _x in 0..2 {
if limit.is_none() && self.parse_keyword(Keyword::LIMIT) {
limit = self.parse_limit()?
}

if offset.is_none() && self.parse_keyword(Keyword::OFFSET) {
offset = Some(self.parse_offset()?)
}

if self.dialect.supports_limit_comma()
&& limit.is_some()
&& offset.is_none()
&& self.consume_token(&Token::Comma)
{
// MySQL style LIMIT x,y => LIMIT y OFFSET x.
// Check <https://dev.mysql.com/doc/refman/8.0/en/select.html> for more details.
offset = Some(Offset {
value: limit.unwrap(),
rows: OffsetRows::None,
});
limit = Some(self.parse_expr()?);
}
}

let limit_by = if dialect_of!(self is ClickHouseDialect | GenericDialect)
&& self.parse_keyword(Keyword::BY)
{
self.parse_comma_separated(Parser::parse_expr)?
} else {
vec![]
};
let limit_clause = self.parse_optional_limit_clause()?;

let settings = self.parse_settings()?;

Expand Down Expand Up @@ -10346,9 +10361,7 @@ impl<'a> Parser<'a> {
with,
body,
order_by,
limit,
limit_by,
offset,
limit_clause,
fetch,
locks,
for_clause,
Expand Down Expand Up @@ -11703,9 +11716,7 @@ impl<'a> Parser<'a> {
with: None,
body: Box::new(values),
order_by: None,
limit: None,
limit_by: vec![],
offset: None,
limit_clause: None,
fetch: None,
locks: vec![],
for_clause: None,
Expand Down
15 changes: 14 additions & 1 deletion tests/sqlparser_clickhouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,12 @@ fn parse_limit_by() {
clickhouse_and_generic().verified_stmt(
r#"SELECT * FROM default.last_asset_runs_mv ORDER BY created_at DESC LIMIT 1 BY asset, toStartOfDay(created_at)"#,
);
clickhouse_and_generic().parse_sql_statements(
r#"SELECT * FROM default.last_asset_runs_mv ORDER BY created_at DESC BY asset, toStartOfDay(created_at)"#,
).expect_err("BY without LIMIT");
clickhouse_and_generic()
.parse_sql_statements("SELECT * FROM T OFFSET 5 BY foo")
.expect_err("BY with OFFSET but without LIMIT");
}

#[test]
Expand Down Expand Up @@ -1107,7 +1113,14 @@ fn parse_select_order_by_with_fill_interpolate() {
},
select.order_by.expect("ORDER BY expected")
);
assert_eq!(Some(Expr::value(number("2"))), select.limit);
assert_eq!(
select.limit_clause,
Some(LimitClause::LimitOffset {
limit: Some(Expr::value(number("2"))),
offset: None,
limit_by: vec![]
})
);
}

#[test]
Expand Down
Loading