Skip to content

Commit f560f3c

Browse files
committed
fix: rewrite limit / offset
1 parent c7eb6d3 commit f560f3c

3 files changed

Lines changed: 124 additions & 0 deletions

File tree

src/rewrite/limit_offset.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-FileCopyrightText: 2026 Stackable GmbH
2+
// SPDX-License-Identifier: OSL-3.0
3+
use sqlparser::ast::{Fetch, LimitClause, Query, VisitorMut};
4+
use std::ops::ControlFlow;
5+
6+
/// Reorders `LIMIT n OFFSET m` into a Trino-compatible form.
7+
///
8+
/// PostgreSQL accepts `LIMIT n OFFSET m`, but Trino's grammar requires the
9+
/// offset to come *before* the row-limiting clause (`OFFSET m LIMIT n` or
10+
/// `OFFSET m FETCH FIRST n ROWS ONLY`). sqlparser's `Display` for
11+
/// [`LimitClause::LimitOffset`] always writes `LIMIT` before `OFFSET`
12+
/// regardless of input order, so a plain round-trip keeps the order Trino
13+
/// rejects — a `VisitorMut` on expressions cannot fix it.
14+
///
15+
/// Instead we exploit `Query`'s field render order: `limit_clause` is emitted
16+
/// before `fetch`. So we leave the `OFFSET` in the limit clause and move the
17+
/// `LIMIT` value into a `FETCH FIRST n ROWS ONLY` clause. The result renders as
18+
/// `... OFFSET m FETCH FIRST n ROWS ONLY`, which is valid Trino and
19+
/// semantically identical to `LIMIT n OFFSET m`. Everything is built from AST
20+
/// nodes — no raw-string manipulation (see the "AST, never raw strings" rule in
21+
/// `AGENTS.md`).
22+
///
23+
/// Using [`VisitorMut::post_visit_query`] means every `Query` node is handled,
24+
/// including subqueries and CTEs, not just the top level.
25+
pub struct LimitOffsetRewriter;
26+
27+
impl VisitorMut for LimitOffsetRewriter {
28+
type Break = ();
29+
30+
fn post_visit_query(&mut self, query: &mut Query) -> ControlFlow<()> {
31+
// Don't clobber a pre-existing FETCH (would be a malformed query anyway).
32+
if query.fetch.is_some() {
33+
return ControlFlow::Continue(());
34+
}
35+
36+
// Only the plain `LIMIT <expr> OFFSET <expr>` case: both present, no
37+
// ClickHouse `LIMIT BY`. `LIMIT ALL OFFSET m` parses to `limit: None`
38+
// (sqlparser drops `ALL`), so `.take()` yields `None` and we leave the
39+
// bare `OFFSET m` untouched — Trino accepts that as-is.
40+
let limit = match &mut query.limit_clause {
41+
Some(LimitClause::LimitOffset {
42+
limit,
43+
offset: Some(_),
44+
limit_by,
45+
}) if limit_by.is_empty() => limit.take(),
46+
_ => None,
47+
};
48+
49+
if let Some(limit) = limit {
50+
query.fetch = Some(Fetch {
51+
with_ties: false,
52+
percent: false,
53+
quantity: Some(limit),
54+
});
55+
}
56+
57+
ControlFlow::Continue(())
58+
}
59+
}

src/rewrite/mod.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: OSL-3.0
33
mod casts;
44
mod functions;
5+
mod limit_offset;
56
mod predicates;
67

