java.lang.Object
com.darkcollective.relix.optimizer.internal.OuterJoinDemotionPass

public final class OuterJoinDemotionPass extends Object
Outer-join demotion (JOIN-004) — turning an outer join into a less outer one when a filter above it makes the padded rows unreachable.

   σ p (A ⟕ B)   ≡   σ p (A ⨝θ B)     when p rejects NULLs on a column of B
 

An outer join emits, besides the matched rows, rows padded with NULLs on the side that had no match. If a predicate above the join can never be true of a row whose column c is NULL, and c comes from the padded side, then those padded rows are all filtered out — so producing them was work for nothing, and the join is equivalent to an inner one.

Why it is worth a rule of its own

Demotion is a gate in front of three optimizations an outer join simply cannot have:

  1. σ pushdown into both sides. SelectionPushdownPass pushes into ⟕'s left only and ⟖'s right only; an inner join takes both.
  2. Merge eligibility and SQL pushdown. Planner.mergeEligible is {INNER, SEMI, ANTI}, so an outer join can never take a sort-merge plan, and SqlPushdownPlanner folds only theta joins into a JOIN … ON.
  3. EQ-001. It reads facts out of inner-join conditions only, so a demoted join becomes eligible for equality propagation — which is why this rule runs before it in the pushdown phase.

The planner additionally builds the cheaper side of an inner join by cost (Planner.buildSide), a choice it does not make for the asymmetric join kinds.

What each shape demotes to

A ⟗ loses one half at a time, because each half is killed by a predicate on the other side's columns — the unmatched-left rows are the ones carrying NULL right columns:

Demotion table
Joinrejects on left columnsrejects on right columns
⟕—⨝θ
⟖⨝θ—
⟗⟕⟖

A ⟗ whose filter rejects on both sides loses both halves at once and goes straight to ⨝θ — one step, one record.

Null rejection

A predicate rejects NULL on c when it can never evaluate to true for a row whose c is NULL:

  • a comparison, a LIKE, or an ∈ whose operand is c — rejects;
  • c IS NOT NULL — rejects; c IS NULL — does not, and that is the trap: σ B.x IS NULL (A ⟕ B) is the anti-join idiom, and demoting it would return the exact opposite rows;
  • ∧ — rejects if either side does; ∨ — only if both do;
  • anything computed from c — a function call, an arithmetic expression — does not reject, conservatively. Relix has Nz, Coalesce and IIf, which exist precisely to map NULL to something truthy.

The ¬ arm is deliberately blunt: only ¬(c IS NULL) is treated as rejecting. Under three-valued logic ¬(c = 5) also rejects — negating unknown leaves it unknown, which is not true — but establishing that in general needs the dual analysis ("can this be false when c is NULL?") rather than an inversion of this one. Under-firing costs an optimization; getting it wrong costs the answer.

Which side a column belongs to is resolved by JoinSides, so a reference either input could own never counts as rejecting.

This class is package-private and stateless; call apply(RelNode, String, SchemaAnnotations, OptimizationContext) as a static method.