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`] converts `LEFT/RIGHT/FULL` joins to `INNER` joins
19use crate::{OptimizerConfig, OptimizerRule};
20use datafusion_common::{Column, DFSchema, Result};
21use datafusion_expr::logical_plan::{Join, JoinType, LogicalPlan};
22use datafusion_expr::{Expr, Filter, Operator};
23
24use crate::optimizer::ApplyOrder;
25use datafusion_common::tree_node::Transformed;
26use datafusion_expr::expr::{BinaryExpr, Cast, InList, Like, TryCast};
27use std::sync::Arc;
28
29///
30/// Attempt to replace outer joins with inner joins.
31///
32/// Outer joins are typically more expensive to compute at runtime
33/// than inner joins and prevent various forms of predicate pushdown
34/// and other optimizations, so removing them if possible is beneficial.
35///
36/// Inner joins filter out rows that do match. Outer joins pass rows
37/// that do not match padded with nulls. If there is a filter in the
38/// query that would filter any such null rows after the join the rows
39/// introduced by the outer join are filtered.
40///
41/// For example, in the `select ... from a left join b on ... where b.xx = 100;`
42///
43/// For rows when `b.xx` is null (as it would be after an outer join),
44/// the `b.xx = 100` predicate filters them out and there is no
45/// need to produce null rows for output.
46///
47/// Generally, an outer join can be rewritten to inner join if the
48/// filters from the WHERE clause return false while any inputs are
49/// null and columns of those quals are come from nullable side of
50/// outer join.
51#[derive(Default, Debug)]
52pub struct EliminateOuterJoin;
53
54impl EliminateOuterJoin {
55    #[expect(missing_docs)]
56    pub fn new() -> Self {
57        Self {}
58    }
59}
60
61/// Attempt to eliminate outer joins.
62impl OptimizerRule for EliminateOuterJoin {
63    fn name(&self) -> &str {
64        "eliminate_outer_join"
65    }
66
67    fn apply_order(&self) -> Option<ApplyOrder> {
68        Some(ApplyOrder::TopDown)
69    }
70
71    fn supports_rewrite(&self) -> bool {
72        true
73    }
74
75    fn rewrite(
76        &self,
77        plan: LogicalPlan,
78        _config: &dyn OptimizerConfig,
79    ) -> Result<Transformed<LogicalPlan>> {
80        match plan {
81            LogicalPlan::Filter(mut filter) => match Arc::unwrap_or_clone(filter.input) {
82                LogicalPlan::Join(join) => {
83                    let mut null_rejecting_cols: Vec<Column> = vec![];
84
85                    extract_null_rejecting_columns(
86                        &filter.predicate,
87                        &mut null_rejecting_cols,
88                        join.left.schema(),
89                        join.right.schema(),
90                        true,
91                    );
92
93                    let new_join_type = if join.join_type.is_outer() {
94                        let mut left_non_nullable = false;
95                        let mut right_non_nullable = false;
96                        for col in null_rejecting_cols.iter() {
97                            if join.left.schema().has_column(col) {
98                                left_non_nullable = true;
99                            }
100                            if join.right.schema().has_column(col) {
101                                right_non_nullable = true;
102                            }
103                        }
104                        eliminate_outer(
105                            join.join_type,
106                            left_non_nullable,
107                            right_non_nullable,
108                        )
109                    } else {
110                        join.join_type
111                    };
112
113                    let new_join = Arc::new(LogicalPlan::Join(Join {
114                        left: join.left,
115                        right: join.right,
116                        join_type: new_join_type,
117                        join_constraint: join.join_constraint,
118                        on: join.on.clone(),
119                        filter: join.filter.clone(),
120                        schema: Arc::clone(&join.schema),
121                        null_equality: join.null_equality,
122                        null_aware: join.null_aware,
123                    }));
124                    Filter::try_new(filter.predicate, new_join)
125                        .map(|f| Transformed::yes(LogicalPlan::Filter(f)))
126                }
127                filter_input => {
128                    filter.input = Arc::new(filter_input);
129                    Ok(Transformed::no(LogicalPlan::Filter(filter)))
130                }
131            },
132            _ => Ok(Transformed::no(plan)),
133        }
134    }
135}
136
137pub fn eliminate_outer(
138    join_type: JoinType,
139    left_non_nullable: bool,
140    right_non_nullable: bool,
141) -> JoinType {
142    let mut new_join_type = join_type;
143    match join_type {
144        JoinType::Left if right_non_nullable => {
145            new_join_type = JoinType::Inner;
146        }
147        JoinType::Left => {}
148        JoinType::Right if left_non_nullable => {
149            new_join_type = JoinType::Inner;
150        }
151        JoinType::Right => {}
152        JoinType::Full => {
153            if left_non_nullable && right_non_nullable {
154                new_join_type = JoinType::Inner;
155            } else if left_non_nullable {
156                new_join_type = JoinType::Left;
157            } else if right_non_nullable {
158                new_join_type = JoinType::Right;
159            }
160        }
161        _ => {}
162    }
163    new_join_type
164}
165
166/// Find the columns that `expr` rejects NULL on. If any of these columns are
167/// NULL, `expr` is guaranteed to evaluate to NULL or false, and the row
168/// therefore cannot survive a WHERE clause. Matching columns are appended to
169/// `null_rejecting_cols`.
170///
171/// The caller uses the result to decide whether an outer join's null-padded
172/// rows could survive the predicate above the join: if a column from the
173/// nullable side appears in `null_rejecting_cols`, it cannot, and the outer
174/// join can be converted to an inner join.
175///
176/// `left_schema` and `right_schema` are the join's two child schemas.
177/// `top_level` is true at the root of the WHERE predicate and false on each
178/// recursion.
179fn extract_null_rejecting_columns(
180    expr: &Expr,
181    null_rejecting_cols: &mut Vec<Column>,
182    left_schema: &Arc<DFSchema>,
183    right_schema: &Arc<DFSchema>,
184    top_level: bool,
185) {
186    match expr {
187        Expr::Column(col) => {
188            null_rejecting_cols.push(col.clone());
189        }
190        Expr::BinaryExpr(BinaryExpr { left, op, right }) => match op {
191            Operator::And | Operator::Or => {
192                // AND distributes only down a top-level AND chain in the WHERE
193                // clause: each conjunct is independently null- rejecting, so
194                // any column either side discovers is a column the WHERE
195                // rejects NULL on. Once an AND appears below any other context,
196                // we fall back to the per-side analysis used for OR, because
197                // the context might influence whether the row is filtered.
198                if top_level && *op == Operator::And {
199                    extract_null_rejecting_columns(
200                        left,
201                        null_rejecting_cols,
202                        left_schema,
203                        right_schema,
204                        top_level,
205                    );
206                    extract_null_rejecting_columns(
207                        right,
208                        null_rejecting_cols,
209                        left_schema,
210                        right_schema,
211                        top_level,
212                    );
213                    return;
214                }
215
216                // OR (and nested AND): a row survives if EITHER operand returns
217                // true. We can credit a join side as null-rejecting only when
218                // BOTH operands independently reject NULL on a column from that
219                // side — otherwise the other branch could let the NULL row
220                // through.
221                let mut left_cols: Vec<Column> = vec![];
222                let mut right_cols: Vec<Column> = vec![];
223                extract_null_rejecting_columns(
224                    left,
225                    &mut left_cols,
226                    left_schema,
227                    right_schema,
228                    top_level,
229                );
230                extract_null_rejecting_columns(
231                    right,
232                    &mut right_cols,
233                    left_schema,
234                    right_schema,
235                    top_level,
236                );
237
238                let find_on = |cols: &[Column], schema: &DFSchema| {
239                    cols.iter().find(|c| schema.has_column(c)).cloned()
240                };
241                for schema in [left_schema, right_schema] {
242                    if let (Some(c), Some(_)) =
243                        (find_on(&left_cols, schema), find_on(&right_cols, schema))
244                    {
245                        null_rejecting_cols.push(c);
246                    }
247                }
248            }
249            // Any other operator that DataFusion declares as NULL-on-NULL:
250            // recurse into both operands so we collect their columns.
251            op if op.returns_null_on_null() => {
252                extract_null_rejecting_columns(
253                    left,
254                    null_rejecting_cols,
255                    left_schema,
256                    right_schema,
257                    false,
258                );
259                extract_null_rejecting_columns(
260                    right,
261                    null_rejecting_cols,
262                    left_schema,
263                    right_schema,
264                    false,
265                )
266            }
267            // All other operators (notably including IS [ NOT ] DISTINCT FROM)
268            // are declared as not null-propagating, so they don't contribute
269            // any null-rejecting columns.
270            _ => {}
271        },
272        Expr::Not(arg) | Expr::Negative(arg) => extract_null_rejecting_columns(
273            arg,
274            null_rejecting_cols,
275            left_schema,
276            right_schema,
277            false,
278        ),
279        // IS NOT NULL / IS TRUE / IS FALSE / IS NOT UNKNOWN all return FALSE on
280        // NULL input. At the top of a WHERE clause, that FALSE filters the row
281        // and so we can recurse; below the top level the surrounding context
282        // may transform that FALSE into something that accepts NULL rows,
283        // making the recursion unsound.
284        Expr::IsNotNull(arg)
285        | Expr::IsTrue(arg)
286        | Expr::IsFalse(arg)
287        | Expr::IsNotUnknown(arg) => {
288            if !top_level {
289                return;
290            }
291            extract_null_rejecting_columns(
292                arg,
293                null_rejecting_cols,
294                left_schema,
295                right_schema,
296                false,
297            )
298        }
299        Expr::Cast(Cast { expr, field: _ })
300        | Expr::TryCast(TryCast { expr, field: _ }) => extract_null_rejecting_columns(
301            expr,
302            null_rejecting_cols,
303            left_schema,
304            right_schema,
305            false,
306        ),
307        // IN list and BETWEEN are null-rejecting on the input expression:
308        // NULL input yields a NULL result, regardless of whether the list
309        // or range bounds themselves contain NULLs.
310        Expr::InList(InList { expr, .. }) => extract_null_rejecting_columns(
311            expr,
312            null_rejecting_cols,
313            left_schema,
314            right_schema,
315            false,
316        ),
317        Expr::Between(between) => extract_null_rejecting_columns(
318            &between.expr,
319            null_rejecting_cols,
320            left_schema,
321            right_schema,
322            false,
323        ),
324        Expr::Like(Like { expr, pattern, .. }) => {
325            extract_null_rejecting_columns(
326                expr,
327                null_rejecting_cols,
328                left_schema,
329                right_schema,
330                false,
331            );
332            extract_null_rejecting_columns(
333                pattern,
334                null_rejecting_cols,
335                left_schema,
336                right_schema,
337                false,
338            );
339        }
340        // Anything not handled above contributes no null-rejecting
341        // columns. Two categories worth calling out:
342        //   - IS NULL, IS NOT TRUE, IS NOT FALSE, IS UNKNOWN — return
343        //     TRUE on NULL input, so they actively *accept* NULL rows
344        //     and are intentionally excluded.
345        //   - Function calls (scalar / aggregate / window / UDF),
346        //     scalar subqueries, struct/list accessors, aliases,
347        //     literals, etc. — we don't have a uniform NULL-propagation
348        //     guarantee for these cases, so we conservatively skip them.
349        _ => {}
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::OptimizerContext;
357    use crate::assert_optimized_plan_eq_snapshot;
358    use crate::test::*;
359    use arrow::datatypes::DataType;
360    use datafusion_common::ScalarValue;
361    use datafusion_expr::{
362        Operator::{And, Or},
363        binary_expr, cast, col, lit,
364        logical_plan::builder::LogicalPlanBuilder,
365        not, try_cast,
366    };
367
368    macro_rules! assert_optimized_plan_equal {
369        (
370            $plan:expr,
371            @ $expected:literal $(,)?
372        ) => {{
373            let optimizer_ctx = OptimizerContext::new().with_max_passes(1);
374            let rules: Vec<Arc<dyn crate::OptimizerRule + Send + Sync>> = vec![Arc::new(EliminateOuterJoin::new())];
375            assert_optimized_plan_eq_snapshot!(
376                optimizer_ctx,
377                rules,
378                $plan,
379                @ $expected,
380            )
381        }};
382    }
383
384    #[test]
385    fn eliminate_left_with_null() -> Result<()> {
386        let t1 = test_table_scan_with_name("t1")?;
387        let t2 = test_table_scan_with_name("t2")?;
388
389        // could not eliminate to inner join
390        let plan = LogicalPlanBuilder::from(t1)
391            .join(
392                t2,
393                JoinType::Left,
394                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
395                None,
396            )?
397            .filter(col("t2.b").is_null())?
398            .build()?;
399
400        assert_optimized_plan_equal!(plan, @r"
401        Filter: t2.b IS NULL
402          Left Join: t1.a = t2.a
403            TableScan: t1
404            TableScan: t2
405        ")
406    }
407
408    #[test]
409    fn eliminate_left_with_not_null() -> Result<()> {
410        let t1 = test_table_scan_with_name("t1")?;
411        let t2 = test_table_scan_with_name("t2")?;
412
413        // eliminate to inner join
414        let plan = LogicalPlanBuilder::from(t1)
415            .join(
416                t2,
417                JoinType::Left,
418                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
419                None,
420            )?
421            .filter(col("t2.b").is_not_null())?
422            .build()?;
423
424        assert_optimized_plan_equal!(plan, @r"
425        Filter: t2.b IS NOT NULL
426          Inner Join: t1.a = t2.a
427            TableScan: t1
428            TableScan: t2
429        ")
430    }
431
432    #[test]
433    fn eliminate_right_with_or() -> Result<()> {
434        let t1 = test_table_scan_with_name("t1")?;
435        let t2 = test_table_scan_with_name("t2")?;
436
437        // eliminate to inner join
438        let plan = LogicalPlanBuilder::from(t1)
439            .join(
440                t2,
441                JoinType::Right,
442                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
443                None,
444            )?
445            .filter(binary_expr(
446                col("t1.b").gt(lit(10u32)),
447                Or,
448                col("t1.c").lt(lit(20u32)),
449            ))?
450            .build()?;
451
452        assert_optimized_plan_equal!(plan, @r"
453        Filter: t1.b > UInt32(10) OR t1.c < UInt32(20)
454          Inner Join: t1.a = t2.a
455            TableScan: t1
456            TableScan: t2
457        ")
458    }
459
460    #[test]
461    fn eliminate_full_with_and() -> Result<()> {
462        let t1 = test_table_scan_with_name("t1")?;
463        let t2 = test_table_scan_with_name("t2")?;
464
465        // eliminate to inner join
466        let plan = LogicalPlanBuilder::from(t1)
467            .join(
468                t2,
469                JoinType::Full,
470                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
471                None,
472            )?
473            .filter(binary_expr(
474                col("t1.b").gt(lit(10u32)),
475                And,
476                col("t2.c").lt(lit(20u32)),
477            ))?
478            .build()?;
479
480        assert_optimized_plan_equal!(plan, @r"
481        Filter: t1.b > UInt32(10) AND t2.c < UInt32(20)
482          Inner Join: t1.a = t2.a
483            TableScan: t1
484            TableScan: t2
485        ")
486    }
487
488    #[test]
489    fn eliminate_left_with_in_list() -> Result<()> {
490        let t1 = test_table_scan_with_name("t1")?;
491        let t2 = test_table_scan_with_name("t2")?;
492
493        // t2.b IN (1, 2, 3) rejects nulls — if t2.b is NULL the IN returns
494        // NULL which is filtered out. So Left Join should become Inner Join.
495        let plan = LogicalPlanBuilder::from(t1)
496            .join(
497                t2,
498                JoinType::Left,
499                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
500                None,
501            )?
502            .filter(col("t2.b").in_list(vec![lit(1u32), lit(2u32), lit(3u32)], false))?
503            .build()?;
504
505        assert_optimized_plan_equal!(plan, @r"
506        Filter: t2.b IN ([UInt32(1), UInt32(2), UInt32(3)])
507          Inner Join: t1.a = t2.a
508            TableScan: t1
509            TableScan: t2
510        ")
511    }
512
513    #[test]
514    fn eliminate_left_with_in_list_containing_null() -> Result<()> {
515        let t1 = test_table_scan_with_name("t1")?;
516        let t2 = test_table_scan_with_name("t2")?;
517
518        // IN list with NULL still rejects null input columns:
519        // if t2.b is NULL, NULL IN (1, NULL) evaluates to NULL, which is filtered out
520        let plan = LogicalPlanBuilder::from(t1)
521            .join(
522                t2,
523                JoinType::Left,
524                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
525                None,
526            )?
527            .filter(
528                col("t2.b")
529                    .in_list(vec![lit(1u32), lit(ScalarValue::UInt32(None))], false),
530            )?
531            .build()?;
532
533        assert_optimized_plan_equal!(plan, @r"
534        Filter: t2.b IN ([UInt32(1), UInt32(NULL)])
535          Inner Join: t1.a = t2.a
536            TableScan: t1
537            TableScan: t2
538        ")
539    }
540
541    #[test]
542    fn eliminate_left_with_not_in_list() -> Result<()> {
543        let t1 = test_table_scan_with_name("t1")?;
544        let t2 = test_table_scan_with_name("t2")?;
545
546        // NOT IN also rejects nulls: if t2.b is NULL, NOT (NULL IN (...))
547        // evaluates to NULL, which is filtered out
548        let plan = LogicalPlanBuilder::from(t1)
549            .join(
550                t2,
551                JoinType::Left,
552                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
553                None,
554            )?
555            .filter(col("t2.b").in_list(vec![lit(1u32), lit(2u32)], true))?
556            .build()?;
557
558        assert_optimized_plan_equal!(plan, @r"
559        Filter: t2.b NOT IN ([UInt32(1), UInt32(2)])
560          Inner Join: t1.a = t2.a
561            TableScan: t1
562            TableScan: t2
563        ")
564    }
565
566    #[test]
567    fn eliminate_left_with_between() -> Result<()> {
568        let t1 = test_table_scan_with_name("t1")?;
569        let t2 = test_table_scan_with_name("t2")?;
570
571        // BETWEEN rejects nulls: if t2.b is NULL, NULL BETWEEN 1 AND 10
572        // evaluates to NULL, which is filtered out
573        let plan = LogicalPlanBuilder::from(t1)
574            .join(
575                t2,
576                JoinType::Left,
577                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
578                None,
579            )?
580            .filter(col("t2.b").between(lit(1u32), lit(10u32)))?
581            .build()?;
582
583        assert_optimized_plan_equal!(plan, @r"
584        Filter: t2.b BETWEEN UInt32(1) AND UInt32(10)
585          Inner Join: t1.a = t2.a
586            TableScan: t1
587            TableScan: t2
588        ")
589    }
590
591    #[test]
592    fn eliminate_right_with_between() -> Result<()> {
593        let t1 = test_table_scan_with_name("t1")?;
594        let t2 = test_table_scan_with_name("t2")?;
595
596        // Right join: filter on left (nullable) side with BETWEEN should convert to Inner
597        let plan = LogicalPlanBuilder::from(t1)
598            .join(
599                t2,
600                JoinType::Right,
601                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
602                None,
603            )?
604            .filter(col("t1.b").between(lit(1u32), lit(10u32)))?
605            .build()?;
606
607        assert_optimized_plan_equal!(plan, @r"
608        Filter: t1.b BETWEEN UInt32(1) AND UInt32(10)
609          Inner Join: t1.a = t2.a
610            TableScan: t1
611            TableScan: t2
612        ")
613    }
614
615    #[test]
616    fn eliminate_full_with_between() -> Result<()> {
617        let t1 = test_table_scan_with_name("t1")?;
618        let t2 = test_table_scan_with_name("t2")?;
619
620        // Full join with BETWEEN on both sides should become Inner
621        let plan = LogicalPlanBuilder::from(t1)
622            .join(
623                t2,
624                JoinType::Full,
625                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
626                None,
627            )?
628            .filter(binary_expr(
629                col("t1.b").between(lit(1u32), lit(10u32)),
630                And,
631                col("t2.b").between(lit(5u32), lit(20u32)),
632            ))?
633            .build()?;
634
635        assert_optimized_plan_equal!(plan, @r"
636        Filter: t1.b BETWEEN UInt32(1) AND UInt32(10) AND t2.b BETWEEN UInt32(5) AND UInt32(20)
637          Inner Join: t1.a = t2.a
638            TableScan: t1
639            TableScan: t2
640        ")
641    }
642
643    #[test]
644    fn eliminate_full_with_in_list() -> Result<()> {
645        let t1 = test_table_scan_with_name("t1")?;
646        let t2 = test_table_scan_with_name("t2")?;
647
648        // Full join with IN filters on both sides should become Inner
649        let plan = LogicalPlanBuilder::from(t1)
650            .join(
651                t2,
652                JoinType::Full,
653                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
654                None,
655            )?
656            .filter(binary_expr(
657                col("t1.b").in_list(vec![lit(1u32), lit(2u32)], false),
658                And,
659                col("t2.b").in_list(vec![lit(3u32), lit(4u32)], false),
660            ))?
661            .build()?;
662
663        assert_optimized_plan_equal!(plan, @r"
664        Filter: t1.b IN ([UInt32(1), UInt32(2)]) AND t2.b IN ([UInt32(3), UInt32(4)])
665          Inner Join: t1.a = t2.a
666            TableScan: t1
667            TableScan: t2
668        ")
669    }
670
671    #[test]
672    fn no_eliminate_left_with_in_list_or_is_null() -> Result<()> {
673        let t1 = test_table_scan_with_name("t1")?;
674        let t2 = test_table_scan_with_name("t2")?;
675
676        // WHERE (t2.b IN (1, 2)) OR (t2.b IS NULL)
677        // The OR with IS NULL makes the predicate null-tolerant:
678        // when t2.b is NULL, IS NULL returns true, so the whole OR is true.
679        // The outer join must be preserved.
680        let plan = LogicalPlanBuilder::from(t1)
681            .join(
682                t2,
683                JoinType::Left,
684                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
685                None,
686            )?
687            .filter(binary_expr(
688                col("t2.b").in_list(vec![lit(1u32), lit(2u32)], false),
689                Or,
690                col("t2.b").is_null(),
691            ))?
692            .build()?;
693
694        // Should NOT be converted to Inner — OR with IS NULL preserves null rows
695        assert_optimized_plan_equal!(plan, @r"
696        Filter: t2.b IN ([UInt32(1), UInt32(2)]) OR t2.b IS NULL
697          Left Join: t1.a = t2.a
698            TableScan: t1
699            TableScan: t2
700        ")
701    }
702
703    #[test]
704    fn eliminate_left_with_like() -> Result<()> {
705        let t1 = test_table_scan_with_name("t1")?;
706        let t2 = test_table_scan_with_name("t2")?;
707
708        // LIKE rejects nulls: if t2.b is NULL, the result is NULL (filtered out)
709        let plan = LogicalPlanBuilder::from(t1)
710            .join(
711                t2,
712                JoinType::Left,
713                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
714                None,
715            )?
716            .filter(col("t2.b").like(lit("%pattern%")))?
717            .build()?;
718
719        assert_optimized_plan_equal!(plan, @r#"
720        Filter: t2.b LIKE Utf8("%pattern%")
721          Inner Join: t1.a = t2.a
722            TableScan: t1
723            TableScan: t2
724        "#)
725    }
726
727    #[test]
728    fn eliminate_left_with_like_pattern_column() -> Result<()> {
729        let t1 = test_table_scan_with_name("t1")?;
730        let t2 = test_table_scan_with_name("t2")?;
731
732        // LIKE with nullable column on the pattern side:
733        // 'x' LIKE t2.b → if t2.b is NULL, result is NULL (filtered out)
734        let plan = LogicalPlanBuilder::from(t1)
735            .join(
736                t2,
737                JoinType::Left,
738                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
739                None,
740            )?
741            .filter(lit("x").like(col("t2.b")))?
742            .build()?;
743
744        assert_optimized_plan_equal!(plan, @r#"
745        Filter: Utf8("x") LIKE t2.b
746          Inner Join: t1.a = t2.a
747            TableScan: t1
748            TableScan: t2
749        "#)
750    }
751
752    #[test]
753    fn eliminate_full_with_like_cross_side() -> Result<()> {
754        let t1 = test_table_scan_with_name("t1")?;
755        let t2 = test_table_scan_with_name("t2")?;
756
757        // LIKE with columns from both sides: t1.c LIKE t2.b
758        // If t1 is NULL → NULL LIKE t2.b → NULL → filtered out (left non-nullable)
759        // If t2 is NULL → t1.c LIKE NULL → NULL → filtered out (right non-nullable)
760        // Both sides are non-nullable → FULL → INNER
761        let plan = LogicalPlanBuilder::from(t1)
762            .join(
763                t2,
764                JoinType::Full,
765                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
766                None,
767            )?
768            .filter(col("t1.c").like(col("t2.b")))?
769            .build()?;
770
771        assert_optimized_plan_equal!(plan, @r"
772        Filter: t1.c LIKE t2.b
773          Inner Join: t1.a = t2.a
774            TableScan: t1
775            TableScan: t2
776        ")
777    }
778
779    #[test]
780    fn eliminate_left_with_is_true() -> Result<()> {
781        let t1 = test_table_scan_with_name("t1")?;
782        let t2 = test_table_scan_with_name("t2")?;
783
784        // IS TRUE rejects nulls: if the expression is NULL, IS TRUE returns false
785        let plan = LogicalPlanBuilder::from(t1)
786            .join(
787                t2,
788                JoinType::Left,
789                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
790                None,
791            )?
792            .filter(col("t2.b").gt(lit(10u32)).is_true())?
793            .build()?;
794
795        assert_optimized_plan_equal!(plan, @r"
796        Filter: t2.b > UInt32(10) IS TRUE
797          Inner Join: t1.a = t2.a
798            TableScan: t1
799            TableScan: t2
800        ")
801    }
802
803    #[test]
804    fn eliminate_left_with_is_false() -> Result<()> {
805        let t1 = test_table_scan_with_name("t1")?;
806        let t2 = test_table_scan_with_name("t2")?;
807
808        // IS FALSE rejects nulls: if the expression is NULL, IS FALSE returns false
809        let plan = LogicalPlanBuilder::from(t1)
810            .join(
811                t2,
812                JoinType::Left,
813                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
814                None,
815            )?
816            .filter(col("t2.b").gt(lit(10u32)).is_false())?
817            .build()?;
818
819        assert_optimized_plan_equal!(plan, @r"
820        Filter: t2.b > UInt32(10) IS FALSE
821          Inner Join: t1.a = t2.a
822            TableScan: t1
823            TableScan: t2
824        ")
825    }
826
827    #[test]
828    fn eliminate_left_with_is_not_unknown() -> Result<()> {
829        let t1 = test_table_scan_with_name("t1")?;
830        let t2 = test_table_scan_with_name("t2")?;
831
832        // IS NOT UNKNOWN rejects nulls: if the expression is NULL, IS NOT UNKNOWN returns false
833        let plan = LogicalPlanBuilder::from(t1)
834            .join(
835                t2,
836                JoinType::Left,
837                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
838                None,
839            )?
840            .filter(col("t2.b").gt(lit(10u32)).is_not_unknown())?
841            .build()?;
842
843        assert_optimized_plan_equal!(plan, @r"
844        Filter: t2.b > UInt32(10) IS NOT UNKNOWN
845          Inner Join: t1.a = t2.a
846            TableScan: t1
847            TableScan: t2
848        ")
849    }
850
851    #[test]
852    fn no_eliminate_left_with_is_not_true() -> Result<()> {
853        let t1 = test_table_scan_with_name("t1")?;
854        let t2 = test_table_scan_with_name("t2")?;
855
856        // IS NOT TRUE is NOT null-rejecting: if the expression is NULL,
857        // IS NOT TRUE returns true, so null rows pass through
858        let plan = LogicalPlanBuilder::from(t1)
859            .join(
860                t2,
861                JoinType::Left,
862                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
863                None,
864            )?
865            .filter(col("t2.b").gt(lit(10u32)).is_not_true())?
866            .build()?;
867
868        assert_optimized_plan_equal!(plan, @r"
869        Filter: t2.b > UInt32(10) IS NOT TRUE
870          Left Join: t1.a = t2.a
871            TableScan: t1
872            TableScan: t2
873        ")
874    }
875
876    #[test]
877    fn no_eliminate_left_with_is_unknown() -> Result<()> {
878        let t1 = test_table_scan_with_name("t1")?;
879        let t2 = test_table_scan_with_name("t2")?;
880
881        // IS UNKNOWN is NOT null-rejecting: if the expression is NULL,
882        // IS UNKNOWN returns true, so null rows pass through
883        let plan = LogicalPlanBuilder::from(t1)
884            .join(
885                t2,
886                JoinType::Left,
887                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
888                None,
889            )?
890            .filter(col("t2.b").gt(lit(10u32)).is_unknown())?
891            .build()?;
892
893        assert_optimized_plan_equal!(plan, @r"
894        Filter: t2.b > UInt32(10) IS UNKNOWN
895          Left Join: t1.a = t2.a
896            TableScan: t1
897            TableScan: t2
898        ")
899    }
900
901    #[test]
902    fn no_eliminate_left_with_not_is_true() -> Result<()> {
903        let t1 = test_table_scan_with_name("t1")?;
904        let t2 = test_table_scan_with_name("t2")?;
905
906        // NOT(<x> IS TRUE) is equivalent to (<x> IS NOT TRUE): TRUE when
907        // <x> is FALSE OR NULL. So `WHERE NOT((t2.b > 5) IS TRUE)` accepts
908        // rows where t2.b is NULL (because t2.b > 5 is NULL → IS TRUE is
909        // FALSE → NOT FALSE = TRUE). The LEFT JOIN must NOT be converted.
910        let plan = LogicalPlanBuilder::from(t1)
911            .join(
912                t2,
913                JoinType::Left,
914                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
915                None,
916            )?
917            .filter(not(col("t2.b").gt(lit(5u32)).is_true()))?
918            .build()?;
919
920        assert_optimized_plan_equal!(plan, @r"
921        Filter: NOT t2.b > UInt32(5) IS TRUE
922          Left Join: t1.a = t2.a
923            TableScan: t1
924            TableScan: t2
925        ")
926    }
927
928    #[test]
929    fn no_eliminate_left_with_not_is_false() -> Result<()> {
930        let t1 = test_table_scan_with_name("t1")?;
931        let t2 = test_table_scan_with_name("t2")?;
932
933        // Same shape, IS FALSE: NOT(<x> IS FALSE) accepts NULL on the
934        // inner column.
935        let plan = LogicalPlanBuilder::from(t1)
936            .join(
937                t2,
938                JoinType::Left,
939                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
940                None,
941            )?
942            .filter(not(col("t2.b").gt(lit(5u32)).is_false()))?
943            .build()?;
944
945        assert_optimized_plan_equal!(plan, @r"
946        Filter: NOT t2.b > UInt32(5) IS FALSE
947          Left Join: t1.a = t2.a
948            TableScan: t1
949            TableScan: t2
950        ")
951    }
952
953    #[test]
954    fn no_eliminate_left_with_not_is_not_unknown() -> Result<()> {
955        let t1 = test_table_scan_with_name("t1")?;
956        let t2 = test_table_scan_with_name("t2")?;
957
958        // Same shape, IS NOT UNKNOWN: NOT(<x> IS NOT UNKNOWN) is
959        // equivalent to (<x> IS UNKNOWN), which is TRUE when <x> is NULL.
960        let plan = LogicalPlanBuilder::from(t1)
961            .join(
962                t2,
963                JoinType::Left,
964                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
965                None,
966            )?
967            .filter(not(col("t2.b").gt(lit(5u32)).is_not_unknown()))?
968            .build()?;
969
970        assert_optimized_plan_equal!(plan, @r"
971        Filter: NOT t2.b > UInt32(5) IS NOT UNKNOWN
972          Left Join: t1.a = t2.a
973            TableScan: t1
974            TableScan: t2
975        ")
976    }
977
978    #[test]
979    fn eliminate_full_with_type_cast() -> Result<()> {
980        let t1 = test_table_scan_with_name("t1")?;
981        let t2 = test_table_scan_with_name("t2")?;
982
983        // eliminate to inner join
984        let plan = LogicalPlanBuilder::from(t1)
985            .join(
986                t2,
987                JoinType::Full,
988                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
989                None,
990            )?
991            .filter(binary_expr(
992                cast(col("t1.b"), DataType::Int64).gt(lit(10u32)),
993                And,
994                try_cast(col("t2.c"), DataType::Int64).lt(lit(20u32)),
995            ))?
996            .build()?;
997
998        assert_optimized_plan_equal!(plan, @r"
999        Filter: CAST(t1.b AS Int64) > UInt32(10) AND TRY_CAST(t2.c AS Int64) < UInt32(20)
1000          Inner Join: t1.a = t2.a
1001            TableScan: t1
1002            TableScan: t2
1003        ")
1004    }
1005
1006    // ----- FULL JOIN → LEFT / RIGHT tests -----
1007    #[test]
1008    fn eliminate_full_to_left_with_left_filter() -> Result<()> {
1009        let t1 = test_table_scan_with_name("t1")?;
1010        let t2 = test_table_scan_with_name("t2")?;
1011
1012        // FULL JOIN with null-rejecting filter only on left side → LEFT JOIN
1013        // (left side becomes non-nullable, right side stays nullable)
1014        let plan = LogicalPlanBuilder::from(t1)
1015            .join(
1016                t2,
1017                JoinType::Full,
1018                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1019                None,
1020            )?
1021            .filter(col("t1.b").gt(lit(10u32)))?
1022            .build()?;
1023
1024        assert_optimized_plan_equal!(plan, @r"
1025        Filter: t1.b > UInt32(10)
1026          Left Join: t1.a = t2.a
1027            TableScan: t1
1028            TableScan: t2
1029        ")
1030    }
1031
1032    #[test]
1033    fn eliminate_full_to_right_with_right_filter() -> Result<()> {
1034        let t1 = test_table_scan_with_name("t1")?;
1035        let t2 = test_table_scan_with_name("t2")?;
1036
1037        // FULL JOIN with null-rejecting filter only on right side → RIGHT JOIN
1038        let plan = LogicalPlanBuilder::from(t1)
1039            .join(
1040                t2,
1041                JoinType::Full,
1042                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1043                None,
1044            )?
1045            .filter(col("t2.b").in_list(vec![lit(1u32), lit(2u32)], false))?
1046            .build()?;
1047
1048        assert_optimized_plan_equal!(plan, @r"
1049        Filter: t2.b IN ([UInt32(1), UInt32(2)])
1050          Right Join: t1.a = t2.a
1051            TableScan: t1
1052            TableScan: t2
1053        ")
1054    }
1055
1056    #[test]
1057    fn eliminate_full_to_left_with_like() -> Result<()> {
1058        let t1 = test_table_scan_with_name("t1")?;
1059        let t2 = test_table_scan_with_name("t2")?;
1060
1061        // FULL JOIN with LIKE on left side only → LEFT JOIN
1062        let plan = LogicalPlanBuilder::from(t1)
1063            .join(
1064                t2,
1065                JoinType::Full,
1066                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1067                None,
1068            )?
1069            .filter(col("t1.b").like(lit("%val%")))?
1070            .build()?;
1071
1072        assert_optimized_plan_equal!(plan, @r#"
1073        Filter: t1.b LIKE Utf8("%val%")
1074          Left Join: t1.a = t2.a
1075            TableScan: t1
1076            TableScan: t2
1077        "#)
1078    }
1079
1080    #[test]
1081    fn eliminate_full_to_right_with_is_true() -> Result<()> {
1082        let t1 = test_table_scan_with_name("t1")?;
1083        let t2 = test_table_scan_with_name("t2")?;
1084
1085        // FULL JOIN with IS TRUE on right side only → RIGHT JOIN
1086        let plan = LogicalPlanBuilder::from(t1)
1087            .join(
1088                t2,
1089                JoinType::Full,
1090                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1091                None,
1092            )?
1093            .filter(col("t2.b").gt(lit(10u32)).is_true())?
1094            .build()?;
1095
1096        assert_optimized_plan_equal!(plan, @r"
1097        Filter: t2.b > UInt32(10) IS TRUE
1098          Right Join: t1.a = t2.a
1099            TableScan: t1
1100            TableScan: t2
1101        ")
1102    }
1103
1104    // ----- Nested AND / OR tests -----
1105
1106    #[test]
1107    fn eliminate_left_with_and_multiple_null_rejecting() -> Result<()> {
1108        let t1 = test_table_scan_with_name("t1")?;
1109        let t2 = test_table_scan_with_name("t2")?;
1110
1111        // Multiple null-rejecting predicates combined with AND on nullable side
1112        let plan = LogicalPlanBuilder::from(t1)
1113            .join(
1114                t2,
1115                JoinType::Left,
1116                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1117                None,
1118            )?
1119            .filter(binary_expr(
1120                col("t2.b").in_list(vec![lit(1u32), lit(2u32)], false),
1121                And,
1122                col("t2.c").between(lit(5u32), lit(20u32)),
1123            ))?
1124            .build()?;
1125
1126        assert_optimized_plan_equal!(plan, @r"
1127        Filter: t2.b IN ([UInt32(1), UInt32(2)]) AND t2.c BETWEEN UInt32(5) AND UInt32(20)
1128          Inner Join: t1.a = t2.a
1129            TableScan: t1
1130            TableScan: t2
1131        ")
1132    }
1133
1134    #[test]
1135    fn eliminate_left_with_or_same_side() -> Result<()> {
1136        let t1 = test_table_scan_with_name("t1")?;
1137        let t2 = test_table_scan_with_name("t2")?;
1138
1139        // OR of two null-rejecting predicates on different columns of the same
1140        // nullable side. If t2 rows are NULL (from LEFT JOIN), both t2.b and
1141        // t2.c are NULL, so the entire OR evaluates to NULL → filtered out.
1142        // This IS null-rejecting, so join should be eliminated.
1143        let plan = LogicalPlanBuilder::from(t1)
1144            .join(
1145                t2,
1146                JoinType::Left,
1147                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1148                None,
1149            )?
1150            .filter(binary_expr(
1151                col("t2.b").gt(lit(10u32)),
1152                Or,
1153                col("t2.c").lt(lit(20u32)),
1154            ))?
1155            .build()?;
1156
1157        assert_optimized_plan_equal!(plan, @r"
1158        Filter: t2.b > UInt32(10) OR t2.c < UInt32(20)
1159          Inner Join: t1.a = t2.a
1160            TableScan: t1
1161            TableScan: t2
1162        ")
1163    }
1164
1165    #[test]
1166    fn no_eliminate_left_with_or_cross_side() -> Result<()> {
1167        let t1 = test_table_scan_with_name("t1")?;
1168        let t2 = test_table_scan_with_name("t2")?;
1169
1170        // OR with columns from different sides — t1.b (preserved) OR t2.b
1171        // (nullable). When t2 is NULL, t1.b > 10 can still be true, so the
1172        // OR is NOT null-rejecting. Join must be preserved.
1173        let plan = LogicalPlanBuilder::from(t1)
1174            .join(
1175                t2,
1176                JoinType::Left,
1177                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1178                None,
1179            )?
1180            .filter(binary_expr(
1181                col("t1.b").gt(lit(10u32)),
1182                Or,
1183                col("t2.b").lt(lit(20u32)),
1184            ))?
1185            .build()?;
1186
1187        assert_optimized_plan_equal!(plan, @r"
1188        Filter: t1.b > UInt32(10) OR t2.b < UInt32(20)
1189          Left Join: t1.a = t2.a
1190            TableScan: t1
1191            TableScan: t2
1192        ")
1193    }
1194
1195    // ----- Mixed predicate tests -----
1196
1197    #[test]
1198    fn eliminate_full_with_mixed_predicates() -> Result<()> {
1199        let t1 = test_table_scan_with_name("t1")?;
1200        let t2 = test_table_scan_with_name("t2")?;
1201
1202        // FULL JOIN with different null-rejecting expr types on each side:
1203        // LIKE on left, BETWEEN on right → INNER JOIN
1204        let plan = LogicalPlanBuilder::from(t1)
1205            .join(
1206                t2,
1207                JoinType::Full,
1208                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1209                None,
1210            )?
1211            .filter(binary_expr(
1212                col("t1.b").like(lit("%pattern%")),
1213                And,
1214                col("t2.b").between(lit(1u32), lit(10u32)),
1215            ))?
1216            .build()?;
1217
1218        assert_optimized_plan_equal!(plan, @r#"
1219        Filter: t1.b LIKE Utf8("%pattern%") AND t2.b BETWEEN UInt32(1) AND UInt32(10)
1220          Inner Join: t1.a = t2.a
1221            TableScan: t1
1222            TableScan: t2
1223        "#)
1224    }
1225
1226    #[test]
1227    fn eliminate_left_with_is_true_and_in_list() -> Result<()> {
1228        let t1 = test_table_scan_with_name("t1")?;
1229        let t2 = test_table_scan_with_name("t2")?;
1230
1231        // AND of IS TRUE and IN on nullable side — both null-rejecting
1232        let plan = LogicalPlanBuilder::from(t1)
1233            .join(
1234                t2,
1235                JoinType::Left,
1236                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1237                None,
1238            )?
1239            .filter(binary_expr(
1240                col("t2.b").gt(lit(5u32)).is_true(),
1241                And,
1242                col("t2.c").in_list(vec![lit(1u32), lit(2u32)], false),
1243            ))?
1244            .build()?;
1245
1246        assert_optimized_plan_equal!(plan, @r"
1247        Filter: t2.b > UInt32(5) IS TRUE AND t2.c IN ([UInt32(1), UInt32(2)])
1248          Inner Join: t1.a = t2.a
1249            TableScan: t1
1250            TableScan: t2
1251        ")
1252    }
1253
1254    #[test]
1255    fn eliminate_left_with_arithmetic_predicate() -> Result<()> {
1256        let t1 = test_table_scan_with_name("t1")?;
1257        let t2 = test_table_scan_with_name("t2")?;
1258
1259        // t2.b * 2 + 1 > 10 is null-rejecting on t2.b: arithmetic
1260        // operators propagate NULL, so the whole expression is NULL when
1261        // t2.b is NULL, and NULL > 10 is filtered out by WHERE.
1262        let plan = LogicalPlanBuilder::from(t1)
1263            .join(
1264                t2,
1265                JoinType::Left,
1266                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1267                None,
1268            )?
1269            .filter(
1270                binary_expr(
1271                    binary_expr(col("t2.b"), Operator::Multiply, lit(2u32)),
1272                    Operator::Plus,
1273                    lit(1u32),
1274                )
1275                .gt(lit(10u32)),
1276            )?
1277            .build()?;
1278
1279        assert_optimized_plan_equal!(plan, @r"
1280        Filter: t2.b * UInt32(2) + UInt32(1) > UInt32(10)
1281          Inner Join: t1.a = t2.a
1282            TableScan: t1
1283            TableScan: t2
1284        ")
1285    }
1286
1287    #[test]
1288    fn eliminate_left_with_negative_predicate() -> Result<()> {
1289        let t1 = test_table_scan_with_name("t1")?;
1290        let t2 = test_table_scan_with_name("t2")?;
1291
1292        // Unary minus propagates NULL: -NULL is NULL, so `WHERE -t2.b > 0`
1293        // is null-rejecting on t2.b.
1294        let plan = LogicalPlanBuilder::from(t1)
1295            .join(
1296                t2,
1297                JoinType::Left,
1298                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1299                None,
1300            )?
1301            .filter(Expr::Negative(Box::new(col("t2.b"))).gt(lit(0u32)))?
1302            .build()?;
1303
1304        assert_optimized_plan_equal!(plan, @r"
1305        Filter: (- t2.b) > UInt32(0)
1306          Inner Join: t1.a = t2.a
1307            TableScan: t1
1308            TableScan: t2
1309        ")
1310    }
1311
1312    #[test]
1313    fn no_eliminate_left_with_is_distinct_from() -> Result<()> {
1314        let t1 = test_table_scan_with_name("t1")?;
1315        let t2 = test_table_scan_with_name("t2")?;
1316
1317        // IS DISTINCT FROM is NOT null-rejecting: t2.b IS DISTINCT FROM 5 is
1318        // true when t2.b is NULL (NULL is distinct from 5). Padding rows from
1319        // a LEFT JOIN would survive the filter, so the LEFT JOIN must stay.
1320        let plan = LogicalPlanBuilder::from(t1)
1321            .join(
1322                t2,
1323                JoinType::Left,
1324                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1325                None,
1326            )?
1327            .filter(binary_expr(
1328                col("t2.b"),
1329                Operator::IsDistinctFrom,
1330                lit(5u32),
1331            ))?
1332            .build()?;
1333
1334        assert_optimized_plan_equal!(plan, @r"
1335        Filter: t2.b IS DISTINCT FROM UInt32(5)
1336          Left Join: t1.a = t2.a
1337            TableScan: t1
1338            TableScan: t2
1339        ")
1340    }
1341
1342    #[test]
1343    fn no_eliminate_left_with_is_not_distinct_from() -> Result<()> {
1344        let t1 = test_table_scan_with_name("t1")?;
1345        let t2 = test_table_scan_with_name("t2")?;
1346
1347        // IS NOT DISTINCT FROM is also not null-rejecting: t2.b IS NOT
1348        // DISTINCT FROM NULL is true when t2.b is NULL. The LEFT JOIN must
1349        // stay.
1350        let plan = LogicalPlanBuilder::from(t1)
1351            .join(
1352                t2,
1353                JoinType::Left,
1354                (vec![Column::from_name("a")], vec![Column::from_name("a")]),
1355                None,
1356            )?
1357            .filter(binary_expr(
1358                col("t2.b"),
1359                Operator::IsNotDistinctFrom,
1360                lit(ScalarValue::UInt32(None)),
1361            ))?
1362            .build()?;
1363
1364        assert_optimized_plan_equal!(plan, @r"
1365        Filter: t2.b IS NOT DISTINCT FROM UInt32(NULL)
1366          Left Join: t1.a = t2.a
1367            TableScan: t1
1368            TableScan: t2
1369        ")
1370    }
1371}