78
use sqlparser::ast::VisitMut;
@@ -22,6 +23,8 @@ use sqlparser::parser::Parser;
2223
/// - PostgreSQL type names are normalized to Trino equivalents
2324
/// - `ILIKE` becomes `lower(x) LIKE lower(pattern)`
2425
/// - PostgreSQL function names are mapped to Trino equivalents
26+
/// - `LIMIT n OFFSET m` is reordered into Trino order
27+
/// (`OFFSET m FETCH FIRST n ROWS ONLY`)
2528
///
2629
/// If parsing fails (e.g. for `SET`, `SHOW`, `DISCARD` commands), the original
2730
/// SQL is returned unchanged.
@@ -57,9 +60,11 @@ pub fn rewrite_sql(sql: &str) -> String {
5760
let mut cast_rewriter = casts::CastRewriter;
5861
let mut ilike_rewriter = predicates::ILikeRewriter;
5962
let mut fn_renamer = functions::FunctionRenamer;
63+
let mut limit_offset_rewriter = limit_offset::LimitOffsetRewriter;
6064
let _ = stmt.visit(&mut cast_rewriter);
6165
let _ = stmt.visit(&mut ilike_rewriter);
6266
let _ = stmt.visit(&mut fn_renamer);
67+
let _ = stmt.visit(&mut limit_offset_rewriter);
6368

6469
stmt.to_string()
6570
}
@@ -133,6 +138,36 @@ mod tests {
133138
must_contain: &["SELECT", "FROM"],
134139
must_not_contain: &[],
135140
},
141+
Case {
142+
name: "LIMIT n OFFSET m → OFFSET m FETCH FIRST n (no bare LIMIT)",
143+
input: "SELECT name FROM t ORDER BY name LIMIT 2 OFFSET 1",
144+
must_contain: &["OFFSET 1", "FETCH FIRST 2"],
145+
must_not_contain: &["LIMIT"],
146+
},
147+
Case {
148+
name: "LIMIT only is left unchanged",
149+
input: "SELECT name FROM t LIMIT 5",
150+
must_contain: &["LIMIT 5"],
151+
must_not_contain: &["FETCH", "OFFSET"],
152+
},
153+
Case {
154+
name: "OFFSET only is left unchanged",
155+
input: "SELECT name FROM t OFFSET 3",
156+
must_contain: &["OFFSET 3"],
157+
must_not_contain: &["FETCH", "LIMIT"],
158+
},
159+
Case {
160+
name: "LIMIT ALL OFFSET m → bare OFFSET (ALL dropped, no FETCH)",
161+
input: "SELECT name FROM t LIMIT ALL OFFSET 4",
162+
must_contain: &["OFFSET 4"],
163+
must_not_contain: &["FETCH", "LIMIT", "ALL"],
164+
},
165+
Case {
166+
name: "subquery LIMIT+OFFSET is reordered too",
167+
input: "SELECT * FROM (SELECT name FROM t ORDER BY name LIMIT 2 OFFSET 1) x",
168+
must_contain: &["OFFSET 1", "FETCH FIRST 2"],
169+
must_not_contain: &["LIMIT"],
170+
},
136171
];
137172

138173
#[test]
@@ -162,6 +197,20 @@ mod tests {
162197
assert_eq!(rewrite_sql(input), input);
163198
}
164199

200+
/// The reordered clause must place `OFFSET` before the row-limiting
201+
/// `FETCH` — the whole point of the rewrite, which the substring-based
202+
/// `Case` table cannot assert on its own.
203+
#[test]
204+
fn limit_offset_emits_offset_before_fetch() {
205+
let result = rewrite_sql("SELECT name FROM t ORDER BY name LIMIT 2 OFFSET 1");
206+
let offset_at = result.find("OFFSET").expect("OFFSET present");
207+
let fetch_at = result.find("FETCH").expect("FETCH present");
208+
assert!(
209+
offset_at < fetch_at,
210+
"expected OFFSET before FETCH in: {result}"
211+
);
212+
}
213+
165214
#[test]
166215
fn show_passes_through_non_empty() {
167216
let result = rewrite_sql("SHOW server_version");

tests/integration_test.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,22 @@ trino_tests!(
437437
"SELECT name FROM nation ORDER BY nationkey OFFSET 5 ROWS FETCH FIRST 3 ROWS ONLY",
438438
Check::Rows { min_rows: 3 }
439439
),
440+
// PostgreSQL-order `LIMIT n OFFSET m` — Trino rejects it verbatim; the
441+
// rewriter reorders it into `OFFSET m FETCH FIRST n ROWS ONLY`. nation
442+
// ordered by nationkey is ALGERIA(0), ARGENTINA(1), ...; offset 1
443+
// limit 1 must yield ARGENTINA.
444+
(
445+
"pg-order limit offset",
446+
"SELECT name FROM nation ORDER BY nationkey LIMIT 1 OFFSET 1",
447+
Check::Value { value: "ARGENTINA" }
448+
),
449+
// Same rewrite must apply inside a subquery (inner → ARGENTINA, BRAZIL;
450+
// outer takes the first alphabetically).
451+
(
452+
"pg-order limit offset in subquery",
453+
"SELECT name FROM (SELECT name FROM nation ORDER BY nationkey LIMIT 2 OFFSET 1) t ORDER BY name LIMIT 1",
454+
Check::Value { value: "ARGENTINA" }
455+
),
440456
]
441457
);
442458

0 commit comments

Comments
 (0)