Skip to main content

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}