Skip to main content

krishiv_sql/
semi_join_reduction.rs

1//! Semi-join reduction through an aggregate.
2//!
3//! When a grouped aggregate is inner-joined on one of its own grouping keys,
4//! only the groups whose key survives the join can appear in the result. Every
5//! other group is computed and then discarded. Filtering the aggregate's
6//! *input* down to the surviving keys first produces exactly the same groups,
7//! because an aggregate value depends only on the rows sharing its key.
8//!
9//! # The query that motivated this
10//!
11//! TPC-H q17 decorrelates to this shape:
12//!
13//! ```text
14//! Inner Join: part.p_partkey = __scalar_sq_1.l_partkey
15//!   ├── Inner Join: lineitem.l_partkey = part.p_partkey
16//!   │     └── Filter: p_brand = 'Brand#23' AND p_container = 'MED BOX'
17//!   └── __scalar_sq_1:
18//!         Aggregate: groupBy=[l_partkey], aggr=[avg(l_quantity)]
19//!           TableScan: lineitem
20//! ```
21//!
22//! At SF100 that aggregate groups all 600M lineitem rows into ~20M groups, and
23//! the join then keeps the ~2000 partkeys matching the brand and container —
24//! four orders of magnitude of thrown-away work. Measured with
25//! `explain --analyze`, it was 221.03 s of a 252 s query, 88% of all compute,
26//! with `spill_count=0`: not a memory problem, just work that need not happen.
27//!
28//! DataFusion's dynamic filter does not help here, and it is worth recording
29//! why, because the plan *looks* like it should:
30//!
31//! ```text
32//! DynamicFilter [ ... l_partkey >= 7682 AND l_partkey <= 19999654 AND hash_lookup ... ]
33//! ```
34//!
35//! The min/max bounds span essentially the whole key domain, since the 2000
36//! surviving partkeys are scattered uniformly across it, so row-group pruning
37//! removes nothing. And the filter belongs to the *join*, which sits downstream
38//! of the aggregate — no amount of selectivity there can reduce what the
39//! aggregate already had to read.
40//!
41//! # What this rule does
42//!
43//! It rewrites the aggregate's input to a `LeftSemi` join against the smallest
44//! subtree of the other side that still produces the join key *and* contains a
45//! filter:
46//!
47//! ```text
48//! Aggregate: groupBy=[l_partkey], aggr=[avg(l_quantity)]
49//!   LeftSemi Join: lineitem.l_partkey = part.p_partkey
50//!     TableScan: lineitem
51//!     Projection: part.p_partkey
52//!       Filter: p_brand = 'Brand#23' AND p_container = 'MED BOX'
53//! ```
54//!
55//! # Why it is safe
56//!
57//! - **Inner joins only.** Under a left/right/full join the unmatched rows are
58//!   preserved, so dropping groups would change the result. Anti/semi joins are
59//!   also excluded — they have their own null semantics.
60//! - **The key must be a grouping column**, matched by *schema position* rather
61//!   than by name, so requalification through `SubqueryAlias` and projections
62//!   cannot silently pair the wrong columns.
63//! - **Aggregate values are unchanged.** Removing rows whose key is not in the
64//!   probe side removes whole groups; it never removes part of a surviving
65//!   group, so no aggregate is computed over a different row set.
66//! - **Nulls agree.** A null key never satisfies an equi-join, so a null group
67//!   would be dropped by the original join anyway; `LeftSemi` drops it too.
68//! - **No duplication.** `LeftSemi` emits each left row at most once regardless
69//!   of how many probe rows match, so counts and sums cannot inflate.
70//!
71//! # Why it is guarded
72//!
73//! The probe subtree is evaluated a second time, so the rule only fires when
74//! that subtree contains a `Filter` — evidence there is real selectivity to
75//! exploit. Against an unfiltered scan the semi-join would remove nothing and
76//! we would have paid for the extra pass. Descent also stops *at* the filter
77//! rather than continuing to the scan beneath it, which is what keeps the probe
78//! small (~2000 rows in q17 rather than the whole `part` table).
79//!
80//! Set `KRISHIV_SEMI_JOIN_REDUCTION=off` to disable.
81
82use datafusion::common::tree_node::Transformed;
83use datafusion::common::{Column, DFSchema, NullEquality, Result};
84use datafusion::logical_expr::{
85    Aggregate, Expr, Join, JoinType, LogicalPlan, LogicalPlanBuilder, Projection, SubqueryAlias,
86};
87use datafusion::optimizer::{ApplyOrder, OptimizerConfig, OptimizerRule};
88use std::sync::Arc;
89
90/// Environment switch for reduction *through an aggregate* (the q17 rule).
91pub const SEMI_JOIN_REDUCTION_ENV: &str = "KRISHIV_SEMI_JOIN_REDUCTION";
92
93/// Environment switch for pushdown *through an inner join* (the q18 rule).
94///
95/// # Why this is a separate switch, and why it defaults ON
96///
97/// One variable used to gate both rules, so neither could be measured alone.
98/// Split so they can be. The pushdown rule was then briefly defaulted **off**,
99/// on the strength of a stage-shape proxy: counting stages that collapse to a
100/// single output partition, with it on versus off,
101///
102/// ```text
103///   q2   5 -> 1     q21  3 -> 0     q17  3 -> 2     q18  0 -> 0
104/// ```
105///
106/// which read as "worse on three, neutral on q18 — the query it was written
107/// for". **That conclusion was wrong, and the measurement that refuted it is
108/// the only one that counts:**
109///
110/// ```text
111///   q18 SF100, rule off : FAILED — Resources exhausted, ExternalSorter could
112///                         not get its first 2.1 MB of a 2.6 GB pool
113///   q18 SF100, rule on  : 424.3 s, succeeded (and 2.33x faster than the
114///                         987 s it took before this session)
115/// ```
116///
117/// Neutral on stage shape is not neutral on memory. With the rule off, q18's
118/// most selective predicate — ~570 surviving orders out of 150M — runs above
119/// the whole four-way join, so the joins carry the full customer/orders/
120/// lineitem cross-section and there is nothing left in the pool for the sort.
121/// The rule is what makes the query fit at all.
122///
123/// So: on by default. `KRISHIV_SEMI_JOIN_PUSHDOWN=off` disables it.
124pub const SEMI_JOIN_PUSHDOWN_ENV: &str = "KRISHIV_SEMI_JOIN_PUSHDOWN";
125
126/// Environment switch for reduction *from a selective dimension* (the q7 rule).
127///
128/// # OFF by default, and the measurement that made it so
129///
130/// It shipped on, was measured across all 22 SF100 queries, and is a large win
131/// on the query it was written for and a **much larger loss on two others**:
132///
133/// ```text
134///        rule on     rule off
135///   q7    118.6 s     ~340 s      2.9x FASTER   (paired A/B median 0.498)
136///   q8    422.6 s      95.4 s     4.4x slower
137///   q10  1997.1 s     110.7 s    18.1x SLOWER
138/// ```
139///
140/// The A/B that cleared it covered q2, q17 and q18 — chosen because *those*
141/// were the queries this rule family had regressed before. It missed q8 and
142/// q10, and only the full sweep caught them. **Picking a regression set from
143/// the last incident is picking the queries you already know about.**
144///
145/// # Why, and why the fix is not a tweak here
146///
147/// The guard asks only whether the dimension side carries a `Filter` — never
148/// whether it is *small*. In q7 that filter sits on `nation`, 25 rows. In q10
149/// the same test passes for `orders` filtered to a 3-month window (~11M rows)
150/// and `lineitem` filtered by `l_returnflag` (~150M), so the rule attaches a
151/// whole extra join instead of a cheap reducer.
152///
153/// The missing discriminator is dimension size, and **it is not available
154/// where this rule lives**: `TableSource` in DF 54 exposes `schema`,
155/// `constraints`, `table_type` and pushdown support — no `statistics()`. A
156/// logical rule cannot tell 25 rows from 150 million.
157///
158/// So the rule belongs at the *physical* level, beside
159/// `distributed_plan::redistribute_unsplittable_broadcast_joins`, where
160/// `partition_statistics()` is what `broadcast_build_estimate_is_empty` and
161/// `broadcast_build_is_too_wide` already read. Until it is moved there this
162/// stays off, and the q7 win stays available to anyone who opts in knowing the
163/// shape their queries have.
164///
165/// `KRISHIV_SEMI_JOIN_DIMENSION=on` enables it.
166pub const SEMI_JOIN_DIMENSION_ENV: &str = "KRISHIV_SEMI_JOIN_DIMENSION";
167
168/// Whether reduction from a selective dimension is enabled (default: **no**).
169pub fn semi_join_dimension_reduction_enabled() -> bool {
170    // Still under the umbrella switch, so turning that off disables all three.
171    semi_join_reduction_enabled()
172        && opt_in_from(&std::env::var(SEMI_JOIN_DIMENSION_ENV).unwrap_or_default())
173}
174
175/// Opt-*in* parsing: anything but an explicit yes is off.
176///
177/// The mirror of [`enabled_from`], kept separate rather than parameterised so
178/// that reading either call site tells you the default without following a
179/// boolean argument.
180fn opt_in_from(value: &str) -> bool {
181    matches!(
182        value.trim().to_ascii_lowercase().as_str(),
183        "1" | "on" | "true" | "yes"
184    )
185}
186
187/// Whether semi-join reduction through aggregates is enabled (default: yes).
188pub fn semi_join_reduction_enabled() -> bool {
189    enabled_from(&std::env::var(SEMI_JOIN_REDUCTION_ENV).unwrap_or_default())
190}
191
192/// Whether semi-join pushdown through an inner join is enabled (default: yes).
193///
194/// See [`SEMI_JOIN_PUSHDOWN_ENV`] for why the default is on, and for the one
195/// revision where it was not.
196pub fn semi_join_pushdown_enabled() -> bool {
197    // Still gated by the umbrella switch, so turning that off disables both.
198    semi_join_reduction_enabled()
199        && enabled_from(&std::env::var(SEMI_JOIN_PUSHDOWN_ENV).unwrap_or_default())
200}
201
202/// The switch's parsing, separated from reading the environment.
203///
204/// Kept pure so it can be tested directly: mutating process environment from a
205/// test is unsound under a multi-threaded test runner, and the workspace denies
206/// the `unsafe` that edition 2024 now requires for `set_var`.
207fn enabled_from(value: &str) -> bool {
208    !matches!(
209        value.trim().to_ascii_lowercase().as_str(),
210        "0" | "off" | "false" | "no"
211    )
212}
213
214/// Push an existing semi-join down through an inner join, so the selective
215/// side filters one join input instead of the join's output.
216///
217/// # The query that motivated this
218///
219/// TPC-H q18's `o_orderkey IN (SELECT l_orderkey … HAVING sum(l_quantity) > 300)`
220/// decorrelates to a semi-join, and DataFusion leaves it at the very top:
221///
222/// ```text
223/// HashJoin [RightSemi] on (l_orderkey, o_orderkey)      300.92 s
224///   Filter: sum(l_quantity) > 300                        <- keeps ~570 of 150M orders
225///     Aggregate: groupBy=[l_orderkey]
226///   HashJoin [Inner] on (o_orderkey, l_orderkey)         764.03 s  <- all 600M rows
227///     HashJoin [Inner] on (c_custkey, o_custkey)          68.07 s
228/// ```
229///
230/// Measured at SF100 the joins are 82.9% of the query and the aggregate only
231/// 16.9%, so this is a join-ordering problem, not an aggregation one. The most
232/// selective predicate in the whole query — 570 surviving orders out of 150M —
233/// executes *last*, after the 764 s join has already materialised the full
234/// customer/orders/lineitem cross-section.
235///
236/// # The rewrite
237///
238/// For an inner join whose output feeds a semi- or anti-join keyed on columns
239/// from only one side:
240///
241/// ```text
242///   SemiJoin(Inner(A, B), S)  on A.k     ==>  Inner(SemiJoin(A, S) on A.k, B)
243///   AntiJoin(Inner(A, B), S)  on A.k     ==>  Inner(AntiJoin(A, S) on A.k, B)
244/// ```
245///
246/// # Anti joins and residual filters
247///
248/// Both were originally refused — anti joins as needing "their own reasoning",
249/// and any join carrying a residual `filter` because it "may reference both
250/// sides". Between them those two guards made the rule **inert on TPC-H q21**,
251/// whose `EXISTS`/`NOT EXISTS` produce exactly a semi *and* an anti join, each
252/// carrying `l_suppkey <> l_suppkey`. q21 was the slowest query in the SF100
253/// sweep at 4309 s against Spark's 391 s — the largest single loss of the 22 —
254/// with the most selective predicate in the query running above the whole
255/// four-way join.
256///
257/// The reasoning does carry over. For both kinds the existence test is a
258/// function of the filtered row and the probe alone, so a row of `Inner(A, B)`
259/// passes exactly when its `A` row passes. The residual is carried down and
260/// **remapped at each level** (see `remap_residual`) rather than refused, and
261/// re-attached only where every column it names resolves into the child being
262/// landed on or the probe.
263///
264/// # Why it is safe
265///
266/// - **The join below must be Inner.** An outer join null-pads its
267///   non-preserved side, so a key that is null after the join was not null
268///   before it, and filtering earlier would keep different rows.
269/// - **Every semi-join key must resolve into one side.** If the keys straddle
270///   `A` and `B`, the existence test genuinely depends on the joined row and
271///   cannot be evaluated before the join. The same test is applied to the
272///   residual's columns.
273/// - **Row multiplicity is preserved.** A semi-join emits each surviving row
274///   at most once and adds no columns, so `Inner(SemiJoin(A,S), B)` produces
275///   exactly the rows of `Inner(A,B)` whose `A.k` had a match — which is the
276///   definition of the original. Counts and sums downstream are unchanged.
277/// - **The output schema is identical.** Semi-joins project only their
278///   filtered side, so `A ⧺ B` in both forms, in the same order.
279///
280/// The outer semi-join is *replaced* rather than duplicated, so there is no
281/// fixed-point concern: after one application the top node is an inner join.
282#[derive(Debug, Default)]
283pub struct SemiJoinPushdownThroughInnerJoin {
284    /// Bypass [`semi_join_pushdown_enabled`] and always apply.
285    ///
286    /// The env switch cannot be exercised from a test: mutating process
287    /// environment is unsound under a multi-threaded runner and `set_var` is
288    /// unsafe since edition 2024, which this workspace denies. Without this
289    /// the rule's own tests would silently test nothing once the default
290    /// flipped to off — the exact failure mode the audit keeps finding.
291    forced: bool,
292}
293
294impl SemiJoinPushdownThroughInnerJoin {
295    /// The rule with its env gate bypassed, for tests and explicit opt-in.
296    pub fn forced() -> Self {
297        Self { forced: true }
298    }
299}
300
301impl OptimizerRule for SemiJoinPushdownThroughInnerJoin {
302    fn name(&self) -> &str {
303        "semi_join_pushdown_through_inner_join"
304    }
305
306    fn apply_order(&self) -> Option<ApplyOrder> {
307        // Top-down: the semi-join starts at the top of the plan, and pushing it
308        // through the outermost inner join first lets the next pass carry it
309        // further down the chain.
310        Some(ApplyOrder::TopDown)
311    }
312
313    fn rewrite(
314        &self,
315        plan: LogicalPlan,
316        _config: &dyn OptimizerConfig,
317    ) -> Result<Transformed<LogicalPlan>> {
318        if !self.forced && !semi_join_pushdown_enabled() {
319            return Ok(Transformed::no(plan));
320        }
321        let LogicalPlan::Join(semi) = &plan else {
322            return Ok(Transformed::no(plan));
323        };
324        // `filtered` is the side whose rows survive; `probe` only supplies the
325        // existence test.
326        //
327        // Anti joins ride along with semi joins. The earlier version excluded
328        // them, on the grounds that "not exists" needed its own reasoning — it
329        // does, and the reasoning comes out the same. For both kinds the test
330        // is a function of the filtered row and the probe alone, so a row of
331        // `Inner(A, B)` passes exactly when its `A` row passes; pushing the
332        // test onto `A` keeps the same rows, and semi/anti both emit each
333        // surviving row exactly once, so multiplicity through `B` is unchanged.
334        let filtered_is_right = match semi.join_type {
335            JoinType::LeftSemi | JoinType::LeftAnti => false,
336            JoinType::RightSemi | JoinType::RightAnti => true,
337            _ => return Ok(Transformed::no(plan)),
338        };
339        if semi.on.is_empty() {
340            return Ok(Transformed::no(plan));
341        }
342        let (filtered, probe) = if filtered_is_right {
343            (semi.right.as_ref(), semi.left.as_ref())
344        } else {
345            (semi.left.as_ref(), semi.right.as_ref())
346        };
347
348        // Pair each filtered-side key with its probe-side counterpart. Both must
349        // be plain columns: an expression could be computed from the joined row
350        // and so may not be evaluable before the join.
351        let mut pairs = Vec::with_capacity(semi.on.len());
352        for (l, r) in &semi.on {
353            let (Expr::Column(lc), Expr::Column(rc)) = (l, r) else {
354                return Ok(Transformed::no(plan));
355            };
356            pairs.push(if filtered_is_right {
357                (rc.clone(), lc.clone())
358            } else {
359                (lc.clone(), rc.clone())
360            });
361        }
362
363        match push_semi_below(
364            filtered,
365            &pairs,
366            probe,
367            filtered_is_right,
368            semi.filter.as_ref(),
369            semi.join_type,
370        )? {
371            Some(rewritten) => Ok(Transformed::yes(rewritten)),
372            None => Ok(Transformed::no(plan)),
373        }
374    }
375}
376
377/// Rewrite the residual filter's references to *this* level's columns into the
378/// level below, leaving probe-side columns untouched.
379///
380/// Returns `None` when some referenced column cannot be followed down (a
381/// computed projection expression, say), in which case the caller declines the
382/// whole rewrite. `Some(None)` means there was no residual to carry.
383///
384/// The pair keys are already remapped by schema position at each level; the
385/// residual has to make the same journey or it would reference names that no
386/// longer exist below. That mismatch is why the residual case was originally
387/// refused outright rather than remapped.
388fn remap_residual(
389    residual: Option<&Expr>,
390    schema: &DFSchema,
391    lower: &dyn Fn(usize) -> Option<Column>,
392) -> Option<Option<Expr>> {
393    use datafusion::common::tree_node::TreeNode;
394
395    // No residual is not a refusal — it is the common case.
396    let Some(expr) = residual.cloned() else {
397        return Some(None);
398    };
399    let mut unfollowable = false;
400    let rewritten = expr
401        .transform(|e| {
402            if let Expr::Column(c) = &e
403                && let Some(idx) = index_of(schema, c)
404            {
405                return match lower(idx) {
406                    Some(inner) => Ok(Transformed::yes(Expr::Column(inner))),
407                    None => {
408                        unfollowable = true;
409                        Ok(Transformed::no(e))
410                    }
411                };
412            }
413            Ok(Transformed::no(e))
414        })
415        .ok()?;
416    if unfollowable {
417        return None;
418    }
419    Some(Some(rewritten.data))
420}
421
422/// Carry a semi-join down to the inner join it should be filtering.
423///
424/// The planner rarely leaves the inner join as a direct child — in q18 a
425/// `Projection` sits between them, which is why matching only on an immediate
426/// `Join` child silently did nothing. Descend through the row-preserving nodes,
427/// remapping the keys at each one by schema position, and rebuild on the way
428/// back up.
429///
430/// `pairs` are `(key on this plan's side, matching key on the probe side)`.
431fn push_semi_below(
432    plan: &LogicalPlan,
433    pairs: &[(Column, Column)],
434    probe: &LogicalPlan,
435    filtered_is_right: bool,
436    residual: Option<&Expr>,
437    join_type: JoinType,
438) -> Result<Option<LogicalPlan>> {
439    match plan {
440        LogicalPlan::Projection(proj) => {
441            let mut mapped = Vec::with_capacity(pairs.len());
442            for (fk, pk) in pairs {
443                let Some(idx) = index_of(&proj.schema, fk) else {
444                    return Ok(None);
445                };
446                // Only a straight column pass-through is safe to follow.
447                let Some(Expr::Column(inner)) = proj.expr.get(idx) else {
448                    return Ok(None);
449                };
450                mapped.push((inner.clone(), pk.clone()));
451            }
452            let lower = |idx: usize| match proj.expr.get(idx) {
453                Some(Expr::Column(inner)) => Some(inner.clone()),
454                _ => None,
455            };
456            let Some(residual) = remap_residual(residual, &proj.schema, &lower) else {
457                return Ok(None);
458            };
459            let Some(new_input) = push_semi_below(
460                &proj.input,
461                &mapped,
462                probe,
463                filtered_is_right,
464                residual.as_ref(),
465                join_type,
466            )?
467            else {
468                return Ok(None);
469            };
470            Ok(Some(LogicalPlan::Projection(Projection::try_new(
471                proj.expr.clone(),
472                Arc::new(new_input),
473            )?)))
474        }
475        LogicalPlan::SubqueryAlias(alias) => {
476            let mut mapped = Vec::with_capacity(pairs.len());
477            for (fk, pk) in pairs {
478                let Some(idx) = index_of(&alias.schema, fk) else {
479                    return Ok(None);
480                };
481                let (qualifier, field) = alias.input.schema().qualified_field(idx);
482                mapped.push((Column::new(qualifier.cloned(), field.name()), pk.clone()));
483            }
484            let lower = |idx: usize| {
485                let (qualifier, field) = alias.input.schema().qualified_field(idx);
486                Some(Column::new(qualifier.cloned(), field.name()))
487            };
488            let Some(residual) = remap_residual(residual, &alias.schema, &lower) else {
489                return Ok(None);
490            };
491            let Some(new_input) = push_semi_below(
492                &alias.input,
493                &mapped,
494                probe,
495                filtered_is_right,
496                residual.as_ref(),
497                join_type,
498            )?
499            else {
500                return Ok(None);
501            };
502            Ok(Some(LogicalPlan::SubqueryAlias(SubqueryAlias::try_new(
503                Arc::new(new_input),
504                alias.alias.clone(),
505            )?)))
506        }
507        LogicalPlan::Join(inner) if inner.join_type == JoinType::Inner => {
508            // Every key must live in the same child, or the existence test
509            // genuinely depends on the joined row.
510            let all_in = |side: &LogicalPlan| {
511                pairs
512                    .iter()
513                    .all(|(fk, _)| index_of(side.schema(), fk).is_some())
514            };
515            let target_is_right = if all_in(&inner.left) {
516                false
517            } else if all_in(&inner.right) {
518                true
519            } else {
520                return Ok(None);
521            };
522            let target = if target_is_right {
523                &inner.right
524            } else {
525                &inner.left
526            };
527
528            // Rebuild the semi-join around the chosen child, keeping the
529            // original orientation so the ON pairs still line up.
530            //
531            // These go in as **equijoin keys**, not as predicate expressions.
532            // `join_on` would park them in the join's `filter` and leave
533            // `extract_equijoin_predicate` to hoist them into `on` later — but
534            // that rule has already run by the time this one fires, so nothing
535            // hoists them and the physical planner sees a join with no keys.
536            // It then picks `NestedLoopJoinExec`: an O(n*m) scan of a pure
537            // equi-join.
538            //
539            // That is not hypothetical. It is what this rule did to TPC-H q2,
540            // measured at 1424 s against Spark's 78 s (18.4x, the second
541            // largest loss of the 22). `stage_dump` counts two
542            // `NestedLoopJoinExec` nodes in q2 with the rule on and **zero**
543            // with `KRISHIV_SEMI_JOIN_REDUCTION=off` — the rule written to
544            // make q18 faster was making q2 eighteen times slower.
545            let (left_keys, right_keys): (Vec<Column>, Vec<Column>) = pairs
546                .iter()
547                .map(|(fk, pk)| {
548                    if filtered_is_right {
549                        (pk.clone(), fk.clone())
550                    } else {
551                        (fk.clone(), pk.clone())
552                    }
553                })
554                .unzip();
555            // The residual may only reference the child we are landing on and
556            // the probe. If it still names a column from the *other* child,
557            // the existence test genuinely depends on the joined row and this
558            // rewrite would evaluate it against rows that do not exist yet.
559            if let Some(filter) = residual {
560                for col in filter.column_refs() {
561                    if index_of(target.schema(), col).is_none()
562                        && index_of(probe.schema(), col).is_none()
563                    {
564                        return Ok(None);
565                    }
566                }
567            }
568
569            // The residual rides in as the join's `filter`, which is what that
570            // field is for. Semi/anti join schemas are the filtered side's
571            // schema regardless of the filter, so this cannot disturb the shape
572            // the parent join was built against.
573            let residual = residual.cloned();
574            let reduced = if filtered_is_right {
575                LogicalPlanBuilder::from(probe.clone()).join_detailed(
576                    target.as_ref().clone(),
577                    join_type,
578                    (left_keys, right_keys),
579                    residual,
580                    NullEquality::NullEqualsNothing,
581                )?
582            } else {
583                LogicalPlanBuilder::from(target.as_ref().clone()).join_detailed(
584                    probe.clone(),
585                    join_type,
586                    (left_keys, right_keys),
587                    residual,
588                    NullEquality::NullEqualsNothing,
589                )?
590            }
591            .build()?;
592
593            let rebuilt = if target_is_right {
594                Join {
595                    right: Arc::new(reduced),
596                    ..inner.clone()
597                }
598            } else {
599                Join {
600                    left: Arc::new(reduced),
601                    ..inner.clone()
602                }
603            };
604            Ok(Some(LogicalPlan::Join(rebuilt)))
605        }
606        _ => Ok(None),
607    }
608}
609
610/// Reduce a fact stream by a *selective dimension* it is inner-joined to,
611/// before the join that needs it.
612///
613/// # The query that motivated this
614///
615/// TPC-H q7's FROM clause is
616/// `supplier, lineitem, orders, customer, nation n1, nation n2`, and the plan
617/// is left-deep in that order — so the two 25-row `nation` tables land at the
618/// very TOP, above every big join:
619///
620/// ```text
621/// Inner Join: n2.n_nationkey = customer.c_nationkey     <- n_name IN (FRANCE, GERMANY)
622///   Inner Join: n1.n_nationkey = supplier.s_nationkey   <- n_name IN (FRANCE, GERMANY)
623///     Inner Join: customer.c_custkey = orders.o_custkey
624///       Inner Join: orders.o_orderkey = lineitem.l_orderkey
625///         Inner Join: supplier.s_suppkey = lineitem.l_suppkey   <- ALL 1M suppliers
626/// ```
627///
628/// `s_nationkey` is carried as payload from the bottom join all the way up,
629/// through **two** shuffles measured at 9.48 GB each, before the nation filter
630/// is ever applied. TPC-H spreads supplier nations uniformly over 25, so
631/// **~8% of suppliers qualify**: the bottom join emits about twelve times more
632/// rows than any of them can survive.
633///
634/// Measured at SF100 on 2026-08-08, those two shuffles and the stage that
635/// consumes them are **81% of q7** (s4 43.4%, s5 37.7%).
636///
637/// # The rewrite
638///
639/// ```text
640///   Inner(N, Big)  on N.k = Big.k    ==>    Inner(N, LeftSemi(Big, N') on Big.k)
641/// ```
642///
643/// where `N'` is `N` descended to its nearest `Filter` and projected to the key
644/// — the same `selective_key_source` the aggregate rule uses.
645///
646/// The reducer is introduced at the TOP of the big side and deliberately left
647/// there: [`SemiJoinPushdownThroughInnerJoin`] already carries a `LeftSemi`
648/// down through inner joins, one level per optimizer pass, and the optimizer
649/// runs to a fixed point. So this rule does not need its own descent, and the
650/// reducer ends up landing directly on the `supplier` scan.
651///
652/// # Why it is safe
653///
654/// - **The join must be Inner.** Under an outer join the unmatched rows are
655///   preserved, so removing them early changes the result.
656/// - **Removing exactly what the join would remove.** A `Big` row whose key has
657///   no match in `N` cannot appear in `Inner(N, Big)`. The reducer removes
658///   precisely those rows and no others, so the output is identical.
659/// - **No duplication.** `LeftSemi` emits each left row at most once however
660///   many `N` rows match, so multiplicity — and every count and sum above — is
661///   unchanged.
662/// - **Nulls agree.** A null key satisfies neither the reducer nor the join.
663/// - **The schema is untouched.** `LeftSemi` projects only its left side, so
664///   the parent join's `on` columns resolve exactly as before.
665///
666/// # Why it is guarded
667///
668/// - **The dimension must carry a `Filter`.** Without one the reducer removes
669///   nothing and costs an extra pass — the same guard, and the same reason, as
670///   the aggregate rule.
671/// - **The big side must contain an inner join.** If it is a bare scan there is
672///   nothing to push past: the reducer would sit directly beneath the join that
673///   already does that work.
674/// - **Idempotence is structural, not shallow.** The pushdown rule moves the
675///   reducer down, so after one pass the big side's top node is an inner join
676///   again and a shallow `already_reduced` check would let this rule add a
677///   second reducer on every pass, forever. `carries_reducer` searches the
678///   whole subtree for this exact probe instead.
679#[derive(Debug, Default)]
680pub struct SemiJoinReductionFromSelectiveDimension {
681    /// Bypass the env gate and always apply — see
682    /// [`SemiJoinPushdownThroughInnerJoin::forced`] for why this exists.
683    forced: bool,
684}
685
686impl SemiJoinReductionFromSelectiveDimension {
687    /// The rule with its env gate bypassed, for tests and explicit opt-in.
688    pub fn forced() -> Self {
689        Self { forced: true }
690    }
691}
692
693impl OptimizerRule for SemiJoinReductionFromSelectiveDimension {
694    fn name(&self) -> &str {
695        "semi_join_reduction_from_selective_dimension"
696    }
697
698    fn apply_order(&self) -> Option<ApplyOrder> {
699        // Bottom-up, so the join tree below is already in its final shape when
700        // a join is examined and `carries_reducer` sees the finished subtree.
701        Some(ApplyOrder::BottomUp)
702    }
703
704    fn rewrite(
705        &self,
706        plan: LogicalPlan,
707        _config: &dyn OptimizerConfig,
708    ) -> Result<Transformed<LogicalPlan>> {
709        if !self.forced && !semi_join_dimension_reduction_enabled() {
710            return Ok(Transformed::no(plan));
711        }
712        let LogicalPlan::Join(join) = &plan else {
713            return Ok(Transformed::no(plan));
714        };
715        if join.join_type != JoinType::Inner || join.on.is_empty() {
716            return Ok(Transformed::no(plan));
717        }
718
719        for (left_key, right_key) in &join.on {
720            let (Expr::Column(left_col), Expr::Column(right_col)) = (left_key, right_key) else {
721                continue;
722            };
723            // Either side may be the dimension; try both orientations. Which
724            // side is which is carried explicitly rather than recovered by
725            // pointer comparison — a self-join whose children are the same
726            // `Arc` makes `Arc::ptr_eq` true for both, and the rewrite would go
727            // into the wrong child (the `4e9203e9` bug).
728            for (dimension_is_right, dimension, dimension_key, big, big_key) in [
729                (true, &join.right, right_col, &join.left, left_col),
730                (false, &join.left, left_col, &join.right, right_col),
731            ] {
732                let Some((probe, probe_col)) = selective_key_source(dimension, dimension_key)?
733                else {
734                    continue;
735                };
736                if !contains_inner_join(big) || carries_reducer(big, &probe) {
737                    continue;
738                }
739                // Equijoin keys, not a predicate expression. `join_on` parks
740                // equalities in the join's `filter`, and by the time this rule
741                // runs nothing hoists them into `on`, so the physical planner
742                // picks a nested-loop join — which is how the sibling rule once
743                // made q2 eighteen times slower.
744                let reduced = LogicalPlanBuilder::from(big.as_ref().clone())
745                    .join_detailed(
746                        probe,
747                        JoinType::LeftSemi,
748                        (vec![big_key.clone()], vec![probe_col]),
749                        None,
750                        NullEquality::NullEqualsNothing,
751                    )?
752                    .build()?;
753                let rebuilt = if dimension_is_right {
754                    Join {
755                        left: Arc::new(reduced),
756                        ..join.clone()
757                    }
758                } else {
759                    Join {
760                        right: Arc::new(reduced),
761                        ..join.clone()
762                    }
763                };
764                return Ok(Transformed::yes(LogicalPlan::Join(rebuilt)));
765            }
766        }
767        Ok(Transformed::no(plan))
768    }
769}
770
771/// Is there an inner join anywhere beneath here for a reducer to be pushed past?
772fn contains_inner_join(plan: &LogicalPlan) -> bool {
773    if matches!(plan, LogicalPlan::Join(j) if j.join_type == JoinType::Inner) {
774        return true;
775    }
776    plan.inputs().iter().any(|child| contains_inner_join(child))
777}
778
779/// Does this subtree already carry a reducer against exactly this probe?
780///
781/// Structural, and searching the *whole* subtree, because
782/// [`SemiJoinPushdownThroughInnerJoin`] relocates the reducer on later passes:
783/// a check that only looked at the top node would see an inner join again and
784/// add another reducer every pass, without ever converging.
785fn carries_reducer(plan: &LogicalPlan, probe: &LogicalPlan) -> bool {
786    if let LogicalPlan::Join(join) = plan
787        && join.join_type == JoinType::LeftSemi
788        && join.right.as_ref() == probe
789    {
790        return true;
791    }
792    plan.inputs()
793        .iter()
794        .any(|child| carries_reducer(child, probe))
795}
796
797/// Push a semi-join built from an inner join's other side into the input of a
798/// grouped aggregate, when the join key is one of the grouping columns.
799#[derive(Debug, Default)]
800pub struct SemiJoinReductionThroughAggregate;
801
802impl OptimizerRule for SemiJoinReductionThroughAggregate {
803    fn name(&self) -> &str {
804        "semi_join_reduction_through_aggregate"
805    }
806
807    fn apply_order(&self) -> Option<ApplyOrder> {
808        // Bottom-up so inner joins are already in their final shape when we
809        // look at them, and so DataFusion drives the recursion.
810        Some(ApplyOrder::BottomUp)
811    }
812
813    fn rewrite(
814        &self,
815        plan: LogicalPlan,
816        _config: &dyn OptimizerConfig,
817    ) -> Result<Transformed<LogicalPlan>> {
818        if !semi_join_reduction_enabled() {
819            return Ok(Transformed::no(plan));
820        }
821        let LogicalPlan::Join(join) = &plan else {
822            return Ok(Transformed::no(plan));
823        };
824        if join.join_type != JoinType::Inner || join.on.is_empty() {
825            return Ok(Transformed::no(plan));
826        }
827
828        for (left_key, right_key) in &join.on {
829            let (Expr::Column(left_col), Expr::Column(right_col)) = (left_key, right_key) else {
830                continue;
831            };
832            // Either side may hold the aggregate; try both orientations. Which
833            // side we are on is carried explicitly rather than recovered with
834            // `Arc::ptr_eq(agg_side, &join.right)`: when both children happen
835            // to be the *same* `Arc` — a self-join whose two sides share a
836            // subtree — that comparison is true for the left orientation too,
837            // and the rewrite would be spliced into the wrong child.
838            for (agg_is_right, agg_side, agg_key, probe_side, probe_key) in [
839                (true, &join.right, right_col, &join.left, left_col),
840                (false, &join.left, left_col, &join.right, right_col),
841            ] {
842                let Some((probe, probe_col)) = selective_key_source(probe_side, probe_key)? else {
843                    continue;
844                };
845                let Some(new_side) = push_through(agg_side, agg_key, &probe, &probe_col)? else {
846                    continue;
847                };
848                let rebuilt = if agg_is_right {
849                    Join {
850                        right: Arc::new(new_side),
851                        ..join.clone()
852                    }
853                } else {
854                    Join {
855                        left: Arc::new(new_side),
856                        ..join.clone()
857                    }
858                };
859                return Ok(Transformed::yes(LogicalPlan::Join(rebuilt)));
860            }
861        }
862        Ok(Transformed::no(plan))
863    }
864}
865
866/// Position of `col` in `schema`, or `None` if it is not there.
867///
868/// Everything below matches columns by this index rather than by name.
869/// `SubqueryAlias` requalifies every column and projections rename them, so
870/// name matching across those boundaries is exactly where a rule like this
871/// pairs the wrong two columns and silently returns wrong answers.
872fn index_of(schema: &DFSchema, col: &Column) -> Option<usize> {
873    schema.index_of_column(col).ok()
874}
875
876/// Rewrite `plan` so the aggregate beneath it filters its input by `probe`.
877///
878/// Returns `None` when the shape does not qualify, in which case the caller
879/// leaves the plan alone. Descends only through nodes that pass rows through
880/// one-for-one and preserve the key's position.
881fn push_through(
882    plan: &LogicalPlan,
883    key: &Column,
884    probe: &LogicalPlan,
885    probe_key: &Column,
886) -> Result<Option<LogicalPlan>> {
887    let Some(idx) = index_of(plan.schema(), key) else {
888        return Ok(None);
889    };
890    match plan {
891        LogicalPlan::SubqueryAlias(alias) => {
892            let (qualifier, field) = alias.input.schema().qualified_field(idx);
893            let inner = Column::new(qualifier.cloned(), field.name());
894            let Some(new_input) = push_through(&alias.input, &inner, probe, probe_key)? else {
895                return Ok(None);
896            };
897            Ok(Some(LogicalPlan::SubqueryAlias(SubqueryAlias::try_new(
898                Arc::new(new_input),
899                alias.alias.clone(),
900            )?)))
901        }
902        LogicalPlan::Projection(proj) => {
903            // Only a straight column pass-through is safe to descend: an
904            // expression could change the key's value, so the semi-join would
905            // be filtering on something other than what the join compares.
906            let Some(Expr::Column(inner)) = proj.expr.get(idx) else {
907                return Ok(None);
908            };
909            let inner = inner.clone();
910            let Some(new_input) = push_through(&proj.input, &inner, probe, probe_key)? else {
911                return Ok(None);
912            };
913            Ok(Some(LogicalPlan::Projection(Projection::try_new(
914                proj.expr.clone(),
915                Arc::new(new_input),
916            )?)))
917        }
918        LogicalPlan::Aggregate(agg) => {
919            // Grouping columns occupy the leading schema positions; anything
920            // past them is an aggregate output, which is not a grouping key.
921            if idx >= agg.group_expr.len() {
922                return Ok(None);
923            }
924            let Some(Expr::Column(group_col)) = agg.group_expr.get(idx) else {
925                return Ok(None);
926            };
927            if already_reduced(&agg.input) {
928                return Ok(None);
929            }
930            // Equijoin keys, not a predicate expression — see the note in
931            // `push_semi_below`. `join_on` parks equalities in `filter`, and
932            // by the time this rule runs nothing hoists them into `on` any
933            // more, so the physical planner falls back to a nested-loop join.
934            let reduced = LogicalPlanBuilder::from(agg.input.as_ref().clone())
935                .join_detailed(
936                    probe.clone(),
937                    JoinType::LeftSemi,
938                    (vec![group_col.clone()], vec![probe_key.clone()]),
939                    None,
940                    NullEquality::NullEqualsNothing,
941                )?
942                .build()?;
943            // LeftSemi preserves the left schema exactly, so the grouping and
944            // aggregate expressions still resolve unchanged.
945            Ok(Some(LogicalPlan::Aggregate(Aggregate::try_new(
946                Arc::new(reduced),
947                agg.group_expr.clone(),
948                agg.aggr_expr.clone(),
949            )?)))
950        }
951        _ => Ok(None),
952    }
953}
954
955/// Has this aggregate input already been reduced by a previous pass?
956///
957/// The optimizer runs rules to a fixed point, so without this the rule would
958/// stack a fresh semi-join on every iteration and never converge.
959fn already_reduced(plan: &LogicalPlan) -> bool {
960    matches!(plan, LogicalPlan::Join(j) if j.join_type == JoinType::LeftSemi)
961}
962
963/// Smallest subtree of `plan` that still produces `key` and carries a filter.
964///
965/// Returns the subtree projected down to the key alone, plus the key's name
966/// inside it. `None` means there is no filter on this side — the semi-join
967/// would then remove nothing while costing an extra pass, so the rule declines.
968fn selective_key_source(plan: &LogicalPlan, key: &Column) -> Result<Option<(LogicalPlan, Column)>> {
969    let Some(source) = descend_to_filter(plan, key) else {
970        return Ok(None);
971    };
972    let (subtree, col) = source;
973    let projected = LogicalPlanBuilder::from(subtree)
974        .project([Expr::Column(col.clone())])?
975        .build()?;
976    Ok(Some((projected, col)))
977}
978
979/// Walk down to the nearest `Filter` that still produces `key`.
980///
981/// Stopping *at* the filter rather than continuing to the scan below it is what
982/// keeps the probe small: in q17 that is the ~2000 filtered parts instead of
983/// the whole 20M-row `part` table.
984fn descend_to_filter(plan: &LogicalPlan, key: &Column) -> Option<(LogicalPlan, Column)> {
985    let idx = index_of(plan.schema(), key)?;
986    match plan {
987        LogicalPlan::Filter(_) => Some((plan.clone(), key.clone())),
988        LogicalPlan::SubqueryAlias(alias) => {
989            let (qualifier, field) = alias.input.schema().qualified_field(idx);
990            descend_to_filter(&alias.input, &Column::new(qualifier.cloned(), field.name()))
991        }
992        LogicalPlan::Projection(proj) => match proj.expr.get(idx) {
993            Some(Expr::Column(inner)) => descend_to_filter(&proj.input, &inner.clone()),
994            _ => None,
995        },
996        LogicalPlan::Join(join) => {
997            // Follow whichever side actually carries the key. An outer join's
998            // null-padded side cannot be used as a probe: it may manufacture
999            // key values that the aggregate side should not be filtered by.
1000            if !matches!(join.join_type, JoinType::Inner) {
1001                return None;
1002            }
1003            for side in [&join.left, &join.right] {
1004                if let Some(found) =
1005                    index_of(side.schema(), key).and_then(|_| descend_to_filter(side, key))
1006                {
1007                    return Some(found);
1008                }
1009            }
1010            None
1011        }
1012        _ => None,
1013    }
1014}
1015
1016#[cfg(test)]
1017#[allow(clippy::unwrap_used, clippy::expect_used)]
1018mod tests {
1019    use super::*;
1020    use datafusion::arrow::array::{Int64Array, StringArray};
1021    use datafusion::arrow::datatypes::{DataType, Field, Schema};
1022    use datafusion::arrow::record_batch::RecordBatch;
1023    use datafusion::datasource::MemTable;
1024    use datafusion::execution::session_state::SessionStateBuilder;
1025    use datafusion::prelude::SessionContext;
1026
1027    /// `lineitem`-shaped: many rows per key.
1028    ///
1029    /// Carries `l_suppkey` and the commit/receipt dates as well, so the q21
1030    /// shape (`EXISTS`/`NOT EXISTS` correlated on `l_orderkey` and comparing
1031    /// `l_suppkey`) can be exercised against the same fixture. Each order gets
1032    /// four lines with four *different* suppliers, and one line per order is
1033    /// late, which is what makes both the semi and the anti test non-trivial.
1034    fn line_table() -> Arc<MemTable> {
1035        let schema = Arc::new(Schema::new(vec![
1036            Field::new("l_partkey", DataType::Int64, false),
1037            Field::new("l_orderkey", DataType::Int64, false),
1038            Field::new("l_quantity", DataType::Int64, false),
1039            Field::new("l_suppkey", DataType::Int64, false),
1040            Field::new("l_commitdate", DataType::Int64, false),
1041            Field::new("l_receiptdate", DataType::Int64, false),
1042        ]));
1043        // keys 1..=5, four rows each with distinct quantities
1044        let mut keys = Vec::new();
1045        let mut orders = Vec::new();
1046        let mut qty = Vec::new();
1047        let mut supp = Vec::new();
1048        let mut commit = Vec::new();
1049        let mut receipt = Vec::new();
1050        for k in 1..=5i64 {
1051            for q in 1..=4i64 {
1052                keys.push(k);
1053                orders.push(k);
1054                qty.push(k * 10 + q);
1055                // four distinct suppliers per order, drawn from 1..=4
1056                supp.push(q);
1057                commit.push(100i64);
1058                // exactly one late line per order, and which supplier is late
1059                // varies with the order, so the anti-join keeps some suppliers
1060                // and drops others rather than all-or-nothing.
1061                receipt.push(if q == (k % 4) + 1 { 200 } else { 50 });
1062            }
1063        }
1064        let batch = RecordBatch::try_new(
1065            schema.clone(),
1066            vec![
1067                Arc::new(Int64Array::from(keys)),
1068                Arc::new(Int64Array::from(orders)),
1069                Arc::new(Int64Array::from(qty)),
1070                Arc::new(Int64Array::from(supp)),
1071                Arc::new(Int64Array::from(commit)),
1072                Arc::new(Int64Array::from(receipt)),
1073            ],
1074        )
1075        .unwrap();
1076        Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap())
1077    }
1078
1079    /// `supplier`-shaped, for the q21 shape.
1080    fn supplier_table() -> Arc<MemTable> {
1081        let schema = Arc::new(Schema::new(vec![
1082            Field::new("s_suppkey", DataType::Int64, false),
1083            Field::new("s_name", DataType::Utf8, false),
1084            Field::new("s_nationkey", DataType::Int64, false),
1085        ]));
1086        let batch = RecordBatch::try_new(
1087            schema.clone(),
1088            vec![
1089                Arc::new(Int64Array::from(vec![1i64, 2, 3, 4])),
1090                Arc::new(StringArray::from(vec!["s1", "s2", "s3", "s4"])),
1091                Arc::new(Int64Array::from(vec![7i64, 7, 8, 7])),
1092            ],
1093        )
1094        .unwrap();
1095        Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap())
1096    }
1097
1098    /// `nation`-shaped, for the q21 shape.
1099    fn nation_table() -> Arc<MemTable> {
1100        let schema = Arc::new(Schema::new(vec![
1101            Field::new("n_nationkey", DataType::Int64, false),
1102            Field::new("n_name", DataType::Utf8, false),
1103        ]));
1104        let batch = RecordBatch::try_new(
1105            schema.clone(),
1106            vec![
1107                Arc::new(Int64Array::from(vec![7i64, 8])),
1108                Arc::new(StringArray::from(vec!["SAUDI ARABIA", "OTHER"])),
1109            ],
1110        )
1111        .unwrap();
1112        Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap())
1113    }
1114
1115    /// `orders`-shaped: one row per orderkey, pointing at a customer.
1116    fn orders_table() -> Arc<MemTable> {
1117        let schema = Arc::new(Schema::new(vec![
1118            Field::new("o_orderkey", DataType::Int64, false),
1119            Field::new("o_custkey", DataType::Int64, false),
1120            Field::new("o_totalprice", DataType::Int64, false),
1121            Field::new("o_orderdate", DataType::Int64, false),
1122            Field::new("o_orderstatus", DataType::Utf8, false),
1123        ]));
1124        let batch = RecordBatch::try_new(
1125            schema.clone(),
1126            vec![
1127                Arc::new(Int64Array::from(vec![1i64, 2, 3, 4, 5])),
1128                Arc::new(Int64Array::from(vec![10i64, 20, 30, 40, 50])),
1129                Arc::new(Int64Array::from(vec![100i64, 200, 300, 400, 500])),
1130                Arc::new(Int64Array::from(vec![
1131                    20260101i64,
1132                    20260102,
1133                    20260103,
1134                    20260104,
1135                    20260105,
1136                ])),
1137                // Not all 'F': a status filter that removes nothing would let a
1138                // broken pushdown pass by accident.
1139                Arc::new(StringArray::from(vec!["F", "F", "F", "O", "F"])),
1140            ],
1141        )
1142        .unwrap();
1143        Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap())
1144    }
1145
1146    /// `customer`-shaped.
1147    fn customer_table() -> Arc<MemTable> {
1148        let schema = Arc::new(Schema::new(vec![
1149            Field::new("c_custkey", DataType::Int64, false),
1150            Field::new("c_name", DataType::Utf8, false),
1151        ]));
1152        let batch = RecordBatch::try_new(
1153            schema.clone(),
1154            vec![
1155                Arc::new(Int64Array::from(vec![10i64, 20, 30, 40, 50])),
1156                Arc::new(StringArray::from(vec!["a", "b", "c", "d", "e"])),
1157            ],
1158        )
1159        .unwrap();
1160        Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap())
1161    }
1162
1163    /// `part`-shaped: one row per key, with a filterable attribute.
1164    fn part_table() -> Arc<MemTable> {
1165        let schema = Arc::new(Schema::new(vec![
1166            Field::new("p_partkey", DataType::Int64, false),
1167            Field::new("p_brand", DataType::Utf8, false),
1168        ]));
1169        let batch = RecordBatch::try_new(
1170            schema.clone(),
1171            vec![
1172                Arc::new(Int64Array::from(vec![1i64, 2, 3, 4, 5])),
1173                Arc::new(StringArray::from(vec![
1174                    "keep", "skip", "keep", "skip", "skip",
1175                ])),
1176            ],
1177        )
1178        .unwrap();
1179        Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap())
1180    }
1181
1182    fn context(with_rule: bool) -> SessionContext {
1183        let mut builder = SessionStateBuilder::new().with_default_features();
1184        if with_rule {
1185            builder = builder
1186                .with_optimizer_rule(Arc::new(SemiJoinReductionThroughAggregate))
1187                .with_optimizer_rule(Arc::new(SemiJoinPushdownThroughInnerJoin::forced()));
1188        }
1189        let ctx = SessionContext::new_with_state(builder.build());
1190        ctx.register_table("lineitem", line_table()).unwrap();
1191        ctx.register_table("part", part_table()).unwrap();
1192        ctx.register_table("orders", orders_table()).unwrap();
1193        ctx.register_table("customer", customer_table()).unwrap();
1194        ctx.register_table("supplier", supplier_table()).unwrap();
1195        ctx.register_table("nation", nation_table()).unwrap();
1196        ctx
1197    }
1198
1199    async fn rows(ctx: &SessionContext, sql: &str) -> Vec<String> {
1200        let batches = ctx.sql(sql).await.unwrap().collect().await.unwrap();
1201        let mut out = Vec::new();
1202        for b in &batches {
1203            for r in 0..b.num_rows() {
1204                let mut cells = Vec::new();
1205                for c in 0..b.num_columns() {
1206                    cells.push(
1207                        datafusion::common::cast::as_string_array(
1208                            &datafusion::arrow::compute::cast(b.column(c), &DataType::Utf8)
1209                                .unwrap(),
1210                        )
1211                        .unwrap()
1212                        .value(r)
1213                        .to_string(),
1214                    );
1215                }
1216                out.push(cells.join("|"));
1217            }
1218        }
1219        out.sort();
1220        out
1221    }
1222
1223    /// As [`context`], plus the selective-dimension rule (the q7 rule).
1224    ///
1225    /// The pushdown rule comes with it deliberately: this rule only *introduces*
1226    /// the reducer, and the pushdown rule is what carries it down onto the
1227    /// dimension-keyed scan. Testing them apart would test half a mechanism.
1228    fn dimension_context(with_rule: bool) -> SessionContext {
1229        let mut builder = SessionStateBuilder::new().with_default_features();
1230        if with_rule {
1231            builder = builder
1232                .with_optimizer_rule(Arc::new(SemiJoinPushdownThroughInnerJoin::forced()))
1233                .with_optimizer_rule(Arc::new(SemiJoinReductionFromSelectiveDimension::forced()));
1234        }
1235        let ctx = SessionContext::new_with_state(builder.build());
1236        ctx.register_table("lineitem", line_table()).unwrap();
1237        ctx.register_table("supplier", supplier_table()).unwrap();
1238        ctx.register_table("nation", nation_table()).unwrap();
1239        ctx
1240    }
1241
1242    /// The q7 shape: a fact stream joined to a *filtered* dimension, where the
1243    /// dimension's key enters at the deepest join and the filter is applied at
1244    /// the top.
1245    const Q7_SHAPE: &str = "SELECT n.n_name, sum(l.l_quantity) AS q \
1246        FROM supplier s, lineitem l, nation n \
1247        WHERE s.s_suppkey = l.l_suppkey AND s.s_nationkey = n.n_nationkey \
1248          AND n.n_name = 'SAUDI ARABIA' \
1249        GROUP BY n.n_name";
1250
1251    /// The reducer must land on the `supplier` scan, not merely exist.
1252    ///
1253    /// Inserting a `LeftSemi` somewhere in the plan is not the win; q7's cost is
1254    /// that all 1M suppliers reach the `lineitem` join, so the reducer has to
1255    /// end up *below* that join. Asserting only "a LeftSemi appears" would pass
1256    /// on a plan that still broadcasts every supplier.
1257    #[tokio::test]
1258    async fn the_reducer_lands_on_the_dimension_keyed_scan() {
1259        let plan = plan_of(&dimension_context(true), Q7_SHAPE).await;
1260        let semi = plan
1261            .lines()
1262            .position(|l| l.contains("LeftSemi"))
1263            .unwrap_or_else(|| panic!("no reducer was introduced:\n{plan}"));
1264        let supplier = plan
1265            .lines()
1266            .position(|l| l.contains("TableScan: supplier"))
1267            .unwrap_or_else(|| panic!("no supplier scan:\n{plan}"));
1268        let lineitem = plan
1269            .lines()
1270            .position(|l| l.contains("TableScan: lineitem"))
1271            .unwrap_or_else(|| panic!("no lineitem scan:\n{plan}"));
1272        // `display_indent` is pre-order, so a node's subtree is the contiguous
1273        // block after it. The reducer is on the supplier side exactly when the
1274        // supplier scan falls inside it and the lineitem scan does not.
1275        assert!(
1276            semi < supplier,
1277            "the reducer must sit above the supplier scan, not below it:\n{plan}"
1278        );
1279        assert!(
1280            semi > lineitem || supplier < lineitem,
1281            "the reducer swallowed the lineitem scan, so it did not land on \
1282             supplier alone:\n{plan}"
1283        );
1284    }
1285
1286    /// Reducing must not change the answer, and the fixture must have an answer
1287    /// to change: `nation` here is 2 rows of which the filter keeps 1, so the
1288    /// suppliers that survive are a strict subset.
1289    #[tokio::test]
1290    async fn reducing_by_the_dimension_keeps_the_same_rows() {
1291        let expected = rows(&dimension_context(false), Q7_SHAPE).await;
1292        assert!(!expected.is_empty(), "fixture must produce rows");
1293        assert_eq!(rows(&dimension_context(true), Q7_SHAPE).await, expected);
1294    }
1295
1296    /// The switch is opt-in: unset means off.
1297    ///
1298    /// Pinned because the default is the whole safety story here — the rule is
1299    /// a measured 18x regression on q10 — and because a default that flips back
1300    /// silently is exactly how the sibling rule's own default drifted once.
1301    #[test]
1302    fn the_dimension_rule_is_off_unless_explicitly_asked_for() {
1303        for unset_or_no in ["", "  ", "off", "0", "false", "no", "maybe"] {
1304            assert!(
1305                !opt_in_from(unset_or_no),
1306                "{unset_or_no:?} must not enable the rule"
1307            );
1308        }
1309        for yes in ["1", "on", "true", "yes", "ON", " On "] {
1310            assert!(opt_in_from(yes), "{yes:?} must enable the rule");
1311        }
1312    }
1313
1314    /// An unfiltered dimension is left alone: the reducer would remove nothing
1315    /// and cost an extra pass over the dimension.
1316    #[tokio::test]
1317    async fn an_unfiltered_dimension_does_not_get_a_reducer() {
1318        let sql = "SELECT n.n_name, sum(l.l_quantity) AS q \
1319            FROM supplier s, lineitem l, nation n \
1320            WHERE s.s_suppkey = l.l_suppkey AND s.s_nationkey = n.n_nationkey \
1321            GROUP BY n.n_name";
1322        let plan = plan_of(&dimension_context(true), sql).await;
1323        assert!(
1324            !plan.contains("LeftSemi"),
1325            "no filter on the dimension means nothing to reduce by:\n{plan}"
1326        );
1327    }
1328
1329    /// The rule must converge.
1330    ///
1331    /// The pushdown rule relocates the reducer, so after one pass the fact
1332    /// side's top node is an inner join again. A shallow "already reduced"
1333    /// check would then add another reducer every pass. One is the right
1334    /// number.
1335    #[tokio::test]
1336    async fn the_reducer_is_introduced_exactly_once() {
1337        let plan = plan_of(&dimension_context(true), Q7_SHAPE).await;
1338        assert_eq!(
1339            plan.matches("LeftSemi").count(),
1340            1,
1341            "the rule stacked reducers instead of converging:\n{plan}"
1342        );
1343    }
1344
1345    async fn plan_of(ctx: &SessionContext, sql: &str) -> String {
1346        format!(
1347            "{}",
1348            ctx.sql(sql)
1349                .await
1350                .unwrap()
1351                .into_optimized_plan()
1352                .unwrap()
1353                .display_indent()
1354        )
1355    }
1356
1357    /// The q17 shape: a grouped aggregate inner-joined on its grouping key,
1358    /// with a filtered relation on the other side.
1359    const Q17_SHAPE: &str = "SELECT p.p_partkey, s.avg_q FROM part p JOIN \
1360        (SELECT l_partkey, avg(l_quantity) AS avg_q FROM lineitem GROUP BY l_partkey) s \
1361        ON p.p_partkey = s.l_partkey WHERE p.p_brand = 'keep'";
1362
1363    #[tokio::test]
1364    async fn the_rule_pushes_a_semi_join_into_the_aggregate_input() {
1365        let plan = plan_of(&context(true), Q17_SHAPE).await;
1366        assert!(
1367            plan.contains("LeftSemi"),
1368            "expected a LeftSemi reduction in:\n{plan}"
1369        );
1370        let baseline = plan_of(&context(false), Q17_SHAPE).await;
1371        assert!(
1372            !baseline.contains("LeftSemi"),
1373            "baseline should not already contain one:\n{baseline}"
1374        );
1375    }
1376
1377    /// The property that actually matters. A faster wrong answer is worse than
1378    /// a slow right one, so the rule is only worth having if this holds.
1379    #[tokio::test]
1380    async fn results_are_identical_with_and_without_the_rule() {
1381        for sql in [
1382            Q17_SHAPE,
1383            // aggregate on the left of the join instead of the right
1384            "SELECT s.l_partkey, s.total FROM \
1385             (SELECT l_partkey, sum(l_quantity) AS total FROM lineitem GROUP BY l_partkey) s \
1386             JOIN part p ON s.l_partkey = p.p_partkey WHERE p.p_brand = 'keep'",
1387            // multiple aggregates, and a count that would inflate if the
1388            // semi-join ever duplicated a left row
1389            "SELECT p.p_partkey, s.n, s.total FROM part p JOIN \
1390             (SELECT l_partkey, count(*) AS n, sum(l_quantity) AS total \
1391              FROM lineitem GROUP BY l_partkey) s \
1392             ON p.p_partkey = s.l_partkey WHERE p.p_brand = 'keep'",
1393        ] {
1394            let with = rows(&context(true), sql).await;
1395            let without = rows(&context(false), sql).await;
1396            assert_eq!(with, without, "results diverged for:\n{sql}");
1397            assert!(!with.is_empty(), "test query returned nothing: {sql}");
1398        }
1399    }
1400
1401    /// Under a LEFT join the unmatched rows are preserved, so dropping groups
1402    /// would change the answer. The rule must decline.
1403    #[tokio::test]
1404    async fn outer_joins_are_left_alone() {
1405        let sql = "SELECT p.p_partkey, s.avg_q FROM part p LEFT JOIN \
1406            (SELECT l_partkey, avg(l_quantity) AS avg_q FROM lineitem GROUP BY l_partkey) s \
1407            ON p.p_partkey = s.l_partkey WHERE p.p_brand = 'keep'";
1408        let plan = plan_of(&context(true), sql).await;
1409        assert!(
1410            !plan.contains("LeftSemi"),
1411            "must not reduce under an outer join:\n{plan}"
1412        );
1413        assert_eq!(
1414            rows(&context(true), sql).await,
1415            rows(&context(false), sql).await
1416        );
1417    }
1418
1419    /// Joining on an *aggregate output* rather than a grouping key is not a
1420    /// key filter — restricting the input would change the aggregate values.
1421    #[tokio::test]
1422    async fn joining_on_an_aggregate_output_is_not_reduced() {
1423        let sql = "SELECT p.p_partkey FROM part p JOIN \
1424            (SELECT l_partkey, sum(l_quantity) AS total FROM lineitem GROUP BY l_partkey) s \
1425            ON p.p_partkey = s.total WHERE p.p_brand = 'keep'";
1426        let plan = plan_of(&context(true), sql).await;
1427        assert!(
1428            !plan.contains("LeftSemi"),
1429            "grouping keys only; an aggregate output is not one:\n{plan}"
1430        );
1431    }
1432
1433    /// With no filter on the probe side the semi-join removes nothing and
1434    /// costs an extra pass, so the guard should decline.
1435    #[tokio::test]
1436    async fn an_unfiltered_probe_side_is_not_worth_reducing() {
1437        let sql = "SELECT p.p_partkey, s.avg_q FROM part p JOIN \
1438            (SELECT l_partkey, avg(l_quantity) AS avg_q FROM lineitem GROUP BY l_partkey) s \
1439            ON p.p_partkey = s.l_partkey";
1440        let plan = plan_of(&context(true), sql).await;
1441        assert!(
1442            !plan.contains("LeftSemi"),
1443            "no filter means no selectivity to exploit:\n{plan}"
1444        );
1445    }
1446
1447    /// The optimizer runs rules to a fixed point. Without the `already_reduced`
1448    /// guard this stacks a new semi-join every iteration and never converges.
1449    #[tokio::test]
1450    async fn reduction_is_applied_at_most_once() {
1451        let plan = plan_of(&context(true), Q17_SHAPE).await;
1452        assert_eq!(
1453            plan.matches("LeftSemi").count(),
1454            1,
1455            "expected exactly one reduction:\n{plan}"
1456        );
1457    }
1458
1459    /// The switch has to actually switch it off — a flag that is declared but
1460    /// never read is worse than no flag, because the registry gate makes it
1461    /// look supported.
1462    #[test]
1463    fn the_env_switch_is_honoured() {
1464        for off in ["off", "OFF", "0", "false", "no", " off "] {
1465            assert!(!enabled_from(off), "{off:?} should disable the rule");
1466        }
1467        for on in ["", "on", "1", "true", "anything-else"] {
1468            assert!(enabled_from(on), "{on:?} should leave the rule enabled");
1469        }
1470    }
1471
1472    /// The reduction must keep exactly the groups the join would have kept —
1473    /// 'keep' selects partkeys 1 and 3 of 5.
1474    #[tokio::test]
1475    async fn the_reduction_keeps_exactly_the_surviving_groups() {
1476        let out = rows(&context(true), Q17_SHAPE).await;
1477        assert_eq!(out.len(), 2, "expected two surviving groups, got {out:?}");
1478    }
1479
1480    // ── q18 shape: semi-join pushdown through an inner join ────────────────
1481
1482    /// The q18 shape: an `IN` subquery over an aggregate, joined against a
1483    /// customer/orders/lineitem chain. Without the rule the semi-join sits on
1484    /// top of the whole join; with it, it filters `orders` first.
1485    const Q18_SHAPE: &str = "SELECT o.o_orderkey, sum(l.l_quantity) \
1486        FROM customer c, orders o, lineitem l \
1487        WHERE o.o_orderkey IN \
1488          (SELECT l_orderkey FROM lineitem GROUP BY l_orderkey HAVING sum(l_quantity) > 100) \
1489          AND c.c_custkey = o.o_custkey AND o.o_orderkey = l.l_orderkey \
1490        GROUP BY o.o_orderkey";
1491
1492    #[tokio::test]
1493    async fn the_semi_join_is_pushed_below_the_inner_join() {
1494        let with = plan_of(&context(true), Q18_SHAPE).await;
1495        let without = plan_of(&context(false), Q18_SHAPE).await;
1496
1497        // Position of the semi-join relative to the inner joins is the whole
1498        // point: deeper means it filters an input rather than the output.
1499        fn depth_of_semi(plan: &str) -> Option<usize> {
1500            plan.lines().position(|l| l.contains("Semi"))
1501        }
1502        fn depth_of_first_inner(plan: &str) -> Option<usize> {
1503            plan.lines().position(|l| l.contains("Inner Join"))
1504        }
1505        let (ws, wi) = (depth_of_semi(&with), depth_of_first_inner(&with));
1506        let (bs, bi) = (depth_of_semi(&without), depth_of_first_inner(&without));
1507        assert!(ws.is_some() && wi.is_some(), "expected both joins:\n{with}");
1508        assert!(
1509            bs < bi,
1510            "baseline should have the semi-join above the inner join:\n{without}"
1511        );
1512        assert!(
1513            ws > wi,
1514            "rule should push the semi-join below the inner join:\n{with}"
1515        );
1516    }
1517
1518    /// Same property as for q17, and the one that decides whether the rewrite
1519    /// is worth anything: identical answers.
1520    #[tokio::test]
1521    async fn q18_results_are_identical_with_and_without_the_rule() {
1522        for sql in [
1523            Q18_SHAPE,
1524            // no aggregate above, so the join output itself is compared
1525            "SELECT o.o_orderkey, c.c_name FROM customer c, orders o \
1526             WHERE o.o_orderkey IN (SELECT l_orderkey FROM lineitem \
1527                                    GROUP BY l_orderkey HAVING sum(l_quantity) > 100) \
1528               AND c.c_custkey = o.o_custkey",
1529            // NOT IN — must not be rewritten as if it were a semi-join
1530            "SELECT o.o_orderkey FROM customer c, orders o \
1531             WHERE o.o_orderkey NOT IN (SELECT l_orderkey FROM lineitem \
1532                                        GROUP BY l_orderkey HAVING sum(l_quantity) > 100) \
1533               AND c.c_custkey = o.o_custkey",
1534        ] {
1535            let with = rows(&context(true), sql).await;
1536            let without = rows(&context(false), sql).await;
1537            assert_eq!(with, without, "results diverged for:\n{sql}");
1538        }
1539    }
1540
1541    /// The *verbatim* q18 shape — every projected column, all five grouping
1542    /// keys, the ORDER BY and the LIMIT.
1543    ///
1544    /// The simplified `Q18_SHAPE` above fires; this one did not on real data,
1545    /// so the difference lives in the SQL, not in the data. Keeping the full
1546    /// form as its own test is what turns "the rule is inert in production"
1547    /// into something reproducible in 0.2 s.
1548    const Q18_VERBATIM: &str = "SELECT c_name, c_custkey, o_orderkey, o_orderdate, o_totalprice, \
1549        sum(l_quantity) FROM customer, orders, lineitem \
1550        WHERE o_orderkey IN (SELECT l_orderkey FROM lineitem GROUP BY l_orderkey \
1551                             HAVING sum(l_quantity) > 100) \
1552          AND c_custkey = o_custkey AND o_orderkey = l_orderkey \
1553        GROUP BY c_name, c_custkey, o_orderkey, o_orderdate, o_totalprice \
1554        ORDER BY o_totalprice DESC, o_orderdate LIMIT 100";
1555
1556    #[tokio::test]
1557    async fn the_verbatim_q18_shape_is_also_pushed_down() {
1558        let with = plan_of(&context(true), Q18_VERBATIM).await;
1559        let semi = with.lines().position(|l| l.contains("Semi"));
1560        let inner = with.lines().position(|l| l.contains("Inner Join"));
1561        assert!(
1562            semi.is_some() && inner.is_some(),
1563            "expected both joins in:\n{with}"
1564        );
1565        assert!(
1566            semi > inner,
1567            "the real q18 shape must be pushed below the inner join too:\n{with}"
1568        );
1569        assert_eq!(
1570            rows(&context(true), Q18_VERBATIM).await,
1571            rows(&context(false), Q18_VERBATIM).await
1572        );
1573    }
1574
1575    // ── q21 shape: semi AND anti joins carrying a residual filter ──────────
1576
1577    /// The verbatim q21 shape — the slowest query in the SF100 sweep.
1578    ///
1579    /// Measured 4309 s against Spark's 391 s (11.0x), the single largest
1580    /// absolute loss of the 22. Its `EXISTS`/`NOT EXISTS` decorrelate to a
1581    /// `LeftSemi` and a `LeftAnti` **each carrying a residual filter**
1582    /// (`l_suppkey <> l_suppkey`), and the pushdown rule declined on both
1583    /// counts — `filter.is_some()` and anti-joins being excluded outright. So
1584    /// the most selective predicate in the query ran last, above the whole
1585    /// four-way join, exactly the shape the q18 work was meant to fix.
1586    const Q21_VERBATIM: &str = "SELECT s_name, count(*) AS numwait \
1587        FROM supplier, lineitem l1, orders, nation \
1588        WHERE s_suppkey = l1.l_suppkey AND o_orderkey = l1.l_orderkey \
1589          AND o_orderstatus = 'F' AND l1.l_receiptdate > l1.l_commitdate \
1590          AND EXISTS (SELECT * FROM lineitem l2 \
1591                      WHERE l2.l_orderkey = l1.l_orderkey \
1592                        AND l2.l_suppkey <> l1.l_suppkey) \
1593          AND NOT EXISTS (SELECT * FROM lineitem l3 \
1594                          WHERE l3.l_orderkey = l1.l_orderkey \
1595                            AND l3.l_suppkey <> l1.l_suppkey \
1596                            AND l3.l_receiptdate > l3.l_commitdate) \
1597          AND s_nationkey = n_nationkey AND n_name = 'SAUDI ARABIA' \
1598        GROUP BY s_name ORDER BY numwait DESC, s_name LIMIT 100";
1599
1600    /// The property that decides whether any of this was worth doing.
1601    ///
1602    /// A residual filter that is carried to the wrong level, or an anti-join
1603    /// pushed where the null semantics differ, produces a *faster wrong
1604    /// answer* — the one outcome worse than the 4309 s.
1605    #[tokio::test]
1606    async fn q21_results_are_identical_with_and_without_the_rule() {
1607        let with = rows(&context(true), Q21_VERBATIM).await;
1608        let without = rows(&context(false), Q21_VERBATIM).await;
1609        assert_eq!(with, without, "q21 diverged under the rewrite");
1610        assert!(
1611            !with.is_empty(),
1612            "the q21 fixture must produce rows or it proves nothing"
1613        );
1614    }
1615
1616    /// Each half of the relaxation, isolated: a bare `EXISTS` (semi + residual)
1617    /// and a bare `NOT EXISTS` (anti + residual). Testing only the full q21
1618    /// would let one of the two regress silently behind the other.
1619    #[tokio::test]
1620    async fn semi_and_anti_with_a_residual_each_keep_their_answers() {
1621        for sql in [
1622            // EXISTS: LeftSemi carrying `l_suppkey <> l_suppkey`
1623            "SELECT s_name FROM supplier, lineitem l1 \
1624             WHERE s_suppkey = l1.l_suppkey \
1625               AND EXISTS (SELECT * FROM lineitem l2 \
1626                           WHERE l2.l_orderkey = l1.l_orderkey \
1627                             AND l2.l_suppkey <> l1.l_suppkey)",
1628            // NOT EXISTS: LeftAnti carrying the same residual
1629            "SELECT s_name FROM supplier, lineitem l1 \
1630             WHERE s_suppkey = l1.l_suppkey \
1631               AND NOT EXISTS (SELECT * FROM lineitem l3 \
1632                               WHERE l3.l_orderkey = l1.l_orderkey \
1633                                 AND l3.l_suppkey <> l1.l_suppkey \
1634                                 AND l3.l_receiptdate > l3.l_commitdate)",
1635            // anti-join whose residual makes it keep *everything*, and one
1636            // that makes it keep nothing — the two ends of the range
1637            "SELECT s_name FROM supplier, lineitem l1 \
1638             WHERE s_suppkey = l1.l_suppkey \
1639               AND NOT EXISTS (SELECT * FROM lineitem l3 \
1640                               WHERE l3.l_orderkey = l1.l_orderkey \
1641                                 AND l3.l_suppkey <> l1.l_suppkey \
1642                                 AND l3.l_quantity > 100000)",
1643        ] {
1644            let with = rows(&context(true), sql).await;
1645            let without = rows(&context(false), sql).await;
1646            assert_eq!(with, without, "results diverged for:\n{sql}");
1647        }
1648    }
1649
1650    /// The rewrite must actually fire on q21, not merely stay correct by
1651    /// declining. `filter.is_some()` used to reject this shape outright, so a
1652    /// results-only test would have passed against the unfixed rule.
1653    #[tokio::test]
1654    async fn the_q21_semi_and_anti_joins_are_pushed_below_the_inner_join() {
1655        let with = plan_of(&context(true), Q21_VERBATIM).await;
1656        let without = plan_of(&context(false), Q21_VERBATIM).await;
1657
1658        let first_inner = |p: &str| p.lines().position(|l| l.contains("Inner Join"));
1659        let first_semi = |p: &str| {
1660            p.lines()
1661                .position(|l| l.contains("LeftSemi") || l.contains("LeftAnti"))
1662        };
1663
1664        let (bs, bi) = (first_semi(&without), first_inner(&without));
1665        assert!(
1666            bs.is_some() && bi.is_some() && bs < bi,
1667            "baseline should have the existence joins above the inner join:\n{without}"
1668        );
1669
1670        let (ws, wi) = (first_semi(&with), first_inner(&with));
1671        assert!(
1672            ws.is_some() && wi.is_some(),
1673            "expected both join kinds in:\n{with}"
1674        );
1675        assert!(
1676            ws > wi,
1677            "q21's existence joins must be pushed below the inner join:\n{with}"
1678        );
1679    }
1680
1681    /// A residual that straddles both children of the inner join genuinely
1682    /// depends on the joined row, so the rewrite must still decline.
1683    ///
1684    /// This is the guard the relaxation could most easily have dropped: the
1685    /// residual is carried down, and without the column check it would be
1686    /// re-attached at a level where one of its columns does not exist yet.
1687    #[tokio::test]
1688    async fn a_residual_straddling_both_children_is_not_pushed() {
1689        let sql = "SELECT s_name FROM supplier, lineitem l1, orders \
1690            WHERE s_suppkey = l1.l_suppkey AND o_orderkey = l1.l_orderkey \
1691              AND EXISTS (SELECT * FROM lineitem l2 \
1692                          WHERE l2.l_orderkey = l1.l_orderkey \
1693                            AND l2.l_quantity > orders.o_totalprice)";
1694        // Correctness is the assertion; whether it fires is the optimizer's
1695        // choice, but it must not produce a different answer either way.
1696        assert_eq!(
1697            rows(&context(true), sql).await,
1698            rows(&context(false), sql).await,
1699            "a straddling residual must not change the answer"
1700        );
1701    }
1702
1703    /// Physical plan text, which is where a missing equijoin key becomes
1704    /// visible: the logical plan looks fine either way.
1705    async fn physical_plan_of(ctx: &SessionContext, sql: &str) -> String {
1706        let logical = ctx.sql(sql).await.unwrap().into_optimized_plan().unwrap();
1707        let physical = ctx.state().create_physical_plan(&logical).await.unwrap();
1708        format!(
1709            "{}",
1710            datafusion::physical_plan::displayable(physical.as_ref()).indent(false)
1711        )
1712    }
1713
1714    /// **The rule must never turn an equi-join into a nested loop.**
1715    ///
1716    /// It did, and this is the most expensive bug the audit found. The
1717    /// rewrites were built with `join_on`, which does not populate the join's
1718    /// `on` list — it parks the whole conjunction in `filter` and relies on
1719    /// `extract_equijoin_predicate` to hoist the equalities afterwards. That
1720    /// rule has already run by the time these fire, so nothing hoisted them,
1721    /// and the physical planner saw a join with no keys and chose
1722    /// `NestedLoopJoinExec` — an O(n*m) scan of a pure equi-join.
1723    ///
1724    /// On TPC-H q2 at SF100 that was 1424 s against Spark's 78 s (18.4x).
1725    /// `stage_dump` counted two `NestedLoopJoinExec` nodes with the rule on
1726    /// and zero with `KRISHIV_SEMI_JOIN_REDUCTION=off`: the optimization was
1727    /// the pessimization.
1728    ///
1729    /// Every prior test here passed throughout, because they compare answers
1730    /// and logical-plan shape — both of which stayed correct. Only the
1731    /// physical plan showed it.
1732    #[tokio::test]
1733    async fn the_rewrites_never_produce_a_nested_loop_join() {
1734        for sql in [
1735            Q17_SHAPE,
1736            Q18_SHAPE,
1737            Q18_VERBATIM,
1738            Q21_VERBATIM,
1739            // q2's shape: a correlated scalar subquery whose decorrelation
1740            // feeds the pushdown rule.
1741            "SELECT s.s_name FROM supplier s, lineitem l \
1742             WHERE s.s_suppkey = l.l_suppkey \
1743               AND l.l_quantity = (SELECT min(l2.l_quantity) FROM lineitem l2 \
1744                                   WHERE l2.l_orderkey = l.l_orderkey)",
1745        ] {
1746            let plan = physical_plan_of(&context(true), sql).await;
1747            assert!(
1748                !plan.contains("NestedLoopJoin"),
1749                "the rewrite produced a nested-loop join — an equi-join lost \
1750                 its keys — for:\n{sql}\n\n{plan}"
1751            );
1752        }
1753    }
1754
1755    /// An outer join below null-pads its non-preserved side, so a key that is
1756    /// null after the join was not null before it. Filtering earlier would keep
1757    /// different rows, and the rule must decline.
1758    #[tokio::test]
1759    async fn a_semi_join_is_not_pushed_through_an_outer_join() {
1760        let sql = "SELECT o.o_orderkey FROM orders o LEFT JOIN customer c \
1761            ON c.c_custkey = o.o_custkey \
1762            WHERE o.o_orderkey IN (SELECT l_orderkey FROM lineitem \
1763                                   GROUP BY l_orderkey HAVING sum(l_quantity) > 100)";
1764        assert_eq!(
1765            rows(&context(true), sql).await,
1766            rows(&context(false), sql).await,
1767            "outer join below must not change the answer"
1768        );
1769    }
1770}