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}