nodedb_sql/planner/lateral/correlation.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Extract correlation predicates from a LATERAL subquery's WHERE clause.
4//!
5//! A correlated predicate is one where the left side references the outer
6//! table and the right side references the inner table (or vice versa).
7//! The outer table is identified by its alias or name.
8
9use sqlparser::ast::{self, Expr, SetExpr};
10
11use crate::parser::normalize::normalize_ident;
12
13/// A single equi-correlation pair extracted from the subquery WHERE.
14///
15/// `outer_col` is the column name on the outer (driving) side;
16/// `inner_col` is the column name on the inner (lateral) side.
17#[derive(Debug, Clone)]
18pub struct CorrelationEq {
19 pub outer_col: String,
20 pub inner_col: String,
21}
22
23/// Result of analysing a lateral subquery's WHERE clause.
24#[derive(Debug, Default)]
25pub struct CorrelationAnalysis {
26 /// Equi-join pairs `(outer_col, inner_col)` extracted from `inner.col = outer.col`.
27 pub equi_keys: Vec<CorrelationEq>,
28 /// Non-equi correlated predicates as `(inner_col, outer_col)`. Populated
29 /// as a routing signal (its presence forces the `LateralLoop` shape); the
30 /// predicates themselves are also retained verbatim in `remaining`.
31 pub non_equi: Vec<(String, String)>,
32 /// WHERE expression with the *equi* correlated predicates stripped (those
33 /// become join keys / correlation keys). Non-equi correlated predicates are
34 /// retained here: they lower to `*Column` scan filters whose outer operand
35 /// is bound per outer row by the Data Plane executor. `None` when nothing
36 /// remains.
37 pub remaining: Option<Expr>,
38}
39
40/// Analyse the WHERE clause of a LATERAL subquery.
41///
42/// `outer_alias` is the alias or name of the driving table (e.g. `"u"` for
43/// `FROM users u`). Any compound identifier `outer_alias.col` or `col = outer_alias.col`
44/// is treated as a correlation reference to the outer side.
45pub fn analyse_lateral_where(subquery: &ast::Query, outer_alias: &str) -> CorrelationAnalysis {
46 let select = match subquery.body.as_ref() {
47 SetExpr::Select(s) => s,
48 _ => return CorrelationAnalysis::default(),
49 };
50 let Some(where_expr) = &select.selection else {
51 return CorrelationAnalysis::default();
52 };
53
54 let mut analysis = CorrelationAnalysis::default();
55 analysis.remaining = extract_correlation_recursive(
56 where_expr,
57 outer_alias,
58 &mut analysis.equi_keys,
59 &mut analysis.non_equi,
60 );
61 analysis
62}
63
64/// Walk the WHERE expression, extracting correlated predicates.
65///
66/// Returns `None` when the expression was fully consumed; `Some(expr)` when
67/// a non-correlated residual remains.
68fn extract_correlation_recursive(
69 expr: &Expr,
70 outer_alias: &str,
71 equi_keys: &mut Vec<CorrelationEq>,
72 non_equi: &mut Vec<(String, String)>,
73) -> Option<Expr> {
74 match expr {
75 // AND: recurse both sides.
76 Expr::BinaryOp {
77 left,
78 op: ast::BinaryOperator::And,
79 right,
80 } => {
81 let l = extract_correlation_recursive(left, outer_alias, equi_keys, non_equi);
82 let r = extract_correlation_recursive(right, outer_alias, equi_keys, non_equi);
83 match (l, r) {
84 (None, None) => None,
85 (Some(e), None) | (None, Some(e)) => Some(e),
86 (Some(l), Some(r)) => Some(Expr::BinaryOp {
87 left: Box::new(l),
88 op: ast::BinaryOperator::And,
89 right: Box::new(r),
90 }),
91 }
92 }
93
94 // Equi-predicate: check if one side is an outer reference.
95 Expr::BinaryOp {
96 left,
97 op: ast::BinaryOperator::Eq,
98 right,
99 } => {
100 let lp = compound_parts(left);
101 let rp = compound_parts(right);
102 match (lp, rp) {
103 (Some((lt, lc)), Some((rt, rc))) => {
104 let lc_str = lc.as_str();
105 let rc_str = rc.as_str();
106 let lt_lower = lt.to_lowercase();
107 let rt_lower = rt.to_lowercase();
108 if lt_lower == outer_alias {
109 // left = outer, right = inner
110 equi_keys.push(CorrelationEq {
111 outer_col: lc_str.to_string(),
112 inner_col: rc_str.to_string(),
113 });
114 None
115 } else if rt_lower == outer_alias {
116 // right = outer, left = inner
117 equi_keys.push(CorrelationEq {
118 outer_col: rc_str.to_string(),
119 inner_col: lc_str.to_string(),
120 });
121 None
122 } else {
123 // No outer reference — leave as-is.
124 Some(expr.clone())
125 }
126 }
127 _ => {
128 // Try non-equi correlation detection. A correlated
129 // non-equi predicate is recorded as a routing signal but
130 // kept in the residual WHERE so it lowers to a runtime-bound
131 // `*Column` scan filter.
132 if is_correlated_expr(expr, outer_alias) {
133 extract_non_equi_correlation(expr, outer_alias, non_equi);
134 }
135 Some(expr.clone())
136 }
137 }
138 }
139
140 // Non-equi predicates referencing the outer table. Recorded as a
141 // routing signal and retained in the residual WHERE so the executor
142 // can bind the outer operand at runtime.
143 Expr::BinaryOp { .. } => {
144 if is_correlated_expr(expr, outer_alias) {
145 extract_non_equi_correlation(expr, outer_alias, non_equi);
146 }
147 Some(expr.clone())
148 }
149
150 Expr::Nested(inner) => {
151 extract_correlation_recursive(inner, outer_alias, equi_keys, non_equi)
152 }
153
154 _ => Some(expr.clone()),
155 }
156}
157
158/// True when the expression references the outer table by alias.
159fn is_correlated_expr(expr: &Expr, outer_alias: &str) -> bool {
160 match expr {
161 Expr::CompoundIdentifier(parts) if parts.len() == 2 => {
162 normalize_ident(&parts[0]).eq_ignore_ascii_case(outer_alias)
163 }
164 Expr::BinaryOp { left, right, .. } => {
165 is_correlated_expr(left, outer_alias) || is_correlated_expr(right, outer_alias)
166 }
167 _ => false,
168 }
169}
170
171/// Extract a non-equi correlation from a binary predicate into `non_equi`.
172///
173/// Records `(inner_side, outer_side)` for the predicate.
174fn extract_non_equi_correlation(
175 expr: &Expr,
176 outer_alias: &str,
177 non_equi: &mut Vec<(String, String)>,
178) {
179 let Expr::BinaryOp { left, right, .. } = expr else {
180 return;
181 };
182 let lp = compound_parts(left);
183 let rp = compound_parts(right);
184 if let (Some((lt, lc)), Some((rt, rc))) = (lp, rp) {
185 if rt.eq_ignore_ascii_case(outer_alias) {
186 non_equi.push((lc, rc));
187 } else if lt.eq_ignore_ascii_case(outer_alias) {
188 non_equi.push((rc, lc));
189 }
190 }
191}
192
193/// Extract `(table_alias, column_name)` from a compound identifier.
194fn compound_parts(expr: &Expr) -> Option<(String, String)> {
195 match expr {
196 Expr::CompoundIdentifier(parts) if parts.len() == 2 => {
197 Some((normalize_ident(&parts[0]), normalize_ident(&parts[1])))
198 }
199 _ => None,
200 }
201}