nodedb_sql/planner/subquery/extract.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! The WHERE-clause walk that pulls subquery predicates out into joins.
4//!
5//! Rewrites WHERE-clause subqueries into semi/anti joins so the existing
6//! hash-join executor handles them without a dedicated subquery engine.
7//!
8//! Supported patterns:
9//! - `WHERE col IN (SELECT col2 FROM tbl ...)` → semi-join
10//! - `WHERE col NOT IN (SELECT col2 FROM tbl ...)` → anti-join
11//! - `WHERE EXISTS (SELECT ... )` → semi-join
12//! - `WHERE NOT EXISTS (SELECT ... )` → anti-join
13//! - `WHERE col > (SELECT AGG(...) FROM tbl ...)` → scalar subquery (materialized)
14
15use sqlparser::ast::{self, Expr};
16
17use crate::error::Result;
18use crate::functions::registry::FunctionRegistry;
19use crate::resolver::columns::TableScope;
20use crate::types::*;
21
22/// Result of extracting subqueries from a WHERE clause.
23pub struct SubqueryExtraction {
24 /// Semi/anti joins to wrap around the base scan.
25 pub joins: Vec<SubqueryJoin>,
26 /// Remaining WHERE expression with subqueries removed (None if nothing remains).
27 pub remaining_where: Option<Expr>,
28}
29
30/// A subquery that was rewritten as a join.
31pub struct SubqueryJoin {
32 /// Equi-join keys as `(outer column, inner column)` pairs. Empty for an
33 /// uncorrelated subquery: the probe then treats every inner row as a
34 /// candidate, which is what `EXISTS` over an unrelated table means.
35 pub on: Vec<(String, String)>,
36 /// The planned inner SELECT.
37 pub inner_plan: SqlPlan,
38 /// Semi (IN / EXISTS) or Anti (NOT IN / NOT EXISTS).
39 pub join_type: JoinType,
40}
41
42/// Extract subquery predicates from a WHERE clause.
43///
44/// `outer` is the scope of the enclosing query, so a correlated reference
45/// inside a subquery resolves against the relation that owns it.
46///
47/// Returns the extracted subquery joins and the remaining WHERE expression
48/// (with subquery predicates removed). If the entire WHERE is a single
49/// subquery predicate, `remaining_where` is `None`.
50pub fn extract_subqueries(
51 expr: &Expr,
52 outer: &TableScope,
53 catalog: &dyn SqlCatalog,
54 functions: &FunctionRegistry,
55 temporal: crate::TemporalScope,
56) -> Result<SubqueryExtraction> {
57 let mut joins = Vec::new();
58 let remaining = extract_recursive(expr, &mut joins, outer, catalog, functions, temporal)?;
59 Ok(SubqueryExtraction {
60 joins,
61 remaining_where: remaining,
62 })
63}
64
65/// Recursively walk the WHERE expression, extracting subquery predicates.
66///
67/// Returns `None` if the entire expression was consumed (subquery-only),
68/// or `Some(expr)` with the remaining non-subquery predicates.
69fn extract_recursive(
70 expr: &Expr,
71 joins: &mut Vec<SubqueryJoin>,
72 outer: &TableScope,
73 catalog: &dyn SqlCatalog,
74 functions: &FunctionRegistry,
75 temporal: crate::TemporalScope,
76) -> Result<Option<Expr>> {
77 match expr {
78 // AND: recurse both sides, reconstruct with remaining parts.
79 Expr::BinaryOp {
80 left,
81 op: ast::BinaryOperator::And,
82 right,
83 } => {
84 let left_remaining =
85 extract_recursive(left, joins, outer, catalog, functions, temporal)?;
86 let right_remaining =
87 extract_recursive(right, joins, outer, catalog, functions, temporal)?;
88 match (left_remaining, right_remaining) {
89 (None, None) => Ok(None),
90 (Some(l), None) => Ok(Some(l)),
91 (None, Some(r)) => Ok(Some(r)),
92 (Some(l), Some(r)) => Ok(Some(Expr::BinaryOp {
93 left: Box::new(l),
94 op: ast::BinaryOperator::And,
95 right: Box::new(r),
96 })),
97 }
98 }
99
100 // IN (SELECT ...): rewrite as semi-join.
101 Expr::InSubquery {
102 expr: outer_expr,
103 subquery,
104 negated,
105 } => {
106 if let Some(join) = super::in_list::try_plan_in_subquery(
107 outer_expr, subquery, *negated, outer, catalog, functions, temporal,
108 )? {
109 joins.push(join);
110 Ok(None) // This predicate is consumed.
111 } else {
112 // Cannot plan as join — return original expression.
113 Ok(Some(expr.clone()))
114 }
115 }
116
117 // Scalar subquery comparison: `col > (SELECT AGG(...) FROM ...)`
118 Expr::BinaryOp { left, op, right } if is_comparison_op(op) => {
119 if let Expr::Subquery(subquery) = right.as_ref() {
120 if let Some(scalar) =
121 super::scalar::try_plan_scalar_subquery(subquery, catalog, functions, temporal)?
122 {
123 joins.push(scalar.join);
124 Ok(Some(Expr::BinaryOp {
125 left: left.clone(),
126 op: op.clone(),
127 right: Box::new(scalar.replacement_expr),
128 }))
129 } else {
130 Ok(Some(expr.clone()))
131 }
132 } else {
133 Ok(Some(expr.clone()))
134 }
135 }
136
137 // EXISTS (SELECT ...): rewrite as semi-join.
138 // NOT EXISTS (SELECT ...): rewrite as anti-join.
139 //
140 // A shape the planner cannot lower raises a typed error naming that
141 // shape. Leaving the node in the residual WHERE would instead surface
142 // it as an unsupported *expression*, which says nothing actionable.
143 Expr::Exists { subquery, negated } => {
144 joins.push(super::exists::plan_exists_subquery(
145 subquery, *negated, outer, catalog, functions, temporal,
146 )?);
147 Ok(None)
148 }
149
150 // Nested parentheses.
151 Expr::Nested(inner) => extract_recursive(inner, joins, outer, catalog, functions, temporal),
152
153 // Not a subquery pattern — return as-is.
154 _ => Ok(Some(expr.clone())),
155 }
156}
157
158fn is_comparison_op(op: &ast::BinaryOperator) -> bool {
159 matches!(
160 op,
161 ast::BinaryOperator::Gt
162 | ast::BinaryOperator::GtEq
163 | ast::BinaryOperator::Lt
164 | ast::BinaryOperator::LtEq
165 | ast::BinaryOperator::Eq
166 | ast::BinaryOperator::NotEq
167 )
168}