Skip to main content

ferrule_sql/
query_builder.rs

1use crate::backend::Backend;
2use crate::error::SqlError;
3
4/// Apply dialect-specific LIMIT / OFFSET paging to a SQL statement.
5///
6/// Returns the original SQL unchanged if:
7/// - both `limit` and `offset` are `None`
8/// - the SQL already contains a `LIMIT` or `OFFSET` or `FETCH` clause
9/// - the SQL contains multiple statements (`;`)
10///
11/// # Dialects
12///
13/// | Backend | Syntax |
14/// |---------|--------|
15/// | Postgres, SQLite, MySQL | `LIMIT {n} OFFSET {m}` |
16/// | MSSQL | `OFFSET {m} ROWS FETCH NEXT {n} ROWS ONLY` |
17/// | Oracle (12c+) | `OFFSET {m} ROWS FETCH NEXT {n} ROWS ONLY` |
18///
19/// For MSSQL and Oracle, if no `ORDER BY` is present, a synthetic
20/// `ORDER BY (SELECT NULL)` / `ORDER BY 1` is injected and a warning
21/// can be emitted by the caller.
22pub fn apply_paging(
23    sql: &str,
24    limit: Option<usize>,
25    offset: Option<usize>,
26    backend: Backend,
27) -> Result<String, SqlError> {
28    let limit = limit.filter(|n| *n > 0);
29    let offset = offset.filter(|n| *n > 0);
30
31    if limit.is_none() && offset.is_none() {
32        return Ok(sql.to_string());
33    }
34
35    let trimmed = sql.trim().trim_end_matches(';').trim();
36    if trimmed.is_empty() {
37        return Ok(sql.to_string());
38    }
39
40    // Refuse multi-statement paging (only if semicolons remain after stripping trailing)
41    if trimmed.contains(';') {
42        return Err(SqlError::QueryFailed(
43            "Multi-statement SQL does not support --limit / --offset.".into(),
44        ));
45    }
46
47    // Only apply paging to SELECT-like statements
48    let first_word = trimmed
49        .split_whitespace()
50        .next()
51        .unwrap_or("")
52        .to_uppercase();
53    if first_word != "SELECT" && first_word != "WITH" {
54        return Ok(sql.to_string());
55    }
56
57    // Check for existing paging clauses (case-insensitive word boundary)
58    let upper = trimmed.to_uppercase();
59    if has_existing_paging(&upper) {
60        return Ok(sql.to_string());
61    }
62
63    let limit_val = limit.unwrap_or(0);
64    let offset_val = offset.unwrap_or(0);
65
66    let paged = match backend {
67        #[cfg(feature = "mssql")]
68        Backend::MsSql => build_mssql_paging(sql, &upper, limit_val, offset_val)?,
69        #[cfg(feature = "oracle")]
70        Backend::Oracle => build_oracle_paging(sql, &upper, limit_val, offset_val)?,
71        #[cfg(any(feature = "postgres", feature = "mysql", feature = "sqlite"))]
72        _ => build_limit_offset_paging(sql, limit_val, offset_val),
73        #[allow(unreachable_patterns)]
74        _ => build_limit_offset_paging(sql, limit_val, offset_val),
75    };
76
77    Ok(paged)
78}
79
80fn has_existing_paging(upper: &str) -> bool {
81    // Look for LIMIT, OFFSET, or FETCH keywords as whole words
82    upper.split_whitespace().any(|word| {
83        word == "LIMIT"
84            || word == "OFFSET"
85            || word == "FETCH"
86            || word == "TOP"
87            || word.starts_with("LIMIT(")
88            || word.starts_with("LIMIT(")
89    })
90}
91
92fn build_limit_offset_paging(sql: &str, limit: usize, offset: usize) -> String {
93    let trimmed = sql.trim().trim_end_matches(';').trim();
94    let mut clauses = Vec::new();
95    if limit > 0 {
96        clauses.push(format!("LIMIT {limit}"));
97    } else if offset > 0 {
98        // Some backends require LIMIT when OFFSET is present
99        clauses.push("LIMIT 18446744073709551615".to_string());
100    }
101    if offset > 0 {
102        clauses.push(format!("OFFSET {offset}"));
103    }
104    if clauses.is_empty() {
105        trimmed.to_string()
106    } else {
107        format!("{} {}", trimmed, clauses.join(" "))
108    }
109}
110
111#[cfg(feature = "mssql")]
112fn build_mssql_paging(
113    sql: &str,
114    upper: &str,
115    limit: usize,
116    offset: usize,
117) -> Result<String, SqlError> {
118    let needs_order_by = !upper.contains("ORDER BY");
119    let trimmed = sql.trim().trim_end_matches(';').trim();
120    let mut result = trimmed.to_string();
121    if needs_order_by {
122        result.push_str(" ORDER BY (SELECT NULL)");
123    }
124    result.push_str(&format!(" OFFSET {offset} ROWS"));
125    if limit > 0 {
126        result.push_str(&format!(" FETCH NEXT {limit} ROWS ONLY"));
127    }
128    Ok(result)
129}
130
131#[cfg(feature = "oracle")]
132fn build_oracle_paging(
133    sql: &str,
134    upper: &str,
135    limit: usize,
136    offset: usize,
137) -> Result<String, SqlError> {
138    let needs_order_by = !upper.contains("ORDER BY");
139    let trimmed = sql.trim().trim_end_matches(';').trim();
140    let mut result = trimmed.to_string();
141    if needs_order_by {
142        result.push_str(" ORDER BY 1");
143    }
144    result.push_str(&format!(" OFFSET {offset} ROWS"));
145    if limit > 0 {
146        result.push_str(&format!(" FETCH NEXT {limit} ROWS ONLY"));
147    }
148    Ok(result)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[cfg(feature = "postgres")]
156    #[test]
157    fn test_no_paging_needed() {
158        let sql = apply_paging("SELECT 1", None, None, Backend::Postgres).unwrap();
159        assert_eq!(sql, "SELECT 1");
160    }
161
162    #[cfg(feature = "postgres")]
163    #[test]
164    fn test_postgres_limit() {
165        let sql = apply_paging("SELECT 1", Some(10), None, Backend::Postgres).unwrap();
166        assert_eq!(sql, "SELECT 1 LIMIT 10");
167    }
168
169    #[cfg(feature = "postgres")]
170    #[test]
171    fn test_postgres_limit_offset() {
172        let sql = apply_paging("SELECT 1", Some(10), Some(5), Backend::Postgres).unwrap();
173        assert_eq!(sql, "SELECT 1 LIMIT 10 OFFSET 5");
174    }
175
176    #[cfg(feature = "postgres")]
177    #[test]
178    fn test_postgres_offset_only() {
179        let sql = apply_paging("SELECT 1", None, Some(20), Backend::Postgres).unwrap();
180        assert_eq!(sql, "SELECT 1 LIMIT 18446744073709551615 OFFSET 20");
181    }
182
183    #[cfg(feature = "postgres")]
184    #[test]
185    fn test_existing_limit_skipped() {
186        let sql = apply_paging("SELECT 1 LIMIT 5", Some(10), None, Backend::Postgres).unwrap();
187        assert_eq!(sql, "SELECT 1 LIMIT 5");
188    }
189
190    #[cfg(feature = "mssql")]
191    #[test]
192    fn test_mssql_paging() {
193        let sql = apply_paging("SELECT * FROM t", Some(10), Some(5), Backend::MsSql).unwrap();
194        assert_eq!(
195            sql,
196            "SELECT * FROM t ORDER BY (SELECT NULL) OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY"
197        );
198    }
199
200    #[cfg(feature = "mssql")]
201    #[test]
202    fn test_mssql_paging_with_order_by() {
203        let sql = apply_paging(
204            "SELECT * FROM t ORDER BY id",
205            Some(10),
206            Some(5),
207            Backend::MsSql,
208        )
209        .unwrap();
210        assert_eq!(
211            sql,
212            "SELECT * FROM t ORDER BY id OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY"
213        );
214    }
215
216    #[cfg(feature = "oracle")]
217    #[test]
218    fn test_oracle_paging() {
219        let sql = apply_paging("SELECT * FROM t", Some(10), Some(5), Backend::Oracle).unwrap();
220        assert_eq!(
221            sql,
222            "SELECT * FROM t ORDER BY 1 OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY"
223        );
224    }
225
226    #[cfg(feature = "postgres")]
227    #[test]
228    fn test_multistatement_rejected() {
229        let result = apply_paging("SELECT 1; SELECT 2", Some(10), None, Backend::Postgres);
230        assert!(result.is_err());
231    }
232}