ferrule_sql/
query_builder.rs1use crate::backend::Backend;
2use crate::error::SqlError;
3
4pub 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 if trimmed.contains(';') {
42 return Err(SqlError::QueryFailed(
43 "Multi-statement SQL does not support --limit / --offset.".into(),
44 ));
45 }
46
47 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 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 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 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}