Skip to main content

linkrs_query/optimizer/heuristic/join_optimization/
extract_equi_join_keys.rs

1//! Extract equi-join keys from a Filter above a keyless InnerJoin.
2//!
3//! This rule identifies the Filter -> InnerJoin pattern where the join
4//! carries no hash/probe keys (a cross product with the predicate evaluated
5//! per pair above it) and moves cross-side equalities into the join keys.
6//!
7//! # Conversion example
8//!
9//! Before:
10//! ```text
11//!   Filter(a.value = b.value)
12//!           |
13//!   InnerJoin (no keys)
14//!   /          \
15//! Left        Right
16//! ```
17//!
18//! After:
19//! ```text
20//!   InnerJoin (hash_keys=[b.value], probe_keys=[a.value])
21//!   /          \
22//! Left        Right
23//! ```
24//!
25//! # Applicable Conditions
26//!
27//! - The InnerJoin has empty hash/probe keys (existing keys are never
28//!   disturbed).
29//! - A top-level `AND` conjunct is an `Equal` whose two sides each reference
30//!   columns from exactly one join input (left/right or right/left).
31//!   Anything else (single-side predicates, literals, non-equalities,
32//!   ambiguous column references) stays in a residual Filter above the join.
33//!
34//! # Side convention
35//!
36//! The executor builds the hash table from the right child with `hash_keys`
37//! and probes it with the left child using `probe_keys`
38//! (`BuildSide::Right` default), so the right-side expression becomes a
39//! hash key and the left-side expression a probe key.
40
41use crate::optimizer::heuristic::context::RewriteContext;
42use crate::optimizer::heuristic::pattern::Pattern;
43use crate::optimizer::heuristic::result::{RewriteResult, TransformResult};
44use crate::optimizer::heuristic::rule::RewriteRule;
45use crate::planning::plan::core::nodes::base::plan_node_traits::SingleInputNode;
46use crate::planning::plan::core::nodes::operation::filter_node::FilterNode;
47use crate::planning::plan::PlanNodeEnum;
48use linkrs_core::types::expr::contextual::ContextualExpression;
49use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
50use linkrs_core::types::expr::visitor::ExpressionVisitor;
51use linkrs_core::types::expr::visitor_collectors::VariableCollector;
52use linkrs_core::types::expr::ExpressionMeta;
53use linkrs_core::types::operators::BinaryOperator;
54use linkrs_core::Expression;
55use std::sync::Arc;
56
57/// Extract equi-join keys from a Filter above a keyless InnerJoin.
58#[derive(Debug)]
59pub struct ExtractEquiJoinKeysRule;
60
61impl ExtractEquiJoinKeysRule {
62    /// Create a rule instance.
63    pub fn new() -> Self {
64        Self
65    }
66
67    /// Flatten a top-level `AND` chain into its conjuncts.
68    fn flatten_conjuncts(expr: &Expression, out: &mut Vec<Expression>) {
69        match expr {
70            Expression::Binary {
71                op: BinaryOperator::And,
72                left,
73                right,
74            } => {
75                Self::flatten_conjuncts(left, out);
76                Self::flatten_conjuncts(right, out);
77            }
78            _ => out.push(expr.clone()),
79        }
80    }
81
82    /// Collect the variable names referenced by an expression.
83    fn referenced_variables(expr: &Expression) -> Vec<String> {
84        let mut collector = VariableCollector::new();
85        collector.visit(expr);
86        collector.variables
87    }
88
89    /// Attribute an expression to exactly one join side.
90    ///
91    /// Returns `Some(true)` when every referenced variable belongs to the
92    /// left input (and none to the right), `Some(false)` for the mirrored
93    /// right case, and `None` when the expression is ambiguous (empty,
94    /// single-side-unresolvable, or shared column names).
95    fn is_left_side(vars: &[String], left_cols: &[String], right_cols: &[String]) -> Option<bool> {
96        if vars.is_empty() {
97            return None;
98        }
99        let in_left = vars.iter().all(|v| left_cols.contains(v));
100        let in_right = vars.iter().all(|v| right_cols.contains(v));
101        match (in_left, in_right) {
102            (true, false) => Some(true),
103            (false, true) => Some(false),
104            _ => None,
105        }
106    }
107
108    /// Register a key expression in the given context.
109    fn register_key(
110        expr: Expression,
111        ctx: &Arc<ExpressionAnalysisContext>,
112    ) -> ContextualExpression {
113        let id = ctx.register_expression(ExpressionMeta::new(expr));
114        ContextualExpression::new(id, ctx.clone())
115    }
116
117    /// Combine residual conjuncts back into a single `AND` expression,
118    /// preserving the original left-to-right order.
119    fn combine_residual(conjuncts: Vec<Expression>) -> Option<Expression> {
120        let mut iter = conjuncts.into_iter();
121        let first = iter.next()?;
122        Some(iter.fold(first, |acc, next| Expression::Binary {
123            left: Box::new(acc),
124            op: BinaryOperator::And,
125            right: Box::new(next),
126        }))
127    }
128}
129
130impl Default for ExtractEquiJoinKeysRule {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl RewriteRule for ExtractEquiJoinKeysRule {
137    fn name(&self) -> &'static str {
138        "ExtractEquiJoinKeysRule"
139    }
140
141    fn pattern(&self) -> Pattern {
142        Pattern::new_with_name("Filter").with_dependency_name("InnerJoin")
143    }
144
145    fn apply(
146        &self,
147        _ctx: &mut RewriteContext,
148        node: &PlanNodeEnum,
149    ) -> RewriteResult<Option<TransformResult>> {
150        let filter_node = match node {
151            PlanNodeEnum::Filter(n) => n,
152            _ => return Ok(None),
153        };
154        let join = match filter_node.input() {
155            PlanNodeEnum::InnerJoin(n) => n,
156            _ => return Ok(None),
157        };
158        // Never disturb joins that already carry keys.
159        if !join.hash_keys().is_empty() || !join.probe_keys().is_empty() {
160            return Ok(None);
161        }
162
163        let condition = filter_node.condition();
164        let filter_expr = match condition.expression() {
165            Some(meta) => meta.inner().clone(),
166            None => return Ok(None),
167        };
168        let expr_ctx = condition.context().clone();
169
170        let left_cols = join.left_input().col_names().to_vec();
171        let right_cols = join.right_input().col_names().to_vec();
172
173        let mut conjuncts = Vec::new();
174        Self::flatten_conjuncts(&filter_expr, &mut conjuncts);
175
176        let mut hash_keys = Vec::new();
177        let mut probe_keys = Vec::new();
178        let mut residual = Vec::new();
179
180        for conjunct in conjuncts {
181            // A usable key is an equality whose sides belong to opposite
182            // join inputs. The left-input expression probes, the
183            // right-input expression builds (see the module docs).
184            let sides = match &conjunct {
185                Expression::Binary {
186                    op: BinaryOperator::Equal,
187                    left,
188                    right,
189                } => {
190                    let left_is_left = Self::is_left_side(
191                        &Self::referenced_variables(left),
192                        &left_cols,
193                        &right_cols,
194                    );
195                    let right_is_left = Self::is_left_side(
196                        &Self::referenced_variables(right),
197                        &left_cols,
198                        &right_cols,
199                    );
200                    match (left_is_left, right_is_left) {
201                        (Some(true), Some(false)) => Some((left, right)),
202                        (Some(false), Some(true)) => Some((right, left)),
203                        _ => None,
204                    }
205                }
206                _ => None,
207            };
208            match sides {
209                Some((probe_side, hash_side)) => {
210                    probe_keys.push(Self::register_key((**probe_side).clone(), &expr_ctx));
211                    hash_keys.push(Self::register_key((**hash_side).clone(), &expr_ctx));
212                }
213                None => residual.push(conjunct),
214            }
215        }
216
217        if hash_keys.is_empty() {
218            return Ok(None);
219        }
220
221        let mut new_join = join.clone();
222        new_join.set_hash_keys(hash_keys);
223        new_join.set_probe_keys(probe_keys);
224        let new_join_enum = PlanNodeEnum::InnerJoin(new_join);
225
226        let mut result = TransformResult::new();
227        result.erase_curr = true;
228        if residual.is_empty() {
229            result.add_new_node(new_join_enum);
230        } else {
231            let residual_expr = match Self::combine_residual(residual) {
232                Some(expr) => expr,
233                None => {
234                    result.add_new_node(new_join_enum);
235                    return Ok(Some(result));
236                }
237            };
238            let residual_ctx_expr = Self::register_key(residual_expr, &expr_ctx);
239            let new_filter = FilterNode::new(new_join_enum, residual_ctx_expr).map_err(|e| {
240                crate::optimizer::heuristic::result::RewriteError::rewrite_failed(format!(
241                    "Failed to create residual FilterNode: {:?}",
242                    e
243                ))
244            })?;
245            result.add_new_node(PlanNodeEnum::Filter(new_filter));
246        }
247        Ok(Some(result))
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::planning::plan::core::nodes::access::graph_scan_node::ScanVerticesNode;
255    use crate::planning::plan::core::nodes::join::join_node::InnerJoinNode;
256    use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
257
258    fn scan_with_cols(space: &str, cols: &[&str]) -> PlanNodeEnum {
259        let mut scan = ScanVerticesNode::new(1, space);
260        scan.set_col_names(cols.iter().map(|s| s.to_string()).collect());
261        PlanNodeEnum::ScanVertices(scan)
262    }
263
264    fn contextual(expr: Expression, ctx: &Arc<ExpressionAnalysisContext>) -> ContextualExpression {
265        let id = ctx.register_expression(ExpressionMeta::new(expr));
266        ContextualExpression::new(id, ctx.clone())
267    }
268
269    fn equality_filter(
270        input: PlanNodeEnum,
271        left: Expression,
272        right: Expression,
273        ctx: &Arc<ExpressionAnalysisContext>,
274    ) -> PlanNodeEnum {
275        let cond = Expression::Binary {
276            left: Box::new(left),
277            op: BinaryOperator::Equal,
278            right: Box::new(right),
279        };
280        let filter = FilterNode::new(input, contextual(cond, ctx)).expect("filter");
281        PlanNodeEnum::Filter(filter)
282    }
283
284    fn prop(var: &str, property: &str) -> Expression {
285        Expression::Property {
286            object: Box::new(Expression::variable(var)),
287            property: property.to_string(),
288        }
289    }
290
291    #[test]
292    fn test_rule_name() {
293        let rule = ExtractEquiJoinKeysRule::new();
294        assert_eq!(rule.name(), "ExtractEquiJoinKeysRule");
295    }
296
297    #[test]
298    fn test_rule_pattern() {
299        let rule = ExtractEquiJoinKeysRule::new();
300        let pattern = rule.pattern();
301        assert!(pattern.node.is_some());
302    }
303
304    #[test]
305    fn test_skips_join_with_existing_keys() {
306        let ctx = Arc::new(ExpressionAnalysisContext::new());
307        let left = scan_with_cols("s", &["a"]);
308        let right = scan_with_cols("s", &["b"]);
309        let key = contextual(Expression::variable("a"), &ctx);
310        let join =
311            InnerJoinNode::new(left, right, vec![key.clone()], vec![key]).expect("join with keys");
312        let node = equality_filter(
313            PlanNodeEnum::InnerJoin(join),
314            prop("a", "value"),
315            prop("b", "value"),
316            &ctx,
317        );
318        let rule = ExtractEquiJoinKeysRule::new();
319        let mut rewrite_ctx = RewriteContext::new();
320        let out = rule.apply(&mut rewrite_ctx, &node).expect("apply");
321        assert!(out.is_none(), "joins with keys must be left alone");
322    }
323
324    #[test]
325    fn test_extracts_cross_side_equality() {
326        let ctx = Arc::new(ExpressionAnalysisContext::new());
327        let left = scan_with_cols("s", &["a"]);
328        let right = scan_with_cols("s", &["b"]);
329        let join = InnerJoinNode::new(left, right, vec![], vec![]).expect("keyless join");
330        let node = equality_filter(
331            PlanNodeEnum::InnerJoin(join),
332            prop("a", "value"),
333            prop("b", "value"),
334            &ctx,
335        );
336        let rule = ExtractEquiJoinKeysRule::new();
337        let mut rewrite_ctx = RewriteContext::new();
338        let out = rule
339            .apply(&mut rewrite_ctx, &node)
340            .expect("apply")
341            .expect("must fire");
342        assert!(out.erase_curr);
343        assert_eq!(out.new_nodes.len(), 1);
344        match &out.new_nodes[0] {
345            PlanNodeEnum::InnerJoin(j) => {
346                assert_eq!(j.hash_keys().len(), 1);
347                assert_eq!(j.probe_keys().len(), 1);
348                // Build side (right) key references b, probe side (left) key
349                // references a.
350                let hash_vars = ExtractEquiJoinKeysRule::referenced_variables(
351                    j.hash_keys()[0].expression().expect("hash").inner(),
352                );
353                let probe_vars = ExtractEquiJoinKeysRule::referenced_variables(
354                    j.probe_keys()[0].expression().expect("probe").inner(),
355                );
356                assert_eq!(hash_vars, vec!["b".to_string()]);
357                assert_eq!(probe_vars, vec!["a".to_string()]);
358            }
359            other => panic!("expected keyed InnerJoin, got {:?}", other.type_name()),
360        }
361    }
362
363    #[test]
364    fn test_extracts_mirrored_equality() {
365        let ctx = Arc::new(ExpressionAnalysisContext::new());
366        let left = scan_with_cols("s", &["a"]);
367        let right = scan_with_cols("s", &["b"]);
368        let join = InnerJoinNode::new(left, right, vec![], vec![]).expect("keyless join");
369        // Equality written right-first: b.value = a.value.
370        let node = equality_filter(
371            PlanNodeEnum::InnerJoin(join),
372            prop("b", "value"),
373            prop("a", "value"),
374            &ctx,
375        );
376        let rule = ExtractEquiJoinKeysRule::new();
377        let mut rewrite_ctx = RewriteContext::new();
378        let out = rule
379            .apply(&mut rewrite_ctx, &node)
380            .expect("apply")
381            .expect("must fire");
382        match &out.new_nodes[0] {
383            PlanNodeEnum::InnerJoin(j) => {
384                let hash_vars = ExtractEquiJoinKeysRule::referenced_variables(
385                    j.hash_keys()[0].expression().expect("hash").inner(),
386                );
387                let probe_vars = ExtractEquiJoinKeysRule::referenced_variables(
388                    j.probe_keys()[0].expression().expect("probe").inner(),
389                );
390                assert_eq!(hash_vars, vec!["b".to_string()]);
391                assert_eq!(probe_vars, vec!["a".to_string()]);
392            }
393            other => panic!("expected keyed InnerJoin, got {:?}", other.type_name()),
394        }
395    }
396
397    #[test]
398    fn test_keeps_residual_filter() {
399        let ctx = Arc::new(ExpressionAnalysisContext::new());
400        let left = scan_with_cols("s", &["a"]);
401        let right = scan_with_cols("s", &["b"]);
402        let join = InnerJoinNode::new(left, right, vec![], vec![]).expect("keyless join");
403        let equality = Expression::Binary {
404            left: Box::new(prop("a", "value")),
405            op: BinaryOperator::Equal,
406            right: Box::new(prop("b", "value")),
407        };
408        let range = Expression::Binary {
409            left: Box::new(prop("a", "value")),
410            op: BinaryOperator::LessThan,
411            right: Box::new(Expression::int(200)),
412        };
413        let cond = Expression::Binary {
414            left: Box::new(equality),
415            op: BinaryOperator::And,
416            right: Box::new(range),
417        };
418        let filter =
419            FilterNode::new(PlanNodeEnum::InnerJoin(join), contextual(cond, &ctx)).expect("filter");
420        let node = PlanNodeEnum::Filter(filter);
421        let rule = ExtractEquiJoinKeysRule::new();
422        let mut rewrite_ctx = RewriteContext::new();
423        let out = rule
424            .apply(&mut rewrite_ctx, &node)
425            .expect("apply")
426            .expect("must fire");
427        assert_eq!(out.new_nodes.len(), 1);
428        match &out.new_nodes[0] {
429            PlanNodeEnum::Filter(f) => match f.input() {
430                PlanNodeEnum::InnerJoin(j) => {
431                    assert_eq!(j.hash_keys().len(), 1);
432                    assert_eq!(j.probe_keys().len(), 1);
433                }
434                other => panic!(
435                    "residual filter must sit above keyed join, got {:?}",
436                    other.type_name()
437                ),
438            },
439            other => panic!("expected residual Filter, got {:?}", other.type_name()),
440        }
441    }
442
443    #[test]
444    fn test_ignores_single_side_predicate() {
445        let ctx = Arc::new(ExpressionAnalysisContext::new());
446        let left = scan_with_cols("s", &["a"]);
447        let right = scan_with_cols("s", &["b"]);
448        let join = InnerJoinNode::new(left, right, vec![], vec![]).expect("keyless join");
449        let range = Expression::Binary {
450            left: Box::new(prop("a", "value")),
451            op: BinaryOperator::LessThan,
452            right: Box::new(Expression::int(200)),
453        };
454        let filter = FilterNode::new(PlanNodeEnum::InnerJoin(join), contextual(range, &ctx))
455            .expect("filter");
456        let node = PlanNodeEnum::Filter(filter);
457        let rule = ExtractEquiJoinKeysRule::new();
458        let mut rewrite_ctx = RewriteContext::new();
459        let out = rule.apply(&mut rewrite_ctx, &node).expect("apply");
460        assert!(out.is_none(), "single-side predicates are not join keys");
461    }
462}