Skip to main content

datafusion_optimizer/
eliminate_outer_join.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! [`EliminateOuterJoin`] rewrites outer joins to simpler join types when
19//! filters make the outer rows unnecessary (e.g. `LEFT`/`RIGHT` to `INNER`,
20//! and `FULL` to `LEFT`/`RIGHT`/`INNER`).
21use crate::push_down_filter::replace_cols_by_name;
22use crate::{OptimizerConfig, OptimizerRule};
23use datafusion_common::{Column, DFSchema, Result, qualified_name};
24use datafusion_expr::logical_plan::{Join, JoinType, LogicalPlan, Projection};
25use datafusion_expr::{Expr, Filter, Operator};
26
27use crate::optimizer::ApplyOrder;
28use datafusion_common::tree_node::Transformed;
29use datafusion_expr::expr::{BinaryExpr, Cast, InList, Like, TryCast};
30use std::collections::HashMap;
31use std::sync::Arc;
32
33/// Attempt to simplify outer joins when filters make their null-padded
34/// rows impossible to observe.
35///
36/// Outer joins are generally more expensive than inner joins and can block
37/// predicate pushdown and other optimizations. When a filter above an outer
38/// join removes every row the join would add for unmatched input rows, the
39/// join can be changed to a cheaper join type.
40///
41/// For example:
42///
43/// ```sql
44/// SELECT ...
45/// FROM a LEFT JOIN b ON ...
46/// WHERE b.xx = 100
47/// ```
48///
49/// For unmatched rows from `a`, the LEFT JOIN would produce a row with
50/// `b.xx` set to NULL. The predicate `b.xx = 100` does not pass for those
51/// rows, so the query does not need the LEFT JOIN's null-padded output and
52/// the join can be rewritten as an inner join.
53///
54/// The same reasoning can also simplify FULL joins to LEFT, RIGHT, or INNER
55/// joins when filters remove the rows padded on one or both sides.
56///
57/// This rule looks for a filter above an outer join:
58///
59/// ```text
60/// Filter(predicate)
61///   Join(LEFT/RIGHT/FULL)
62/// ```
63///
64/// It also handles plan shapes where projection pruning has inserted one or
65/// more Projection nodes between the filter and join:
66///
67/// ```text
68/// Filter(predicate over projection output)
69///   Projection(...)
70///     ...
71///       Join(LEFT/RIGHT/FULL)
72/// ```
73///
74/// In the projection case, the rule rewrites a copy of the predicate through
75/// each Projection so it can analyze the predicate against the Join inputs.
76/// The original filter predicate and Projection nodes are preserved when the
77/// plan is rebuilt.
78#[derive(Default, Debug)]
79pub struct EliminateOuterJoin;
80
81impl EliminateOuterJoin {
82    #[expect(missing_docs)]
83    pub fn new() -> Self {
84        Self {}
85    }
86}
87
88/// Attempt to eliminate outer joins.
89impl OptimizerRule for EliminateOuterJoin {
90    fn name(&self) -> &str {
91        "eliminate_outer_join"
92    }
93
94    fn apply_order(&self) -> Option<ApplyOrder> {
95        Some(ApplyOrder::TopDown)
96    }
97
98    fn supports_rewrite(&self) -> bool {
99        true
100    }
101
102    fn rewrite(
103        &self,
104        plan: LogicalPlan,
105        _config: &dyn OptimizerConfig,
106    ) -> Result<Transformed<LogicalPlan>> {
107        let LogicalPlan::Filter(filter) = plan else {
108            return Ok(Transformed::no(plan));
109        };
110
111        // Descend through one or more Projection nodes until we find a Join.
112        // For each Projection we encounter, rewrite a working copy of the
113        // predicate by replacing references to projection output columns with
114        // the expressions that define them. Keep the filter's original
115        // predicate intact for eventual use in the rebuilt plan; the rewritten
116        // predicate is used only for the null-rejection analysis.
117        let mut rewritten_predicate = filter.predicate.clone();
118        let mut projections: Vec<Projection> = Vec::new();
119        let mut cur = Arc::clone(&filter.input);
120
121        let new_join = loop {
122            match cur.as_ref() {
123                LogicalPlan::Projection(p) => {
124                    rewritten_predicate =
125                        inline_through_projection(rewritten_predicate, p)?;
126                    let next = Arc::clone(&p.input);
127                    projections.push(p.clone());
128                    cur = next;
129                }
130                LogicalPlan::Join(join) => {
131                    let Some(new_join) = try_simplify_join(join, &rewritten_predicate)
132                    else {
133                        return Ok(Transformed::no(LogicalPlan::Filter(filter)));
134                    };
135                    break new_join;
136                }
137                _ => {
138                    return Ok(Transformed::no(LogicalPlan::Filter(filter)));
139                }
140            }
141        };
142
143        let rebuilt_inner = rewrap_projections(new_join, projections);
144        Filter::try_new(filter.predicate, Arc::new(rebuilt_inner))
145            .map(|f| Transformed::yes(LogicalPlan::Filter(f)))
146    }
147}
148
149/// Attempt to simplify an outer join by analyzing `predicate` for
150/// null-rejection.  If the predicate filters out rows padded with NULLs on one
151/// or both sides, return a copy of `join` rewritten to an equivalent join type
152/// that omits those rows in the first place; otherwise return `None`.
153fn try_simplify_join(join: &Join, predicate: &Expr) -> Option<LogicalPlan> {
154    if !join.join_type.is_outer() {
155        return None;
156    }
157
158    let null_rejecting_sides = extract_null_rejecting_sides(
159        predicate,
160        join.left.schema(),
161        join.right.schema(),
162        true,
163    );
164
165    let new_join_type = eliminate_outer(
166        join.join_type,
167        null_rejecting_sides.left,
168        null_rejecting_sides.right,
169    );
170    if new_join_type == join.join_type {
171        return None;
172    }
173
174    Some(LogicalPlan::Join(Join {
175        left: Arc::clone(&join.left),
176        right: Arc::clone(&join.right),
177        join_type: new_join_type,
178        join_constraint: join.join_constraint,
179        on: join.on.clone(),
180        filter: join.filter.clone(),
181        schema: Arc::clone(&join.schema),
182        null_equality: join.null_equality,
183        null_aware: join.null_aware,
184    }))
185}
186
187/// Substitute the projection's output column references in `predicate` with
188/// the projection's defining expressions (stripped of any `Alias` wrapper).
189/// The result expresses `predicate` over the projection's *input* schema.
190///
191/// Unlike `PushDownFilter`, this rule does not change expression evaluation
192/// behavior (in fact, the rewritten expressions are only used for analysis
193/// purposes). Therefore, function volatility and `MoveTowardsLeafNodes`
194/// placement can be ignored here.
195fn inline_through_projection(predicate: Expr, p: &Projection) -> Result<Expr> {
196    let mut map: HashMap<String, Expr> = HashMap::new();
197    for ((qualifier, field), expr) in p.schema.iter().zip(p.expr.iter()) {
198        map.insert(
199            qualified_name(qualifier, field.name()),
200            unalias(expr).clone(),
201        );
202    }
203    replace_cols_by_name(predicate, &map)
204}
205
206/// Re-attach a stack of projections above `new_inner`, restoring the original
207/// plan shape with the new (possibly retyped) join at the bottom. Projection
208/// schemas are reused as-is; only nullability of columns sourced from the
209/// formerly-outer side may have changed, and the existing rule already takes
210/// this looser-schema approach at the join itself.
211fn rewrap_projections(
212    new_inner: LogicalPlan,
213    projections: Vec<Projection>,
214) -> LogicalPlan {
215    let mut current = new_inner;
216    for mut p in projections.into_iter().rev() {
217        p.input = Arc::new(current);
218        current = LogicalPlan::Projection(p);
219    }
220    current
221}
222
223fn unalias(expr: &Expr) -> &Expr {
224    if let Expr::Alias(a) = expr {
225        unalias(&a.expr)
226    } else {
227        expr
228    }
229}
230
231pub fn eliminate_outer(
232    join_type: JoinType,
233    left_non_nullable: bool,
234    right_non_nullable: bool,
235) -> JoinType {
236    match (join_type, left_non_nullable, right_non_nullable) {
237        (JoinType::Left, _, true) => JoinType::Inner,
238        (JoinType::Right, true, _) => JoinType::Inner,
239        (JoinType::Full, true, true) => JoinType::Inner,
240        (JoinType::Full, true, false) => JoinType::Left,
241        (JoinType::Full, false, true) => JoinType::Right,
242        _ => join_type,
243    }
244}
245
246#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
247struct NullRejectingSides {
248    left: bool,
249    right: bool,
250}
251
252impl NullRejectingSides {
253    /// The join side(s) a column belongs to.
254    ///
255    /// A bare column reference is null-rejecting on its own side: if the column
256    /// is NULL, every null-propagating operator above it yields NULL and the row
257    /// is filtered.
258    fn for_column(col: &Column, left_schema: &DFSchema, right_schema: &DFSchema) -> Self {
259        Self {
260            left: left_schema.has_column(col),
261            right: right_schema.has_column(col),
262        }
263    }
264
265    fn union(self, other: Self) -> Self {
266        Self {
267            left: self.left || other.left,
268            right: self.right || other.right,
269        }
270    }
271
272    fn intersection(self, other: Self) -> Self {
273        Self {
274            left: self.left && other.left,
275            right: self.right && other.right,
276        }
277    }
278}
279
280/// Compute which join sides are null-rejected by `expr` in a WHERE clause.
281/// For each marked side, rows padded with NULLs on that side are guaranteed to
282/// evaluate to NULL or false and be filtered out.
283///
284/// `left_schema` and `right_schema` map column references to join sides.
285/// `top_level` is true only while walking the root WHERE context; nested
286/// contexts are more conservative because their boolean result may be combined
287/// by an enclosing expression.
288fn extract_null_rejecting_sides(
289    expr: &Expr,
290    left_schema: &Arc<DFSchema>,
291    right_schema: &Arc<DFSchema>,
292    top_level: bool,
293) -> NullRejectingSides {
294    match expr {
295        Expr::Column(col) => {
296            NullRejectingSides::for_column(col, left_schema, right_schema)
297        }
298        Expr::BinaryExpr(BinaryExpr { left, op, right }) => match op {
299            Operator::And | Operator::Or => {
300                let left_sides = extract_null_rejecting_sides(
301                    left,
302                    left_schema,
303                    right_schema,
304                    top_level,
305                );
306                let right_sides = extract_null_rejecting_sides(
307                    right,
308                    left_schema,
309                    right_schema,
310                    top_level,
311                );
312
313                // Top-level AND: each conjunct is an independent WHERE filter,
314                // so side evidence from either branch is sufficient.
315                // Nested AND is handled like OR because the enclosing context
316                // may still let a NULL-padded row pass.
317                if top_level && *op == Operator::And {
318                    left_sides.union(right_sides)
319                } else {
320                    // OR (and nested AND): a NULL-padded row is rejected only
321                    // if both branches reject NULLs for the same side.
322                    left_sides.intersection(right_sides)
323                }
324            }
325            // Other NULL-on-NULL operators preserve null rejection from either
326            // operand.
327            op if op.returns_null_on_null() => {
328                let left_sides =
329                    extract_null_rejecting_sides(left, left_schema, right_schema, false);
330                let right_sides =
331                    extract_null_rejecting_sides(right, left_schema, right_schema, false);
332                left_sides.union(right_sides)
333            }
334            // Other operators, notably IS [ NOT ] DISTINCT FROM, are not
335            // NULL-propagating and provide no side-level rejection evidence.
336            _ => NullRejectingSides::default(),
337        },
338        Expr::Not(arg) | Expr::Negative(arg) => {
339            extract_null_rejecting_sides(arg, left_schema, right_schema, false)
340        }
341        // These wrappers return FALSE on NULL input, so they reject NULLs only
342        // when they are themselves in the root WHERE context. Under another
343        // expression, that FALSE can be transformed into a NULL-accepting result
344        // (for example by NOT), so recurse only at the top level.
345        Expr::IsNotNull(arg)
346        | Expr::IsTrue(arg)
347        | Expr::IsFalse(arg)
348        | Expr::IsNotUnknown(arg) => {
349            if top_level {
350                extract_null_rejecting_sides(arg, left_schema, right_schema, false)
351            } else {
352                NullRejectingSides::default()
353            }
354        }
355        Expr::Cast(Cast { expr, field: _ })
356        | Expr::TryCast(TryCast { expr, field: _ }) => {
357            extract_null_rejecting_sides(expr, left_schema, right_schema, false)
358        }
359        // IN list and BETWEEN reject NULLs from their input expression; list
360        // values and range bounds do not affect which join side is padded.
361        Expr::InList(InList { expr, .. }) => {
362            extract_null_rejecting_sides(expr, left_schema, right_schema, false)
363        }
364        Expr::Between(between) => {
365            extract_null_rejecting_sides(&between.expr, left_schema, right_schema, false)
366        }
367        Expr::Like(Like { expr, pattern, .. }) => {
368            let expr_sides =
369                extract_null_rejecting_sides(expr, left_schema, right_schema, false);
370            let pattern_sides =
371                extract_null_rejecting_sides(pattern, left_schema, right_schema, false);
372            expr_sides.union(pattern_sides)
373        }
374        // Strict scalar functions are NULL-propagating: if any argument from a
375        // padded join side is NULL, the function result is NULL, and an
376        // enclosing NULL-rejecting predicate filters the row out.
377        Expr::ScalarFunction(func) if func.func.is_strict() => func
378            .args
379            .iter()
380            .map(|arg| {
381                extract_null_rejecting_sides(arg, left_schema, right_schema, false)
382            })
383            .fold(NullRejectingSides::default(), NullRejectingSides::union),
384        // Everything else is conservative: NULL-accepting predicates such as
385        // IS NULL / IS NOT TRUE / IS NOT FALSE / IS UNKNOWN must not eliminate
386        // an outer join, and non-strict functions/subqueries/accessors/literals
387        // have no uniform NULL-propagation contract here.
388        _ => NullRejectingSides::default(),
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::OptimizerContext;
396    use crate::assert_optimized_plan_eq_snapshot;
397    use crate::test::*;
398    use arrow::datatypes::DataType;
399    use datafusion_common::ScalarValue;
400    use datafusion_expr::{
401        ColumnarValue,
402        Operator::{And, Or},
403        ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, Volatility, binary_expr,
404        cast, col, lit,
405        logical_plan::builder::LogicalPlanBuilder,
406        not, try_cast,
407    };
408
409    #[test]
410    fn null_rejecting_sides_union() {
411        let left_side = NullRejectingSides {
412            left: true,
413            right: false,
414        };
415        let right_side = NullRejectingSides {
416            left: false,
417            right: true,
418        };
419
420        assert_eq!(
421            left_side.union(right_side),
422            NullRejectingSides {
423                left: true,
424                right: true,
425            }
426        );
427    }
428
429    #[test]
430    fn null_rejecting_sides_intersection() {
431        let both_sides = NullRejectingSides {
432            left: true,
433            right: true,
434        };
435        let right_side = NullRejectingSides {
436            left: false,
437            right: true,
438        };
439
440        assert_eq!(
441            both_sides.intersection(right_side),
442            NullRejectingSides {
443                left: false,
444                right: true,
445            }
446        );
447    }
448
449    macro_rules! assert_optimized_plan_equal {
450        (
451            $plan:expr,
452            @ $expected:literal $(,)?
453        ) => {{
454            let optimizer_ctx = OptimizerContext::new().with_max_passes(1);
455            let rules: Vec<Arc<dyn crate::OptimizerRule + Send + Sync>> = vec![Arc::new(EliminateOuterJoin::new())];
456            assert_optimized_plan_eq_snapshot!(
457                optimizer_ctx,
458                rules,
459                $plan,
460                @ $expected,
461            )
462        }};
463    }
464
465    #[derive(Debug, PartialEq, Eq, Hash)]
466    struct TestUdf {
467        name: &'static str,
468        signature: Signature,
469        strict: bool,
470    }
471
472    impl TestUdf {
473        fn new(name: &'static str, strict: bool) -> Self {
474            Self {
475                name,
476                signature: Signature::uniform(
477                    1,
478                    vec![DataType::UInt32],
479                    Volatility::Immutable,
480                ),
481                strict,
482            }
483        }
484    }
485
486    impl ScalarUDFImpl for TestUdf {
487        fn name(&self) -> &str {
488            self.name
489        }
490
491        fn signature(&self) -> &Signature {
492            &self.signature
493        }
494
495        fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
496            Ok(DataType::UInt32)
497        }
498
499        fn is_strict(&self) -> bool {
500            self.strict
501        }
502
503        fn invoke_with_args(&self, _args: ScalarFunctionArgs) -> Result<ColumnarValue> {
504            unimplemented!()
505        }
506    }
507
508    fn strict_udf(arg: Expr) -> Expr {
509        ScalarUDF::from(TestUdf::new("strict_test", true)).call(vec![arg])
510    }
511
512    fn non_strict_udf(arg: Expr) -> Expr {
513        ScalarUDF::from(TestUdf::new("non_strict_test", false)).call(vec![arg])
514    }
515
516    #[test]
517    fn eliminate_left_with_null() -> Result<()> {
518        let t1 = test_table_scan_with_name("t1")?;
519        let t2 = test_table_scan_with_name("t2")?;
520
521        // could not eliminate to inner join
522        let plan = LogicalPlanBuilder::from(t1)
523            .join(
524                t2,
525                JoinType::Left,
526                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
527                None,
528            )?
529            .filter(col("t2.b").is_null())?
530            .build()?;
531
532        assert_optimized_plan_equal!(plan, @r"
533        Filter: t2.b IS NULL
534          Left Join: t1.a = t2.a
535            TableScan: t1
536            TableScan: t2
537        ")
538    }
539
540    #[test]
541    fn eliminate_left_with_not_null() -> Result<()> {
542        let t1 = test_table_scan_with_name("t1")?;
543        let t2 = test_table_scan_with_name("t2")?;
544
545        // eliminate to inner join
546        let plan = LogicalPlanBuilder::from(t1)
547            .join(
548                t2,
549                JoinType::Left,
550                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
551                None,
552            )?
553            .filter(col("t2.b").is_not_null())?
554            .build()?;
555
556        assert_optimized_plan_equal!(plan, @r"
557        Filter: t2.b IS NOT NULL
558          Inner Join: t1.a = t2.a
559            TableScan: t1
560            TableScan: t2
561        ")
562    }
563
564    #[test]
565    fn eliminate_left_with_strict_function() -> Result<()> {
566        let t1 = test_table_scan_with_name("t1")?;
567        let t2 = test_table_scan_with_name("t2")?;
568
569        let plan = LogicalPlanBuilder::from(t1)
570            .join(
571                t2,
572                JoinType::Left,
573                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
574                None,
575            )?
576            .filter(strict_udf(col("t2.b")).gt(lit(5u32)))?
577            .build()?;
578
579        assert_optimized_plan_equal!(plan, @r"
580        Filter: strict_test(t2.b) > UInt32(5)
581          Inner Join: t1.a = t2.a
582            TableScan: t1
583            TableScan: t2
584        ")
585    }
586
587    #[test]
588    fn no_eliminate_left_with_non_strict_function() -> Result<()> {
589        let t1 = test_table_scan_with_name("t1")?;
590        let t2 = test_table_scan_with_name("t2")?;
591
592        let plan = LogicalPlanBuilder::from(t1)
593            .join(
594                t2,
595                JoinType::Left,
596                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
597                None,
598            )?
599            .filter(non_strict_udf(col("t2.b")).gt(lit(5u32)))?
600            .build()?;
601
602        assert_optimized_plan_equal!(plan, @r"
603        Filter: non_strict_test(t2.b) > UInt32(5)
604          Left Join: t1.a = t2.a
605            TableScan: t1
606            TableScan: t2
607        ")
608    }
609
610    #[test]
611    fn eliminate_left_with_nested_strict_is_not_null() -> Result<()> {
612        let t1 = test_table_scan_with_name("t1")?;
613        let t2 = test_table_scan_with_name("t2")?;
614
615        let plan = LogicalPlanBuilder::from(t1)
616            .join(
617                t2,
618                JoinType::Left,
619                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
620                None,
621            )?
622            .filter(strict_udf(strict_udf(col("t2.b"))).is_not_null())?
623            .build()?;
624
625        assert_optimized_plan_equal!(plan, @r"
626        Filter: strict_test(strict_test(t2.b)) IS NOT NULL
627          Inner Join: t1.a = t2.a
628            TableScan: t1
629            TableScan: t2
630        ")
631    }
632
633    #[test]
634    fn no_eliminate_left_with_strict_function_is_null() -> Result<()> {
635        let t1 = test_table_scan_with_name("t1")?;
636        let t2 = test_table_scan_with_name("t2")?;
637
638        let plan = LogicalPlanBuilder::from(t1)
639            .join(
640                t2,
641                JoinType::Left,
642                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
643                None,
644            )?
645            .filter(strict_udf(col("t2.b")).is_null())?
646            .build()?;
647
648        assert_optimized_plan_equal!(plan, @r"
649        Filter: strict_test(t2.b) IS NULL
650          Left Join: t1.a = t2.a
651            TableScan: t1
652            TableScan: t2
653        ")
654    }
655
656    #[test]
657    fn eliminate_right_with_or() -> Result<()> {
658        let t1 = test_table_scan_with_name("t1")?;
659        let t2 = test_table_scan_with_name("t2")?;
660
661        // eliminate to inner join
662        let plan = LogicalPlanBuilder::from(t1)
663            .join(
664                t2,
665                JoinType::Right,
666                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
667                None,
668            )?
669            .filter(binary_expr(
670                col("t1.b").gt(lit(10u32)),
671                Or,
672                col("t1.c").lt(lit(20u32)),
673            ))?
674            .build()?;
675
676        assert_optimized_plan_equal!(plan, @r"
677        Filter: t1.b > UInt32(10) OR t1.c < UInt32(20)
678          Inner Join: t1.a = t2.a
679            TableScan: t1
680            TableScan: t2
681        ")
682    }
683
684    #[test]
685    fn eliminate_full_with_and() -> Result<()> {
686        let t1 = test_table_scan_with_name("t1")?;
687        let t2 = test_table_scan_with_name("t2")?;
688
689        // eliminate to inner join
690        let plan = LogicalPlanBuilder::from(t1)
691            .join(
692                t2,
693                JoinType::Full,
694                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
695                None,
696            )?
697            .filter(binary_expr(
698                col("t1.b").gt(lit(10u32)),
699                And,
700                col("t2.c").lt(lit(20u32)),
701            ))?
702            .build()?;
703
704        assert_optimized_plan_equal!(plan, @r"
705        Filter: t1.b > UInt32(10) AND t2.c < UInt32(20)
706          Inner Join: t1.a = t2.a
707            TableScan: t1
708            TableScan: t2
709        ")
710    }
711
712    #[test]
713    fn eliminate_left_with_in_list() -> Result<()> {
714        let t1 = test_table_scan_with_name("t1")?;
715        let t2 = test_table_scan_with_name("t2")?;
716
717        // t2.b IN (1, 2, 3) rejects nulls — if t2.b is NULL the IN returns
718        // NULL which is filtered out. So Left Join should become Inner Join.
719        let plan = LogicalPlanBuilder::from(t1)
720            .join(
721                t2,
722                JoinType::Left,
723                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
724                None,
725            )?
726            .filter(col("t2.b").in_list(vec![lit(1u32), lit(2u32), lit(3u32)], false))?
727            .build()?;
728
729        assert_optimized_plan_equal!(plan, @r"
730        Filter: t2.b IN ([UInt32(1), UInt32(2), UInt32(3)])
731          Inner Join: t1.a = t2.a
732            TableScan: t1
733            TableScan: t2
734        ")
735    }
736
737    #[test]
738    fn eliminate_left_with_in_list_containing_null() -> Result<()> {
739        let t1 = test_table_scan_with_name("t1")?;
740        let t2 = test_table_scan_with_name("t2")?;
741
742        // IN list with NULL still rejects null input columns:
743        // if t2.b is NULL, NULL IN (1, NULL) evaluates to NULL, which is filtered out
744        let plan = LogicalPlanBuilder::from(t1)
745            .join(
746                t2,
747                JoinType::Left,
748                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
749                None,
750            )?
751            .filter(
752                col("t2.b")
753                    .in_list(vec![lit(1u32), lit(ScalarValue::UInt32(None))], false),
754            )?
755            .build()?;
756
757        assert_optimized_plan_equal!(plan, @r"
758        Filter: t2.b IN ([UInt32(1), UInt32(NULL)])
759          Inner Join: t1.a = t2.a
760            TableScan: t1
761            TableScan: t2
762        ")
763    }
764
765    #[test]
766    fn eliminate_left_with_not_in_list() -> Result<()> {
767        let t1 = test_table_scan_with_name("t1")?;
768        let t2 = test_table_scan_with_name("t2")?;
769
770        // NOT IN also rejects nulls: if t2.b is NULL, NOT (NULL IN (...))
771        // evaluates to NULL, which is filtered out
772        let plan = LogicalPlanBuilder::from(t1)
773            .join(
774                t2,
775                JoinType::Left,
776                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
777                None,
778            )?
779            .filter(col("t2.b").in_list(vec![lit(1u32), lit(2u32)], true))?
780            .build()?;
781
782        assert_optimized_plan_equal!(plan, @r"
783        Filter: t2.b NOT IN ([UInt32(1), UInt32(2)])
784          Inner Join: t1.a = t2.a
785            TableScan: t1
786            TableScan: t2
787        ")
788    }
789
790    #[test]
791    fn eliminate_left_with_between() -> Result<()> {
792        let t1 = test_table_scan_with_name("t1")?;
793        let t2 = test_table_scan_with_name("t2")?;
794
795        // BETWEEN rejects nulls: if t2.b is NULL, NULL BETWEEN 1 AND 10
796        // evaluates to NULL, which is filtered out
797        let plan = LogicalPlanBuilder::from(t1)
798            .join(
799                t2,
800                JoinType::Left,
801                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
802                None,
803            )?
804            .filter(col("t2.b").between(lit(1u32), lit(10u32)))?
805            .build()?;
806
807        assert_optimized_plan_equal!(plan, @r"
808        Filter: t2.b BETWEEN UInt32(1) AND UInt32(10)
809          Inner Join: t1.a = t2.a
810            TableScan: t1
811            TableScan: t2
812        ")
813    }
814
815    #[test]
816    fn eliminate_right_with_between() -> Result<()> {
817        let t1 = test_table_scan_with_name("t1")?;
818        let t2 = test_table_scan_with_name("t2")?;
819
820        // Right join: filter on left (nullable) side with BETWEEN should convert to Inner
821        let plan = LogicalPlanBuilder::from(t1)
822            .join(
823                t2,
824                JoinType::Right,
825                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
826                None,
827            )?
828            .filter(col("t1.b").between(lit(1u32), lit(10u32)))?
829            .build()?;
830
831        assert_optimized_plan_equal!(plan, @r"
832        Filter: t1.b BETWEEN UInt32(1) AND UInt32(10)
833          Inner Join: t1.a = t2.a
834            TableScan: t1
835            TableScan: t2
836        ")
837    }
838
839    #[test]
840    fn eliminate_full_with_between() -> Result<()> {
841        let t1 = test_table_scan_with_name("t1")?;
842        let t2 = test_table_scan_with_name("t2")?;
843
844        // Full join with BETWEEN on both sides should become Inner
845        let plan = LogicalPlanBuilder::from(t1)
846            .join(
847                t2,
848                JoinType::Full,
849                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
850                None,
851            )?
852            .filter(binary_expr(
853                col("t1.b").between(lit(1u32), lit(10u32)),
854                And,
855                col("t2.b").between(lit(5u32), lit(20u32)),
856            ))?
857            .build()?;
858
859        assert_optimized_plan_equal!(plan, @r"
860        Filter: t1.b BETWEEN UInt32(1) AND UInt32(10) AND t2.b BETWEEN UInt32(5) AND UInt32(20)
861          Inner Join: t1.a = t2.a
862            TableScan: t1
863            TableScan: t2
864        ")
865    }
866
867    #[test]
868    fn eliminate_full_with_in_list() -> Result<()> {
869        let t1 = test_table_scan_with_name("t1")?;
870        let t2 = test_table_scan_with_name("t2")?;
871
872        // Full join with IN filters on both sides should become Inner
873        let plan = LogicalPlanBuilder::from(t1)
874            .join(
875                t2,
876                JoinType::Full,
877                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
878                None,
879            )?
880            .filter(binary_expr(
881                col("t1.b").in_list(vec![lit(1u32), lit(2u32)], false),
882                And,
883                col("t2.b").in_list(vec![lit(3u32), lit(4u32)], false),
884            ))?
885            .build()?;
886
887        assert_optimized_plan_equal!(plan, @r"
888        Filter: t1.b IN ([UInt32(1), UInt32(2)]) AND t2.b IN ([UInt32(3), UInt32(4)])
889          Inner Join: t1.a = t2.a
890            TableScan: t1
891            TableScan: t2
892        ")
893    }
894
895    #[test]
896    fn no_eliminate_left_with_in_list_or_is_null() -> Result<()> {
897        let t1 = test_table_scan_with_name("t1")?;
898        let t2 = test_table_scan_with_name("t2")?;
899
900        // WHERE (t2.b IN (1, 2)) OR (t2.b IS NULL)
901        // The OR with IS NULL makes the predicate null-tolerant:
902        // when t2.b is NULL, IS NULL returns true, so the whole OR is true.
903        // The outer join must be preserved.
904        let plan = LogicalPlanBuilder::from(t1)
905            .join(
906                t2,
907                JoinType::Left,
908                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
909                None,
910            )?
911            .filter(binary_expr(
912                col("t2.b").in_list(vec![lit(1u32), lit(2u32)], false),
913                Or,
914                col("t2.b").is_null(),
915            ))?
916            .build()?;
917
918        // Should NOT be converted to Inner — OR with IS NULL preserves null rows
919        assert_optimized_plan_equal!(plan, @r"
920        Filter: t2.b IN ([UInt32(1), UInt32(2)]) OR t2.b IS NULL
921          Left Join: t1.a = t2.a
922            TableScan: t1
923            TableScan: t2
924        ")
925    }
926
927    #[test]
928    fn eliminate_left_with_like() -> Result<()> {
929        let t1 = test_table_scan_with_name("t1")?;
930        let t2 = test_table_scan_with_name("t2")?;
931
932        // LIKE rejects nulls: if t2.b is NULL, the result is NULL (filtered out)
933        let plan = LogicalPlanBuilder::from(t1)
934            .join(
935                t2,
936                JoinType::Left,
937                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
938                None,
939            )?
940            .filter(col("t2.b").like(lit("%pattern%")))?
941            .build()?;
942
943        assert_optimized_plan_equal!(plan, @r#"
944        Filter: t2.b LIKE Utf8("%pattern%")
945          Inner Join: t1.a = t2.a
946            TableScan: t1
947            TableScan: t2
948        "#)
949    }
950
951    #[test]
952    fn eliminate_left_with_like_pattern_column() -> Result<()> {
953        let t1 = test_table_scan_with_name("t1")?;
954        let t2 = test_table_scan_with_name("t2")?;
955
956        // LIKE with nullable column on the pattern side:
957        // 'x' LIKE t2.b → if t2.b is NULL, result is NULL (filtered out)
958        let plan = LogicalPlanBuilder::from(t1)
959            .join(
960                t2,
961                JoinType::Left,
962                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
963                None,
964            )?
965            .filter(lit("x").like(col("t2.b")))?
966            .build()?;
967
968        assert_optimized_plan_equal!(plan, @r#"
969        Filter: Utf8("x") LIKE t2.b
970          Inner Join: t1.a = t2.a
971            TableScan: t1
972            TableScan: t2
973        "#)
974    }
975
976    #[test]
977    fn eliminate_full_with_like_cross_side() -> Result<()> {
978        let t1 = test_table_scan_with_name("t1")?;
979        let t2 = test_table_scan_with_name("t2")?;
980
981        // LIKE with columns from both sides: t1.c LIKE t2.b
982        // If t1 is NULL → NULL LIKE t2.b → NULL → filtered out (left non-nullable)
983        // If t2 is NULL → t1.c LIKE NULL → NULL → filtered out (right non-nullable)
984        // Both sides are non-nullable → FULL → INNER
985        let plan = LogicalPlanBuilder::from(t1)
986            .join(
987                t2,
988                JoinType::Full,
989                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
990                None,
991            )?
992            .filter(col("t1.c").like(col("t2.b")))?
993            .build()?;
994
995        assert_optimized_plan_equal!(plan, @r"
996        Filter: t1.c LIKE t2.b
997          Inner Join: t1.a = t2.a
998            TableScan: t1
999            TableScan: t2
1000        ")
1001    }
1002
1003    #[test]
1004    fn eliminate_left_with_is_true() -> Result<()> {
1005        let t1 = test_table_scan_with_name("t1")?;
1006        let t2 = test_table_scan_with_name("t2")?;
1007
1008        // IS TRUE rejects nulls: if the expression is NULL, IS TRUE returns false
1009        let plan = LogicalPlanBuilder::from(t1)
1010            .join(
1011                t2,
1012                JoinType::Left,
1013                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1014                None,
1015            )?
1016            .filter(col("t2.b").gt(lit(10u32)).is_true())?
1017            .build()?;
1018
1019        assert_optimized_plan_equal!(plan, @r"
1020        Filter: t2.b > UInt32(10) IS TRUE
1021          Inner Join: t1.a = t2.a
1022            TableScan: t1
1023            TableScan: t2
1024        ")
1025    }
1026
1027    #[test]
1028    fn eliminate_left_with_is_false() -> Result<()> {
1029        let t1 = test_table_scan_with_name("t1")?;
1030        let t2 = test_table_scan_with_name("t2")?;
1031
1032        // IS FALSE rejects nulls: if the expression is NULL, IS FALSE returns false
1033        let plan = LogicalPlanBuilder::from(t1)
1034            .join(
1035                t2,
1036                JoinType::Left,
1037                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1038                None,
1039            )?
1040            .filter(col("t2.b").gt(lit(10u32)).is_false())?
1041            .build()?;
1042
1043        assert_optimized_plan_equal!(plan, @r"
1044        Filter: t2.b > UInt32(10) IS FALSE
1045          Inner Join: t1.a = t2.a
1046            TableScan: t1
1047            TableScan: t2
1048        ")
1049    }
1050
1051    #[test]
1052    fn eliminate_left_with_is_not_unknown() -> Result<()> {
1053        let t1 = test_table_scan_with_name("t1")?;
1054        let t2 = test_table_scan_with_name("t2")?;
1055
1056        // IS NOT UNKNOWN rejects nulls: if the expression is NULL, IS NOT UNKNOWN returns false
1057        let plan = LogicalPlanBuilder::from(t1)
1058            .join(
1059                t2,
1060                JoinType::Left,
1061                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1062                None,
1063            )?
1064            .filter(col("t2.b").gt(lit(10u32)).is_not_unknown())?
1065            .build()?;
1066
1067        assert_optimized_plan_equal!(plan, @r"
1068        Filter: t2.b > UInt32(10) IS NOT UNKNOWN
1069          Inner Join: t1.a = t2.a
1070            TableScan: t1
1071            TableScan: t2
1072        ")
1073    }
1074
1075    #[test]
1076    fn no_eliminate_left_with_is_not_true() -> Result<()> {
1077        let t1 = test_table_scan_with_name("t1")?;
1078        let t2 = test_table_scan_with_name("t2")?;
1079
1080        // IS NOT TRUE is NOT null-rejecting: if the expression is NULL,
1081        // IS NOT TRUE returns true, so null rows pass through
1082        let plan = LogicalPlanBuilder::from(t1)
1083            .join(
1084                t2,
1085                JoinType::Left,
1086                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1087                None,
1088            )?
1089            .filter(col("t2.b").gt(lit(10u32)).is_not_true())?
1090            .build()?;
1091
1092        assert_optimized_plan_equal!(plan, @r"
1093        Filter: t2.b > UInt32(10) IS NOT TRUE
1094          Left Join: t1.a = t2.a
1095            TableScan: t1
1096            TableScan: t2
1097        ")
1098    }
1099
1100    #[test]
1101    fn no_eliminate_left_with_is_unknown() -> Result<()> {
1102        let t1 = test_table_scan_with_name("t1")?;
1103        let t2 = test_table_scan_with_name("t2")?;
1104
1105        // IS UNKNOWN is NOT null-rejecting: if the expression is NULL,
1106        // IS UNKNOWN returns true, so null rows pass through
1107        let plan = LogicalPlanBuilder::from(t1)
1108            .join(
1109                t2,
1110                JoinType::Left,
1111                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1112                None,
1113            )?
1114            .filter(col("t2.b").gt(lit(10u32)).is_unknown())?
1115            .build()?;
1116
1117        assert_optimized_plan_equal!(plan, @r"
1118        Filter: t2.b > UInt32(10) IS UNKNOWN
1119          Left Join: t1.a = t2.a
1120            TableScan: t1
1121            TableScan: t2
1122        ")
1123    }
1124
1125    #[test]
1126    fn no_eliminate_left_with_not_is_true() -> Result<()> {
1127        let t1 = test_table_scan_with_name("t1")?;
1128        let t2 = test_table_scan_with_name("t2")?;
1129
1130        // NOT(<x> IS TRUE) is equivalent to (<x> IS NOT TRUE): TRUE when
1131        // <x> is FALSE OR NULL. So `WHERE NOT((t2.b > 5) IS TRUE)` accepts
1132        // rows where t2.b is NULL (because t2.b > 5 is NULL → IS TRUE is
1133        // FALSE → NOT FALSE = TRUE). The LEFT JOIN must NOT be converted.
1134        let plan = LogicalPlanBuilder::from(t1)
1135            .join(
1136                t2,
1137                JoinType::Left,
1138                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1139                None,
1140            )?
1141            .filter(not(col("t2.b").gt(lit(5u32)).is_true()))?
1142            .build()?;
1143
1144        assert_optimized_plan_equal!(plan, @r"
1145        Filter: NOT t2.b > UInt32(5) IS TRUE
1146          Left Join: t1.a = t2.a
1147            TableScan: t1
1148            TableScan: t2
1149        ")
1150    }
1151
1152    #[test]
1153    fn no_eliminate_left_with_not_is_false() -> Result<()> {
1154        let t1 = test_table_scan_with_name("t1")?;
1155        let t2 = test_table_scan_with_name("t2")?;
1156
1157        // Same shape, IS FALSE: NOT(<x> IS FALSE) accepts NULL on the
1158        // inner column.
1159        let plan = LogicalPlanBuilder::from(t1)
1160            .join(
1161                t2,
1162                JoinType::Left,
1163                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1164                None,
1165            )?
1166            .filter(not(col("t2.b").gt(lit(5u32)).is_false()))?
1167            .build()?;
1168
1169        assert_optimized_plan_equal!(plan, @r"
1170        Filter: NOT t2.b > UInt32(5) IS FALSE
1171          Left Join: t1.a = t2.a
1172            TableScan: t1
1173            TableScan: t2
1174        ")
1175    }
1176
1177    #[test]
1178    fn no_eliminate_left_with_not_is_not_unknown() -> Result<()> {
1179        let t1 = test_table_scan_with_name("t1")?;
1180        let t2 = test_table_scan_with_name("t2")?;
1181
1182        // Same shape, IS NOT UNKNOWN: NOT(<x> IS NOT UNKNOWN) is
1183        // equivalent to (<x> IS UNKNOWN), which is TRUE when <x> is NULL.
1184        let plan = LogicalPlanBuilder::from(t1)
1185            .join(
1186                t2,
1187                JoinType::Left,
1188                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1189                None,
1190            )?
1191            .filter(not(col("t2.b").gt(lit(5u32)).is_not_unknown()))?
1192            .build()?;
1193
1194        assert_optimized_plan_equal!(plan, @r"
1195        Filter: NOT t2.b > UInt32(5) IS NOT UNKNOWN
1196          Left Join: t1.a = t2.a
1197            TableScan: t1
1198            TableScan: t2
1199        ")
1200    }
1201
1202    #[test]
1203    fn eliminate_full_with_type_cast() -> Result<()> {
1204        let t1 = test_table_scan_with_name("t1")?;
1205        let t2 = test_table_scan_with_name("t2")?;
1206
1207        // eliminate to inner join
1208        let plan = LogicalPlanBuilder::from(t1)
1209            .join(
1210                t2,
1211                JoinType::Full,
1212                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1213                None,
1214            )?
1215            .filter(binary_expr(
1216                cast(col("t1.b"), DataType::Int64).gt(lit(10u32)),
1217                And,
1218                try_cast(col("t2.c"), DataType::Int64).lt(lit(20u32)),
1219            ))?
1220            .build()?;
1221
1222        assert_optimized_plan_equal!(plan, @r"
1223        Filter: CAST(t1.b AS Int64) > UInt32(10) AND TRY_CAST(t2.c AS Int64) < UInt32(20)
1224          Inner Join: t1.a = t2.a
1225            TableScan: t1
1226            TableScan: t2
1227        ")
1228    }
1229
1230    // ----- FULL JOIN → LEFT / RIGHT tests -----
1231    #[test]
1232    fn eliminate_full_to_left_with_left_filter() -> Result<()> {
1233        let t1 = test_table_scan_with_name("t1")?;
1234        let t2 = test_table_scan_with_name("t2")?;
1235
1236        // FULL JOIN with null-rejecting filter only on left side → LEFT JOIN
1237        // (left side becomes non-nullable, right side stays nullable)
1238        let plan = LogicalPlanBuilder::from(t1)
1239            .join(
1240                t2,
1241                JoinType::Full,
1242                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1243                None,
1244            )?
1245            .filter(col("t1.b").gt(lit(10u32)))?
1246            .build()?;
1247
1248        assert_optimized_plan_equal!(plan, @r"
1249        Filter: t1.b > UInt32(10)
1250          Left Join: t1.a = t2.a
1251            TableScan: t1
1252            TableScan: t2
1253        ")
1254    }
1255
1256    #[test]
1257    fn eliminate_full_to_right_with_right_filter() -> Result<()> {
1258        let t1 = test_table_scan_with_name("t1")?;
1259        let t2 = test_table_scan_with_name("t2")?;
1260
1261        // FULL JOIN with null-rejecting filter only on right side → RIGHT JOIN
1262        let plan = LogicalPlanBuilder::from(t1)
1263            .join(
1264                t2,
1265                JoinType::Full,
1266                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1267                None,
1268            )?
1269            .filter(col("t2.b").in_list(vec![lit(1u32), lit(2u32)], false))?
1270            .build()?;
1271
1272        assert_optimized_plan_equal!(plan, @r"
1273        Filter: t2.b IN ([UInt32(1), UInt32(2)])
1274          Right Join: t1.a = t2.a
1275            TableScan: t1
1276            TableScan: t2
1277        ")
1278    }
1279
1280    #[test]
1281    fn eliminate_full_to_left_with_like() -> Result<()> {
1282        let t1 = test_table_scan_with_name("t1")?;
1283        let t2 = test_table_scan_with_name("t2")?;
1284
1285        // FULL JOIN with LIKE on left side only → LEFT JOIN
1286        let plan = LogicalPlanBuilder::from(t1)
1287            .join(
1288                t2,
1289                JoinType::Full,
1290                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1291                None,
1292            )?
1293            .filter(col("t1.b").like(lit("%val%")))?
1294            .build()?;
1295
1296        assert_optimized_plan_equal!(plan, @r#"
1297        Filter: t1.b LIKE Utf8("%val%")
1298          Left Join: t1.a = t2.a
1299            TableScan: t1
1300            TableScan: t2
1301        "#)
1302    }
1303
1304    #[test]
1305    fn eliminate_full_to_right_with_is_true() -> Result<()> {
1306        let t1 = test_table_scan_with_name("t1")?;
1307        let t2 = test_table_scan_with_name("t2")?;
1308
1309        // FULL JOIN with IS TRUE on right side only → RIGHT JOIN
1310        let plan = LogicalPlanBuilder::from(t1)
1311            .join(
1312                t2,
1313                JoinType::Full,
1314                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1315                None,
1316            )?
1317            .filter(col("t2.b").gt(lit(10u32)).is_true())?
1318            .build()?;
1319
1320        assert_optimized_plan_equal!(plan, @r"
1321        Filter: t2.b > UInt32(10) IS TRUE
1322          Right Join: t1.a = t2.a
1323            TableScan: t1
1324            TableScan: t2
1325        ")
1326    }
1327
1328    // ----- Nested AND / OR tests -----
1329
1330    #[test]
1331    fn eliminate_left_with_and_multiple_null_rejecting() -> Result<()> {
1332        let t1 = test_table_scan_with_name("t1")?;
1333        let t2 = test_table_scan_with_name("t2")?;
1334
1335        // Multiple null-rejecting predicates combined with AND on nullable side
1336        let plan = LogicalPlanBuilder::from(t1)
1337            .join(
1338                t2,
1339                JoinType::Left,
1340                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1341                None,
1342            )?
1343            .filter(binary_expr(
1344                col("t2.b").in_list(vec![lit(1u32), lit(2u32)], false),
1345                And,
1346                col("t2.c").between(lit(5u32), lit(20u32)),
1347            ))?
1348            .build()?;
1349
1350        assert_optimized_plan_equal!(plan, @r"
1351        Filter: t2.b IN ([UInt32(1), UInt32(2)]) AND t2.c BETWEEN UInt32(5) AND UInt32(20)
1352          Inner Join: t1.a = t2.a
1353            TableScan: t1
1354            TableScan: t2
1355        ")
1356    }
1357
1358    #[test]
1359    fn eliminate_left_with_or_same_side() -> Result<()> {
1360        let t1 = test_table_scan_with_name("t1")?;
1361        let t2 = test_table_scan_with_name("t2")?;
1362
1363        // OR of two null-rejecting predicates on different columns of the same
1364        // nullable side. If t2 rows are NULL (from LEFT JOIN), both t2.b and
1365        // t2.c are NULL, so the entire OR evaluates to NULL → filtered out.
1366        // This IS null-rejecting, so join should be eliminated.
1367        let plan = LogicalPlanBuilder::from(t1)
1368            .join(
1369                t2,
1370                JoinType::Left,
1371                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1372                None,
1373            )?
1374            .filter(binary_expr(
1375                col("t2.b").gt(lit(10u32)),
1376                Or,
1377                col("t2.c").lt(lit(20u32)),
1378            ))?
1379            .build()?;
1380
1381        assert_optimized_plan_equal!(plan, @r"
1382        Filter: t2.b > UInt32(10) OR t2.c < UInt32(20)
1383          Inner Join: t1.a = t2.a
1384            TableScan: t1
1385            TableScan: t2
1386        ")
1387    }
1388
1389    #[test]
1390    fn no_eliminate_left_with_or_cross_side() -> Result<()> {
1391        let t1 = test_table_scan_with_name("t1")?;
1392        let t2 = test_table_scan_with_name("t2")?;
1393
1394        // OR with columns from different sides — t1.b (preserved) OR t2.b
1395        // (nullable). When t2 is NULL, t1.b > 10 can still be true, so the
1396        // OR is NOT null-rejecting. Join must be preserved.
1397        let plan = LogicalPlanBuilder::from(t1)
1398            .join(
1399                t2,
1400                JoinType::Left,
1401                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1402                None,
1403            )?
1404            .filter(binary_expr(
1405                col("t1.b").gt(lit(10u32)),
1406                Or,
1407                col("t2.b").lt(lit(20u32)),
1408            ))?
1409            .build()?;
1410
1411        assert_optimized_plan_equal!(plan, @r"
1412        Filter: t1.b > UInt32(10) OR t2.b < UInt32(20)
1413          Left Join: t1.a = t2.a
1414            TableScan: t1
1415            TableScan: t2
1416        ")
1417    }
1418
1419    // ----- Mixed predicate tests -----
1420
1421    #[test]
1422    fn eliminate_full_with_mixed_predicates() -> Result<()> {
1423        let t1 = test_table_scan_with_name("t1")?;
1424        let t2 = test_table_scan_with_name("t2")?;
1425
1426        // FULL JOIN with different null-rejecting expr types on each side:
1427        // LIKE on left, BETWEEN on right → INNER JOIN
1428        let plan = LogicalPlanBuilder::from(t1)
1429            .join(
1430                t2,
1431                JoinType::Full,
1432                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1433                None,
1434            )?
1435            .filter(binary_expr(
1436                col("t1.b").like(lit("%pattern%")),
1437                And,
1438                col("t2.b").between(lit(1u32), lit(10u32)),
1439            ))?
1440            .build()?;
1441
1442        assert_optimized_plan_equal!(plan, @r#"
1443        Filter: t1.b LIKE Utf8("%pattern%") AND t2.b BETWEEN UInt32(1) AND UInt32(10)
1444          Inner Join: t1.a = t2.a
1445            TableScan: t1
1446            TableScan: t2
1447        "#)
1448    }
1449
1450    #[test]
1451    fn eliminate_left_with_is_true_and_in_list() -> Result<()> {
1452        let t1 = test_table_scan_with_name("t1")?;
1453        let t2 = test_table_scan_with_name("t2")?;
1454
1455        // AND of IS TRUE and IN on nullable side — both null-rejecting
1456        let plan = LogicalPlanBuilder::from(t1)
1457            .join(
1458                t2,
1459                JoinType::Left,
1460                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1461                None,
1462            )?
1463            .filter(binary_expr(
1464                col("t2.b").gt(lit(5u32)).is_true(),
1465                And,
1466                col("t2.c").in_list(vec![lit(1u32), lit(2u32)], false),
1467            ))?
1468            .build()?;
1469
1470        assert_optimized_plan_equal!(plan, @r"
1471        Filter: t2.b > UInt32(5) IS TRUE AND t2.c IN ([UInt32(1), UInt32(2)])
1472          Inner Join: t1.a = t2.a
1473            TableScan: t1
1474            TableScan: t2
1475        ")
1476    }
1477
1478    // ----- Filter pierces a Projection to reach the Join -----
1479
1480    #[test]
1481    fn eliminate_left_through_projection() -> Result<()> {
1482        let t1 = test_table_scan_with_name("t1")?;
1483        let t2 = test_table_scan_with_name("t2")?;
1484
1485        // Filter → Projection → LeftJoin is the shape produced by projection
1486        // pruning in queries such as TPC-DS q49, where the post-join
1487        // Projection sits between the filter and the join.
1488        let plan = LogicalPlanBuilder::from(t1)
1489            .join(
1490                t2,
1491                JoinType::Left,
1492                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1493                None,
1494            )?
1495            .project(vec![col("t1.a"), col("t2.b").alias("bb")])?
1496            .filter(col("bb").gt(lit(10u32)))?
1497            .build()?;
1498
1499        assert_optimized_plan_equal!(plan, @r"
1500        Filter: bb > UInt32(10)
1501          Projection: t1.a, t2.b AS bb
1502            Inner Join: t1.a = t2.a
1503              TableScan: t1
1504              TableScan: t2
1505        ")
1506    }
1507
1508    #[test]
1509    fn no_eliminate_left_through_projection_with_or_cross_side() -> Result<()> {
1510        let t1 = test_table_scan_with_name("t1")?;
1511        let t2 = test_table_scan_with_name("t2")?;
1512
1513        // After inlining the filter is still t1.b > 10 OR t2.b < 20, which
1514        // is null-tolerant when t2 is NULL (the t1.b clause can still hold).
1515        // The LEFT JOIN must be preserved.
1516        let plan = LogicalPlanBuilder::from(t1)
1517            .join(
1518                t2,
1519                JoinType::Left,
1520                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1521                None,
1522            )?
1523            .project(vec![col("t1.b").alias("x"), col("t2.b").alias("y")])?
1524            .filter(binary_expr(
1525                col("x").gt(lit(10u32)),
1526                Or,
1527                col("y").lt(lit(20u32)),
1528            ))?
1529            .build()?;
1530
1531        assert_optimized_plan_equal!(plan, @r"
1532        Filter: x > UInt32(10) OR y < UInt32(20)
1533          Projection: t1.b AS x, t2.b AS y
1534            Left Join: t1.a = t2.a
1535              TableScan: t1
1536              TableScan: t2
1537        ")
1538    }
1539
1540    #[test]
1541    fn no_eliminate_left_through_projection_with_only_left_filter() -> Result<()> {
1542        let t1 = test_table_scan_with_name("t1")?;
1543        let t2 = test_table_scan_with_name("t2")?;
1544
1545        // A filter that constrains only the preserved (left) side of a
1546        // LEFT JOIN does not justify converting it to INNER — the LEFT
1547        // would still pass nullable right-side rows that the filter
1548        // accepts.
1549        let plan = LogicalPlanBuilder::from(t1)
1550            .join(
1551                t2,
1552                JoinType::Left,
1553                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1554                None,
1555            )?
1556            .project(vec![col("t1.b").alias("x"), col("t2.b")])?
1557            .filter(col("x").gt(lit(10u32)))?
1558            .build()?;
1559
1560        assert_optimized_plan_equal!(plan, @r"
1561        Filter: x > UInt32(10)
1562          Projection: t1.b AS x, t2.b
1563            Left Join: t1.a = t2.a
1564              TableScan: t1
1565              TableScan: t2
1566        ")
1567    }
1568
1569    #[test]
1570    fn eliminate_left_with_arithmetic_predicate() -> Result<()> {
1571        let t1 = test_table_scan_with_name("t1")?;
1572        let t2 = test_table_scan_with_name("t2")?;
1573
1574        // t2.b * 2 + 1 > 10 is null-rejecting on t2.b: arithmetic
1575        // operators propagate NULL, so the whole expression is NULL when
1576        // t2.b is NULL, and NULL > 10 is filtered out by WHERE.
1577        let plan = LogicalPlanBuilder::from(t1)
1578            .join(
1579                t2,
1580                JoinType::Left,
1581                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1582                None,
1583            )?
1584            .filter(
1585                binary_expr(
1586                    binary_expr(col("t2.b"), Operator::Multiply, lit(2u32)),
1587                    Operator::Plus,
1588                    lit(1u32),
1589                )
1590                .gt(lit(10u32)),
1591            )?
1592            .build()?;
1593
1594        assert_optimized_plan_equal!(plan, @r"
1595        Filter: t2.b * UInt32(2) + UInt32(1) > UInt32(10)
1596          Inner Join: t1.a = t2.a
1597            TableScan: t1
1598            TableScan: t2
1599        ")
1600    }
1601    #[test]
1602    fn eliminate_left_with_negative_predicate() -> Result<()> {
1603        let t1 = test_table_scan_with_name("t1")?;
1604        let t2 = test_table_scan_with_name("t2")?;
1605
1606        // Unary minus propagates NULL: -NULL is NULL, so `WHERE -t2.b > 0`
1607        // is null-rejecting on t2.b.
1608        let plan = LogicalPlanBuilder::from(t1)
1609            .join(
1610                t2,
1611                JoinType::Left,
1612                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1613                None,
1614            )?
1615            .filter(Expr::Negative(Box::new(col("t2.b"))).gt(lit(0u32)))?
1616            .build()?;
1617
1618        assert_optimized_plan_equal!(plan, @r"
1619        Filter: (- t2.b) > UInt32(0)
1620          Inner Join: t1.a = t2.a
1621            TableScan: t1
1622            TableScan: t2
1623        ")
1624    }
1625
1626    #[test]
1627    fn no_eliminate_left_with_is_distinct_from() -> Result<()> {
1628        let t1 = test_table_scan_with_name("t1")?;
1629        let t2 = test_table_scan_with_name("t2")?;
1630
1631        // IS DISTINCT FROM is NOT null-rejecting: t2.b IS DISTINCT FROM 5 is
1632        // true when t2.b is NULL (NULL is distinct from 5). Padding rows from
1633        // a LEFT JOIN would survive the filter, so the LEFT JOIN must stay.
1634        let plan = LogicalPlanBuilder::from(t1)
1635            .join(
1636                t2,
1637                JoinType::Left,
1638                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1639                None,
1640            )?
1641            .filter(binary_expr(
1642                col("t2.b"),
1643                Operator::IsDistinctFrom,
1644                lit(5u32),
1645            ))?
1646            .build()?;
1647
1648        assert_optimized_plan_equal!(plan, @r"
1649        Filter: t2.b IS DISTINCT FROM UInt32(5)
1650          Left Join: t1.a = t2.a
1651            TableScan: t1
1652            TableScan: t2
1653        ")
1654    }
1655
1656    #[test]
1657    fn no_eliminate_left_with_is_not_distinct_from() -> Result<()> {
1658        let t1 = test_table_scan_with_name("t1")?;
1659        let t2 = test_table_scan_with_name("t2")?;
1660
1661        // IS NOT DISTINCT FROM is also not null-rejecting: t2.b IS NOT
1662        // DISTINCT FROM NULL is true when t2.b is NULL. The LEFT JOIN must
1663        // stay.
1664        let plan = LogicalPlanBuilder::from(t1)
1665            .join(
1666                t2,
1667                JoinType::Left,
1668                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1669                None,
1670            )?
1671            .filter(binary_expr(
1672                col("t2.b"),
1673                Operator::IsNotDistinctFrom,
1674                lit(ScalarValue::UInt32(None)),
1675            ))?
1676            .build()?;
1677
1678        assert_optimized_plan_equal!(plan, @r"
1679        Filter: t2.b IS NOT DISTINCT FROM UInt32(NULL)
1680          Left Join: t1.a = t2.a
1681            TableScan: t1
1682            TableScan: t2
1683        ")
1684    }
1685
1686    #[test]
1687    fn no_eliminate_through_non_transparent() -> Result<()> {
1688        let t1 = test_table_scan_with_name("t1")?;
1689        let t2 = test_table_scan_with_name("t2")?;
1690
1691        // Limit is intentionally not treated as transparent: a Limit below
1692        // the Filter changes which rows survive, so swapping LEFT→INNER
1693        // beneath it could yield a different surviving-row set even when
1694        // the filter is null-rejecting on the right side.
1695        let plan = LogicalPlanBuilder::from(t1)
1696            .join(
1697                t2,
1698                JoinType::Left,
1699                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1700                None,
1701            )?
1702            .limit(0, Some(5))?
1703            .filter(col("t2.b").gt(lit(10u32)))?
1704            .build()?;
1705
1706        assert_optimized_plan_equal!(plan, @r"
1707        Filter: t2.b > UInt32(10)
1708          Limit: skip=0, fetch=5
1709            Left Join: t1.a = t2.a
1710              TableScan: t1
1711              TableScan: t2
1712        ")
1713    }
1714}