Skip to main content

krishiv_sql/
subquery.rs

1//! E5.1 — Correlated subquery decorrelation: EXISTS/IN/scalar subquery analysis.
2//!
3//! DataFusion 53 already handles subquery decorrelation for batch queries via
4//! the `DecorrelatePredicateSubquery` optimizer rule. This module adds:
5//!
6//! 1. **AST-level detection** of EXISTS/IN/NOT IN/scalar subquery patterns.
7//! 2. **Streaming guard**: rejects correlated subqueries that reference a
8//!    registered streaming table — DataFusion does not handle these.
9//! 3. **Kind classification** so callers can adapt error messages and explain output.
10
11use std::collections::HashSet;
12
13use datafusion::sql::sqlparser::ast::{Expr, Query, Statement, visit_expressions, visit_relations};
14use datafusion::sql::sqlparser::dialect::GenericDialect;
15use datafusion::sql::sqlparser::parser::Parser;
16
17use crate::{SqlError, SqlResult};
18
19// ── Subquery kind ─────────────────────────────────────────────────────────────
20
21/// Classification of a subquery occurrence detected in a SQL statement.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum SubqueryKind {
24    /// `expr IN (SELECT ...)` — rewritten by DataFusion to a left-semi join.
25    InSubquery,
26    /// `expr NOT IN (SELECT ...)` — rewritten to a left-anti join.
27    NotInSubquery,
28    /// `EXISTS (SELECT ...)` — rewritten to a left-semi join.
29    Exists,
30    /// `NOT EXISTS (SELECT ...)` — rewritten to a left-anti join.
31    NotExists,
32    /// `(SELECT single_value)` used as a scalar expression — rewritten to an
33    /// apply/cross-join with a LIMIT 1 inner query.
34    Scalar,
35}
36
37/// A subquery occurrence found in a SQL statement.
38#[derive(Debug, Clone)]
39pub struct DetectedSubquery {
40    pub kind: SubqueryKind,
41    /// The inner query text (as rendered by the AST `Display` impl).
42    pub inner_query: String,
43}
44
45// ── Detection ─────────────────────────────────────────────────────────────────
46
47/// Analyse `sql` and return every subquery occurrence.
48///
49/// Returns an empty vec if the SQL contains no subqueries.
50/// Returns a parse error only when the SQL is syntactically invalid.
51pub fn detect_subqueries(sql: &str) -> SqlResult<Vec<DetectedSubquery>> {
52    let dialect = GenericDialect {};
53    let stmts = Parser::parse_sql(&dialect, sql).map_err(|e| SqlError::Unsupported {
54        feature: format!("subquery detection: parse error: {e}"),
55    })?;
56
57    let mut found = Vec::new();
58
59    for stmt in &stmts {
60        collect_subqueries(stmt, &mut found);
61    }
62
63    Ok(found)
64}
65
66/// Push every subquery occurrence anywhere inside `node`.
67///
68/// This walks the AST with sqlparser's own expression visitor rather than a
69/// hand-written traversal. The hand-written one descended only
70/// `SetExpr::Select` and a fixed list of `Expr` variants, so it silently saw
71/// nothing in a `UNION` branch, a CTE, a derived table, a `JOIN ... ON`
72/// condition, or even a parenthesised predicate (`Expr::Nested`) — and this
73/// module's whole job is to *reject* streaming subqueries, so each gap was a
74/// way past the guard rather than a missing nicety. Delegating to the visitor
75/// makes the coverage a property of the parser instead of of this list.
76///
77/// The visitor already recurses through subquery bodies, so nested occurrences
78/// are reported without descending explicitly.
79fn collect_subqueries<V>(node: &V, out: &mut Vec<DetectedSubquery>)
80where
81    V: datafusion::sql::sqlparser::ast::Visit,
82{
83    let _ = visit_expressions(node, |expr| {
84        match expr {
85            Expr::InSubquery {
86                subquery, negated, ..
87            } => out.push(DetectedSubquery {
88                kind: if *negated {
89                    SubqueryKind::NotInSubquery
90                } else {
91                    SubqueryKind::InSubquery
92                },
93                inner_query: subquery.to_string(),
94            }),
95            Expr::Exists { subquery, negated } => out.push(DetectedSubquery {
96                kind: if *negated {
97                    SubqueryKind::NotExists
98                } else {
99                    SubqueryKind::Exists
100                },
101                inner_query: subquery.to_string(),
102            }),
103            Expr::Subquery(q) => out.push(DetectedSubquery {
104                kind: SubqueryKind::Scalar,
105                inner_query: q.to_string(),
106            }),
107            _ => {}
108        }
109        std::ops::ControlFlow::<()>::Continue(())
110    });
111}
112
113// ── Streaming guard ───────────────────────────────────────────────────────────
114
115/// Validate that `sql` contains no subqueries that reference a streaming table.
116///
117/// Returns `Ok(())` when either:
118/// - No subqueries are present, or
119/// - No subquery body references a name in `streaming_tables`.
120///
121/// Returns `Err` when a subquery body contains a streaming table name (case-
122/// insensitive), because DataFusion's decorrelation rules do not handle unbounded
123/// inputs.
124pub fn validate_no_streaming_subqueries(
125    sql: &str,
126    streaming_tables: &HashSet<String>,
127) -> SqlResult<()> {
128    if streaming_tables.is_empty() {
129        return Ok(());
130    }
131
132    // Normalize to lowercase for case-insensitive matching against the SQL
133    // identifier names produced by extract_table_names_from_query.
134    let lower_tables: HashSet<String> = streaming_tables.iter().map(|s| s.to_lowercase()).collect();
135
136    let dialect = GenericDialect {};
137    let stmts = match Parser::parse_sql(&dialect, sql) {
138        Ok(s) => s,
139        Err(_) => return Ok(()), // parse errors are surfaced later by DataFusion
140    };
141
142    for stmt in &stmts {
143        {
144            let mut subqueries = Vec::new();
145            collect_subqueries(stmt, &mut subqueries);
146            for sq in &subqueries {
147                let inner_stmts =
148                    Parser::parse_sql(&GenericDialect {}, &sq.inner_query).unwrap_or_default();
149                for s in &inner_stmts {
150                    if let Statement::Query(iq) = s {
151                        let names = extract_table_names_from_query(iq);
152                        if names.iter().any(|t| lower_tables.contains(t)) {
153                            return Err(SqlError::Unsupported {
154                                feature: "correlated subquery over a streaming (unbounded) table \
155                                          is not supported; use a streaming join or MATCH_RECOGNIZE \
156                                          for event-pattern matching"
157                                    .into(),
158                            });
159                        }
160                    }
161                }
162            }
163        }
164    }
165    Ok(())
166}
167
168fn extract_table_names_from_query(query: &Query) -> HashSet<String> {
169    let mut names = HashSet::new();
170    let _ = visit_relations(query, |relation| {
171        names.insert(relation.to_string().to_lowercase());
172        std::ops::ControlFlow::<()>::Continue(())
173    });
174    names
175}
176
177// ── Explain helpers ───────────────────────────────────────────────────────────
178
179/// Return a human-readable summary of subquery kinds found in `sql`.
180///
181/// Returns `None` when `sql` has no subqueries.
182pub fn explain_subqueries(sql: &str) -> Option<String> {
183    let found = detect_subqueries(sql).unwrap_or_default();
184    if found.is_empty() {
185        return None;
186    }
187    let summary = found
188        .iter()
189        .map(|sq| match sq.kind {
190            SubqueryKind::InSubquery => "IN-subquery → semi-join",
191            SubqueryKind::NotInSubquery => "NOT IN-subquery → anti-join",
192            SubqueryKind::Exists => "EXISTS → semi-join",
193            SubqueryKind::NotExists => "NOT EXISTS → anti-join",
194            SubqueryKind::Scalar => "scalar subquery → cross-apply",
195        })
196        .collect::<Vec<_>>()
197        .join(", ");
198    Some(format!("subqueries: [{summary}]"))
199}
200
201// ── Tests ─────────────────────────────────────────────────────────────────────
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn detects_in_subquery() {
209        let sql = "SELECT * FROM orders WHERE customer_id IN (SELECT id FROM vip_customers)";
210        let found = detect_subqueries(sql).unwrap();
211        assert_eq!(found.len(), 1);
212        assert_eq!(found[0].kind, SubqueryKind::InSubquery);
213    }
214
215    #[test]
216    fn detects_not_in_subquery() {
217        let sql = "SELECT * FROM orders WHERE customer_id NOT IN (SELECT id FROM banned)";
218        let found = detect_subqueries(sql).unwrap();
219        assert_eq!(found.len(), 1);
220        assert_eq!(found[0].kind, SubqueryKind::NotInSubquery);
221    }
222
223    #[test]
224    fn detects_exists_subquery() {
225        let sql = "SELECT * FROM orders o WHERE EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id)";
226        let found = detect_subqueries(sql).unwrap();
227        assert_eq!(found.len(), 1);
228        assert_eq!(found[0].kind, SubqueryKind::Exists);
229    }
230
231    #[test]
232    fn detects_not_exists_subquery() {
233        let sql = "SELECT * FROM orders o WHERE NOT EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id)";
234        let found = detect_subqueries(sql).unwrap();
235        assert_eq!(found.len(), 1);
236        assert_eq!(found[0].kind, SubqueryKind::NotExists);
237    }
238
239    #[test]
240    fn detects_scalar_subquery() {
241        let sql = "SELECT id, (SELECT MAX(amount) FROM payments WHERE order_id = o.id) as max_payment FROM orders o";
242        let found = detect_subqueries(sql).unwrap();
243        assert_eq!(found.len(), 1);
244        assert_eq!(found[0].kind, SubqueryKind::Scalar);
245    }
246
247    #[test]
248    fn detects_nested_subqueries() {
249        let sql = "SELECT * FROM a WHERE x IN (SELECT y FROM b WHERE y NOT IN (SELECT z FROM c))";
250        let found = detect_subqueries(sql).unwrap();
251        assert!(found.len() >= 2);
252        assert!(found.iter().any(|s| s.kind == SubqueryKind::InSubquery));
253        assert!(found.iter().any(|s| s.kind == SubqueryKind::NotInSubquery));
254    }
255
256    #[test]
257    fn no_subqueries_returns_empty() {
258        let sql = "SELECT id, amount FROM orders WHERE status = 'completed'";
259        let found = detect_subqueries(sql).unwrap();
260        assert!(found.is_empty());
261    }
262
263    #[test]
264    fn streaming_guard_passes_when_no_streaming_tables() {
265        let sql = "SELECT * FROM t WHERE id IN (SELECT id FROM s)";
266        let streaming: HashSet<String> = HashSet::new();
267        assert!(validate_no_streaming_subqueries(sql, &streaming).is_ok());
268    }
269
270    #[test]
271    fn streaming_guard_rejects_subquery_over_streaming_table() {
272        let sql = "SELECT * FROM events WHERE id IN (SELECT id FROM live_stream)";
273        let mut streaming = HashSet::new();
274        streaming.insert("live_stream".into());
275        let err = validate_no_streaming_subqueries(sql, &streaming).unwrap_err();
276        assert!(matches!(err, SqlError::Unsupported { .. }));
277    }
278
279    #[test]
280    fn streaming_guard_passes_for_batch_tables() {
281        let sql = "SELECT * FROM events WHERE id IN (SELECT id FROM reference_table)";
282        let mut streaming = HashSet::new();
283        streaming.insert("live_stream".into());
284        assert!(validate_no_streaming_subqueries(sql, &streaming).is_ok());
285    }
286
287    #[test]
288    fn explain_subqueries_returns_none_for_plain_sql() {
289        assert!(explain_subqueries("SELECT 1").is_none());
290    }
291
292    #[test]
293    fn explain_subqueries_describes_kinds() {
294        let sql = "SELECT * FROM t WHERE x IN (SELECT y FROM s)";
295        let desc = explain_subqueries(sql).unwrap();
296        assert!(desc.contains("semi-join"));
297    }
298
299    #[test]
300    fn case_expression_does_not_panic() {
301        let sql = "SELECT CASE WHEN x > 0 THEN 'pos' ELSE 'neg' END FROM t";
302        let found = detect_subqueries(sql).unwrap();
303        assert!(found.is_empty());
304    }
305
306    // ── Places the hand-written traversal never looked ────────────────────
307    //
308    // Each of these parses to a node the old walk did not descend, so it
309    // reported zero subqueries and `validate_no_streaming_subqueries` waved
310    // the statement through. They are listed one per construct because the
311    // failure mode is silent: a guard that returns `Ok(())` looks exactly
312    // like a guard that ran.
313
314    /// `query.body` is a `SetOperation`, not a `Select`.
315    #[test]
316    fn finds_a_subquery_in_a_union_branch() {
317        let sql = "SELECT a FROM t WHERE a IN (SELECT id FROM s) UNION ALL SELECT b FROM u";
318        let found = detect_subqueries(sql).unwrap();
319        assert_eq!(found.len(), 1, "union branch not scanned: {found:?}");
320        assert_eq!(found[0].kind, SubqueryKind::InSubquery);
321    }
322
323    /// `query.with` was never visited at all.
324    #[test]
325    fn finds_a_subquery_inside_a_cte() {
326        let sql = "WITH c AS (SELECT id FROM t WHERE id IN (SELECT id FROM s)) SELECT * FROM c";
327        let found = detect_subqueries(sql).unwrap();
328        assert_eq!(found.len(), 1, "CTE body not scanned: {found:?}");
329    }
330
331    /// Join constraints live in `sel.from`, which was not walked.
332    #[test]
333    fn finds_a_subquery_in_a_join_condition() {
334        let sql = "SELECT * FROM a JOIN b ON b.id IN (SELECT id FROM s)";
335        let found = detect_subqueries(sql).unwrap();
336        assert_eq!(found.len(), 1, "join condition not scanned: {found:?}");
337    }
338
339    /// A derived table is a whole query hiding in `sel.from`.
340    #[test]
341    fn finds_a_subquery_inside_a_derived_table() {
342        let sql = "SELECT * FROM (SELECT id FROM t WHERE id IN (SELECT id FROM s)) d";
343        let found = detect_subqueries(sql).unwrap();
344        assert_eq!(found.len(), 1, "derived table not scanned: {found:?}");
345    }
346
347    /// Parentheses alone were enough to hide a subquery: they wrap the
348    /// predicate in `Expr::Nested`, which the old `collect_from_expr` did not
349    /// match, so it stopped there.
350    #[test]
351    fn finds_a_subquery_behind_parentheses() {
352        let sql = "SELECT * FROM t WHERE (id IN (SELECT id FROM s))";
353        let found = detect_subqueries(sql).unwrap();
354        assert_eq!(found.len(), 1, "parenthesised predicate not scanned: {found:?}");
355    }
356
357    /// The point of the module: the guard must not be bypassable by writing
358    /// the same query with a UNION.
359    #[test]
360    fn the_streaming_guard_is_not_bypassed_by_a_union() {
361        let sql = "SELECT a FROM events WHERE a IN (SELECT id FROM live_stream) \
362                   UNION ALL SELECT b FROM other";
363        let mut streaming = HashSet::new();
364        streaming.insert("live_stream".into());
365        assert!(
366            validate_no_streaming_subqueries(sql, &streaming).is_err(),
367            "a streaming subquery in a UNION branch slipped past the guard"
368        );
369    }
370
371    /// Same, hidden in a CTE.
372    #[test]
373    fn the_streaming_guard_is_not_bypassed_by_a_cte() {
374        let sql = "WITH c AS (SELECT id FROM events WHERE id IN (SELECT id FROM live_stream)) \
375                   SELECT * FROM c";
376        let mut streaming = HashSet::new();
377        streaming.insert("live_stream".into());
378        assert!(
379            validate_no_streaming_subqueries(sql, &streaming).is_err(),
380            "a streaming subquery in a CTE slipped past the guard"
381        );
382    }
383
384    /// A statement that is not a bare `Query` — the old loop matched only
385    /// `Statement::Query`, so `INSERT ... SELECT` was never examined.
386    #[test]
387    fn the_streaming_guard_examines_insert_select() {
388        let sql = "INSERT INTO sink SELECT id FROM events WHERE id IN (SELECT id FROM live_stream)";
389        let mut streaming = HashSet::new();
390        streaming.insert("live_stream".into());
391        assert!(
392            validate_no_streaming_subqueries(sql, &streaming).is_err(),
393            "INSERT ... SELECT was not examined"
394        );
395    }
396}