Skip to main content

krishiv_sql/
pipe_syntax.rs

1//! P10: SQL Pipe Syntax — `FROM t |> WHERE x |> SELECT y` → standard SQL.
2//!
3//! Spark 4.0 introduced pipe syntax for SQL readability. This module provides
4//! a pre-processor that converts pipe syntax to standard SQL before parsing.
5//!
6//! # Syntax
7//!
8//! ```sql
9//! -- Pipe syntax
10//! FROM orders |> WHERE amount > 100 |> SELECT customer_id, amount
11//!
12//! -- Equivalent standard SQL
13//! SELECT customer_id, amount FROM orders WHERE amount > 100
14//! ```
15//!
16//! # Supported Pipe Operators
17//!
18//! - `|> WHERE <condition>` — filter rows
19//! - `|> SELECT <columns>` — project columns
20//! - `|> GROUP BY <columns>` — group rows
21//! - `|> ORDER BY <columns>` — sort rows
22//! - `|> LIMIT <n>` — limit output rows
23//! - `|> JOIN <table> ON <condition>` — join with another table
24//! - `|> LEFT JOIN <table> ON <condition>` — left join
25//! - `|> RIGHT JOIN <table> ON <condition>` — right join
26//! - `|> INNER JOIN <table> ON <condition>` — inner join
27//! - `|> CROSS JOIN <table>` — cross join
28
29use std::fmt;
30
31/// Errors that can occur during pipe syntax processing.
32#[derive(Debug)]
33pub enum PipeSyntaxError {
34    /// Invalid pipe syntax.
35    InvalidSyntax(String),
36    /// Unsupported pipe operator.
37    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
51/// Pre-process SQL to convert pipe syntax to standard SQL.
52///
53/// If the SQL does not contain pipe syntax, it is returned unchanged.
54pub fn process_pipe_syntax(sql: &str) -> Result<String, PipeSyntaxError> {
55    let trimmed = sql.trim();
56
57    // Check if this is a pipe syntax query (starts with FROM and contains |>)
58    if !trimmed.to_uppercase().starts_with("FROM ") || !trimmed.contains("|>") {
59        return Ok(trimmed.to_string());
60    }
61
62    // Split on pipe operator. `split_first` rather than `parts[0]`/`&parts[1..]`:
63    // the crate denies `clippy::indexing_slicing`, which this file never had to
64    // satisfy while it was outside the module tree.
65    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    // First part is the FROM clause
74    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    // Pipe syntax is *sequential*: each stage applies to the result of the one
84    // before it. Flattening the stages into one SELECT loses that, so the two
85    // places where order is observable are tracked explicitly:
86    //
87    //  * a repeated stage cannot be expressed in a single flat SELECT, so it is
88    //    rejected rather than silently overwriting the earlier one. Assigning
89    //    `where_clause = …` per stage meant `|> WHERE a |> WHERE b` dropped
90    //    `a` entirely and returned rows that never satisfied it;
91    //  * a filter *after* a GROUP BY is a HAVING, not a WHERE. Emitting it as a
92    //    WHERE applies it before grouping, which is a different query.
93    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    /// Reject a second occurrence instead of discarding the first.
102    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        // ASCII folding so `strip_kw`'s byte offsets into `part` are valid.
120        let upper = part.to_ascii_uppercase();
121
122        if let Some(rest) = strip_kw(part, &upper, "WHERE ") {
123            // Grouped already? Then this filters groups, i.e. HAVING.
124            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    // Build standard SQL
150    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    // Successive filters compose by conjunction, which is exactly what
168    // `filter(filter(t, a), b)` means.
169    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
193/// Strip a leading keyword, matching case-insensitively but returning the
194/// original-case remainder. `upper` must be `part.to_uppercase()`; both are
195/// ASCII keywords so the byte offsets line up.
196fn 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
205/// Combine filters with AND, parenthesising each so `a OR b` composed with `c`
206/// does not silently become `a OR (b AND c)`.
207fn 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
218/// Check if SQL contains pipe syntax.
219pub 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    /// Successive filters compose by conjunction. Each stage used to *assign*
266    /// `where_clause`, so `|> WHERE a |> WHERE b` dropped `a` and returned rows
267    /// that never satisfied it.
268    #[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    /// Each filter is parenthesised so a disjunction cannot be re-associated.
279    #[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    /// A filter *after* a GROUP BY filters groups — that is HAVING. Emitting it
287    /// as a WHERE would apply it before grouping, a different query.
288    #[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    /// ...and a filter *before* the GROUP BY stays a WHERE.
300    #[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    /// A repeated stage that cannot be flattened must be refused, not silently
308    /// reduced to its last occurrence.
309    #[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    /// The translator has to be reachable from the engine, not merely correct.
334    /// The module was never declared in `lib.rs`, so none of this was compiled
335    /// and `FROM t |> …` went to DataFusion verbatim and failed to parse.
336    #[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}