krishiv_sql/
pipe_syntax.rs1use std::fmt;
30
31#[derive(Debug)]
33pub enum PipeSyntaxError {
34 InvalidSyntax(String),
36 UnsupportedOperator(String),
38}
39
40impl fmt::Display for PipeSyntaxError {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 Self::InvalidSyntax(msg) => write!(f, "invalid pipe syntax: {msg}"),
44 Self::UnsupportedOperator(msg) => write!(f, "unsupported pipe operator: {msg}"),
45 }
46 }
47}
48
49impl std::error::Error for PipeSyntaxError {}
50
51pub fn process_pipe_syntax(sql: &str) -> Result<String, PipeSyntaxError> {
55 let trimmed = sql.trim();
56
57 if !trimmed.to_uppercase().starts_with("FROM ") || !trimmed.contains("|>") {
59 return Ok(trimmed.to_string());
60 }
61
62 let parts: Vec<&str> = trimmed.split("|>").collect();
66 let Some((from_clause, stages)) = parts.split_first() else {
67 return Ok(trimmed.to_string());
68 };
69 if stages.is_empty() {
70 return Ok(trimmed.to_string());
71 }
72
73 let from_clause = from_clause.trim();
75 if !from_clause.to_uppercase().starts_with("FROM ") {
76 return Err(PipeSyntaxError::InvalidSyntax(
77 "pipe syntax must start with FROM".into(),
78 ));
79 }
80
81 let table_name = from_clause[5..].trim();
82
83 let mut predicates: Vec<String> = Vec::new();
94 let mut having: Vec<String> = Vec::new();
95 let mut select_clause: Option<String> = None;
96 let mut group_by_clause: Option<String> = None;
97 let mut order_by_clause: Option<String> = None;
98 let mut limit_clause: Option<String> = None;
99 let mut join_clauses = Vec::new();
100
101 fn set_once(
103 slot: &mut Option<String>,
104 value: String,
105 operator: &str,
106 ) -> Result<(), PipeSyntaxError> {
107 if slot.is_some() {
108 return Err(PipeSyntaxError::UnsupportedOperator(format!(
109 "repeated |> {operator}: a pipeline with more than one {operator} stage cannot \
110 be expressed as a single SELECT; write it as a subquery"
111 )));
112 }
113 *slot = Some(value);
114 Ok(())
115 }
116
117 for part in stages {
118 let part = part.trim();
119 let upper = part.to_ascii_uppercase();
121
122 if let Some(rest) = strip_kw(part, &upper, "WHERE ") {
123 if group_by_clause.is_some() {
125 having.push(rest.to_string());
126 } else {
127 predicates.push(rest.to_string());
128 }
129 } else if let Some(rest) = strip_kw(part, &upper, "SELECT ") {
130 set_once(&mut select_clause, rest.to_string(), "SELECT")?;
131 } else if let Some(rest) = strip_kw(part, &upper, "GROUP BY ") {
132 set_once(&mut group_by_clause, rest.to_string(), "GROUP BY")?;
133 } else if let Some(rest) = strip_kw(part, &upper, "ORDER BY ") {
134 set_once(&mut order_by_clause, rest.to_string(), "ORDER BY")?;
135 } else if let Some(rest) = strip_kw(part, &upper, "LIMIT ") {
136 set_once(&mut limit_clause, rest.to_string(), "LIMIT")?;
137 } else if upper.starts_with("JOIN ")
138 || upper.starts_with("INNER JOIN ")
139 || upper.starts_with("LEFT JOIN ")
140 || upper.starts_with("RIGHT JOIN ")
141 || upper.starts_with("CROSS JOIN ")
142 {
143 join_clauses.push(part.to_string());
144 } else {
145 return Err(PipeSyntaxError::UnsupportedOperator(part.to_string()));
146 }
147 }
148
149 let mut sql = String::new();
151 match &select_clause {
152 Some(projection) => {
153 sql.push_str("SELECT ");
154 sql.push_str(projection);
155 }
156 None => sql.push_str("SELECT *"),
157 }
158
159 sql.push_str(" FROM ");
160 sql.push_str(table_name);
161
162 for join in &join_clauses {
163 sql.push(' ');
164 sql.push_str(join);
165 }
166
167 if !predicates.is_empty() {
170 sql.push_str(" WHERE ");
171 sql.push_str(&join_predicates(&predicates));
172 }
173 if let Some(group_by) = &group_by_clause {
174 sql.push_str(" GROUP BY ");
175 sql.push_str(group_by);
176 }
177 if !having.is_empty() {
178 sql.push_str(" HAVING ");
179 sql.push_str(&join_predicates(&having));
180 }
181 if let Some(order_by) = &order_by_clause {
182 sql.push_str(" ORDER BY ");
183 sql.push_str(order_by);
184 }
185 if let Some(limit) = &limit_clause {
186 sql.push_str(" LIMIT ");
187 sql.push_str(limit);
188 }
189
190 Ok(sql)
191}
192
193fn strip_kw<'a>(part: &'a str, upper: &str, keyword: &str) -> Option<&'a str> {
197 upper
198 .starts_with(keyword)
199 .then(|| part.get(keyword.len()..))
200 .flatten()
201 .map(str::trim)
202 .filter(|rest| !rest.is_empty())
203}
204
205fn join_predicates(parts: &[String]) -> String {
208 if let [single] = parts {
209 return single.clone();
210 }
211 parts
212 .iter()
213 .map(|p| format!("({p})"))
214 .collect::<Vec<_>>()
215 .join(" AND ")
216}
217
218pub fn has_pipe_syntax(sql: &str) -> bool {
220 let trimmed = sql.trim();
221 trimmed.to_uppercase().starts_with("FROM ") && trimmed.contains("|>")
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn simple_pipe_syntax() {
230 let sql = "FROM orders |> WHERE amount > 100 |> SELECT customer_id, amount";
231 let result = process_pipe_syntax(sql).unwrap();
232 assert_eq!(
233 result,
234 "SELECT customer_id, amount FROM orders WHERE amount > 100"
235 );
236 }
237
238 #[test]
239 fn pipe_syntax_with_group_by() {
240 let sql = "FROM orders |> GROUP BY region |> SELECT region, SUM(amount) as total";
241 let result = process_pipe_syntax(sql).unwrap();
242 assert_eq!(
243 result,
244 "SELECT region, SUM(amount) as total FROM orders GROUP BY region"
245 );
246 }
247
248 #[test]
249 fn pipe_syntax_with_order_by_and_limit() {
250 let sql = "FROM orders |> ORDER BY amount DESC |> LIMIT 10";
251 let result = process_pipe_syntax(sql).unwrap();
252 assert_eq!(result, "SELECT * FROM orders ORDER BY amount DESC LIMIT 10");
253 }
254
255 #[test]
256 fn pipe_syntax_with_join() {
257 let sql = "FROM orders |> JOIN customers ON orders.customer_id = customers.id |> SELECT *";
258 let result = process_pipe_syntax(sql).unwrap();
259 assert_eq!(
260 result,
261 "SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id"
262 );
263 }
264
265 #[test]
269 fn successive_where_stages_are_conjoined_not_overwritten() {
270 let sql = "FROM orders |> WHERE amount > 100 |> WHERE region = 'US' |> SELECT id";
271 let result = process_pipe_syntax(sql).unwrap();
272 assert_eq!(
273 result,
274 "SELECT id FROM orders WHERE (amount > 100) AND (region = 'US')"
275 );
276 }
277
278 #[test]
280 fn conjoined_filters_are_parenthesised() {
281 let sql = "FROM t |> WHERE a OR b |> WHERE c";
282 let result = process_pipe_syntax(sql).unwrap();
283 assert_eq!(result, "SELECT * FROM t WHERE (a OR b) AND (c)");
284 }
285
286 #[test]
289 fn a_filter_after_group_by_becomes_having() {
290 let sql = "FROM orders |> GROUP BY region |> WHERE SUM(amount) > 500 \
291 |> SELECT region, SUM(amount)";
292 let result = process_pipe_syntax(sql).unwrap();
293 assert_eq!(
294 result,
295 "SELECT region, SUM(amount) FROM orders GROUP BY region HAVING SUM(amount) > 500"
296 );
297 }
298
299 #[test]
301 fn a_filter_before_group_by_stays_a_where() {
302 let sql = "FROM orders |> WHERE amount > 0 |> GROUP BY region";
303 let result = process_pipe_syntax(sql).unwrap();
304 assert_eq!(result, "SELECT * FROM orders WHERE amount > 0 GROUP BY region");
305 }
306
307 #[test]
310 fn repeated_unflattenable_stages_are_rejected() {
311 for sql in [
312 "FROM t |> SELECT a |> SELECT b",
313 "FROM t |> GROUP BY a |> GROUP BY b",
314 "FROM t |> ORDER BY a |> ORDER BY b",
315 "FROM t |> LIMIT 1 |> LIMIT 2",
316 ] {
317 let error = process_pipe_syntax(sql)
318 .expect_err("a repeated stage must be refused, not silently dropped");
319 assert!(
320 error.to_string().contains("repeated |>"),
321 "unexpected error for {sql:?}: {error}"
322 );
323 }
324 }
325
326 #[test]
327 fn standard_sql_unchanged() {
328 let sql = "SELECT * FROM orders WHERE amount > 100";
329 let result = process_pipe_syntax(sql).unwrap();
330 assert_eq!(result, sql);
331 }
332
333 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
337 async fn pipe_syntax_runs_through_the_engine() {
338 use arrow::array::Int64Array;
339 use arrow::datatypes::{DataType, Field, Schema};
340 use std::sync::Arc;
341
342 let engine = crate::SqlEngine::new();
343 let schema = Arc::new(Schema::new(vec![Field::new("amount", DataType::Int64, false)]));
344 let batch = arrow::record_batch::RecordBatch::try_new(
345 schema,
346 vec![Arc::new(Int64Array::from(vec![50_i64, 150, 250]))],
347 )
348 .unwrap();
349 engine
350 .register_record_batches("orders", vec![batch])
351 .await
352 .unwrap();
353
354 let batches = engine
355 .sql("FROM orders |> WHERE amount > 100 |> WHERE amount < 200 |> SELECT amount")
356 .await
357 .expect("piped query should plan")
358 .collect()
359 .await
360 .expect("piped query should execute");
361
362 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
363 assert_eq!(
364 rows, 1,
365 "both filters must apply: only amount=150 is >100 and <200"
366 );
367 }
368
369 #[test]
370 fn has_pipe_syntax_detection() {
371 assert!(has_pipe_syntax("FROM t |> SELECT *"));
372 assert!(!has_pipe_syntax("SELECT * FROM t"));
373 assert!(!has_pipe_syntax("FROM t"));
374 }
375}