Skip to content

fix: preserve outer join equivalences for null rows - #26042

Closed
Prajwal-k-tech wants to merge 5 commits into
apache:mainfrom
Prajwal-k-tech:prajwal/26036-outer-join-equivalence
Closed

Prajwal-k-tech wants to merge 5 commits into
apache:mainfrom
Prajwal-k-tech:prajwal/26036-outer-join-equivalence

Conversation

@Prajwal-k-tech

Copy link
Copy Markdown

Which issue does this PR close?

Rationale for this change

After a LEFT, RIGHT, or FULL OUTER JOIN, the nullable side can contain rows whose columns were extended with NULLs. An equivalence such as b = coalesce(a, 0) is true on the input rows but does not remain true for those null-extended rows. Retaining it can lead to incorrect ordering and LIMIT results.

What changes are included in this PR?

  • Filter nullable-side equivalence classes against a one-row all-NULL batch for the outer-join cases where that side may be null-extended.
  • Ignore volatile expressions during this check and discard classes that are no longer meaningful.
  • Add LEFT and FULL OUTER JOIN SQL regression cases, including the reported ORDER BY/LIMIT behavior.

What is the testing strategy for this PR?

  • Verified the new SQL cases fail on the unmodified code and pass with this change.
  • cargo test -p datafusion-sqllogictest --test sqllogictests -- joins
  • cargo test -p datafusion-physical-expr (1,685 passed, 2 ignored; 13 doctests passed)
  • RUST_BACKTRACE=1 cargo test -p datafusion (passed)
  • RUST_BACKTRACE=1 cargo test -p datafusion-cli (passed)
  • cargo clippy --all-targets --all-features -- -D warnings (passed)
  • cargo +1.99.0 fmt --all -- --check and git diff --check (passed)

This contribution was prepared with AI assistance.

Are there any user-facing changes?

This fixes incorrect query results for affected outer joins. There are no API changes.

Copilot AI balanced review requested due to automatic review settings October 4, 2026 22:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) auto detected api change Auto detected API change labels Oct 4, 2026
@Prajwal-k-tech

Copy link
Copy Markdown
Author

I pushed a regression fix for the CI failure: zero-column batches now preserve their row count, with a focused Full-join test. GitHub marked the new fork workflow runs as action_required before starting jobs. Could a maintainer approve them so CI can verify the updated commit?

@Prajwal-k-tech

Copy link
Copy Markdown
Author

I also addressed the semver bot feedback by restoring the original public EquivalenceGroup::join signature and using a crate-internal schema-aware helper for join planning. The updated head is 5f1cabc; GitHub has created another set of action_required workflows for this commit. Could a maintainer approve the latest runs?

@kumarUjjawal
kumarUjjawal self-requested a review October 5, 2026 06:59
@github-actions github-actions Bot removed the auto detected api change Auto detected API change label Oct 5, 2026
@alamb

alamb commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Thanks @Prajwal-k-tech

Which of these Prs would you like us to review first?

I think it will be eaier to focus and get one done / in rather than trying to do a bunch in parallel

@Prajwal-k-tech

Copy link
Copy Markdown
Author

No preference on my end; going numerically (#26040, then #26041, then #26042) works well. Thanks!

@kumarUjjawal kumarUjjawal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @Prajwal-k-tech

Left few comments please take a look.

let expressions = class.into_iter().filter(|expr| {
!is_volatile(expr)
&& expr
.evaluate(null_batch)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Planning now runs every non-volatile expression in an equivalence class, including user and third-party ScalarUDFs, on a synthetic all-NULL batch; it does this every time join properties are recomputed, and a panic inside evaluate is not caught.

&& expr
.evaluate(null_batch)
.and_then(|value| value.into_array(1))
.is_ok_and(|array| array.null_count() == 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

array.null_count() == 1 reads the physical null buffer, so arrays with only logical nulls report 0 and their valid equivalences are dropped.

/// that may no longer hold after an outer join.
fn with_null_preserving_expressions(self, null_batch: &RecordBatch) -> Self {
let classes = self.classes.into_iter().filter_map(|class| {
let expressions = class.into_iter().filter(|expr| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter keeps only members that evaluate to NULL, so it drops members that still agree on null-extended rows because they evaluate to the same non-NULL value.

)?);
}
JoinType::Full => {
let batch = null_batch(join_schema)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null_batch allocates a new schema and one null array per output column on every outer-join property computation, even when the nullable side has no equivalence classes.**

schema
.fields()
.iter()
.map(|field| Field::new(field.name(), field.data_type().clone(), true))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null_batch rebuilds each field with Field::new(name, type, true), which drops field metadata such as extension-type annotations.

statement ok
CREATE TABLE outer_join_sort_right (a BIGINT, b BIGINT) AS VALUES (2, 2), (3, 3);

query III

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regression tests cover only LEFT and FULL OUTER JOIN; the new JoinType::Right branch (class.rs:874) and the cross-join path are never exercised.

Ok(())
}

use datafusion_expr::Operator;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new test full_join_equivalences_support_empty_schema was inserted between the test module's use lines, leaving use datafusion_expr::Operator; stranded after a function.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public EquivalenceGroup::join (no schema) keeps the old unsound outer-join behavior with no doc warning or deprecation, so external callers still get wrong equivalences.

@Prajwal-k-tech

Copy link
Copy Markdown
Author

Thanks for the detailed review. I pushed the follow-up to the existing fork branch in commits 16a78c3 and 63cc845.

  • Outer-join pruning now replaces nullable-side columns with NULL structurally and retains equivalence groups whose rewritten expressions match, including expressions that produce the same non-NULL result. It does not evaluate physical expressions or ScalarUDFs during planning.
  • The synthetic RecordBatch is removed, avoiding its allocations, physical-null-buffer assumptions, and reconstructed fields that lost metadata.
  • The public EquivalenceGroup::join now applies the same conservative outer-join handling without requiring a schema; its docs describe that behavior.
  • Added coverage for RIGHT JOIN, a no-key inner-join path, and expressions with matching non-NULL results after null extension. Moved the stranded import.

Validation: cargo fmt --all -- --check, workspace Clippy (cargo clippy --all-targets --all-features -- -D warnings), all 1,690 datafusion-physical-expr unit tests (1,688 passed, 2 ignored), and the focused joins.slt suite pass. The repository-wide extended tests could not complete because this checkout lacks datafusion/testing/data; a representative failure reports that ARROW_TEST_DATA is unset and asks for the test-data submodule. I have not counted that suite as passing.

/// Replace input columns with NULL without evaluating the expression.
fn null_extended_expr(expr: &PhysicalExprRef) -> Option<PhysicalExprRef> {
if expr.downcast_ref::<Column>().is_some() {
return Some(Arc::new(Literal::new(ScalarValue::Null)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we preserve the type information, or drop these expressions when we cannot prove they remain equal? A regression test with a type-sensitive UDF would help cover this case.

@github-actions

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion-physical-expr v55.1.0 (current)
       Built [  23.081s] (current)
     Parsing datafusion-physical-expr v55.1.0 (current)
      Parsed [   0.039s] (current)
    Building datafusion-physical-expr v55.1.0 (baseline)
       Built [  21.954s] (baseline)
     Parsing datafusion-physical-expr v55.1.0 (baseline)
      Parsed [   0.042s] (baseline)
    Checking datafusion-physical-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.281s] 223 checks: 219 pass, 4 fail, 0 warn, 31 skip

--- failure function_missing: pub fn removed or renamed ---

Description:
A publicly-visible function cannot be imported by its prior path. A `pub use` may have been removed, or the function itself may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/function_missing.ron

Failed in:
  function datafusion_physical_expr::proto::physical_expr_names, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/c33ee42c471ae50dfe92ebd75fd10336145ab968/datafusion/physical-expr/src/proto.rs:225
  function datafusion_physical_expr::proto::extension_expr_node, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/c33ee42c471ae50dfe92ebd75fd10336145ab968/datafusion/physical-expr/src/proto.rs:118
  function datafusion_physical_expr::proto::register_physical_expr, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/c33ee42c471ae50dfe92ebd75fd10336145ab968/datafusion/physical-expr/src/proto.rs:185

--- failure inherent_method_missing: pub method removed or renamed ---

Description:
A publicly-visible method or associated fn is no longer available under its prior name. It may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/inherent_method_missing.ron

Failed in:
  CastExpr::with_new_expr_and_target_field, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/c33ee42c471ae50dfe92ebd75fd10336145ab968/datafusion/physical-expr/src/expressions/cast.rs:292
  Partitioning::keeps_keys_local, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/c33ee42c471ae50dfe92ebd75fd10336145ab968/datafusion/physical-expr/src/partitioning.rs:851

--- failure module_missing: pub module removed or renamed ---

Description:
A publicly-visible module cannot be imported by its prior path. A `pub use` may have been removed, or the module may have been renamed, removed, or made non-public.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/module_missing.ron

Failed in:
  mod datafusion_physical_expr::proto, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/c33ee42c471ae50dfe92ebd75fd10336145ab968/datafusion/physical-expr/src/proto.rs:18

--- failure trait_missing: pub trait removed or renamed ---

Description:
A publicly-visible trait cannot be imported by its prior path. A `pub use` may have been removed, or the trait itself may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/trait_missing.ron

Failed in:
  trait datafusion_physical_expr::proto::ExtensionExprFromProto, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/c33ee42c471ae50dfe92ebd75fd10336145ab968/datafusion/physical-expr/src/proto.rs:87

     Summary semver requires new major version: 4 major and 0 minor checks failed
    Finished [  46.684s] datafusion-physical-expr
    Building datafusion-sqllogictest v55.1.0 (current)
       Built [  67.234s] (current)
     Parsing datafusion-sqllogictest v55.1.0 (current)
      Parsed [   0.013s] (current)
    Building datafusion-sqllogictest v55.1.0 (baseline)
       Built [  69.140s] (baseline)
     Parsing datafusion-sqllogictest v55.1.0 (baseline)
      Parsed [   0.013s] (baseline)
    Checking datafusion-sqllogictest v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.073s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure inherent_method_missing: pub method removed or renamed ---

Description:
A publicly-visible method or associated fn is no longer available under its prior name. It may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/inherent_method_missing.ron

Failed in:
  DataFusionSubstraitRoundTrip::with_optimization, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/c33ee42c471ae50dfe92ebd75fd10336145ab968/datafusion/sqllogictest/src/engines/datafusion_substrait_roundtrip_engine/runner.rs:60

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [ 138.610s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Oct 23, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.88525% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.72%. Comparing base (c3ef346) to head (63cc845).
⚠️ Report is 274 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-expr/src/equivalence/class.rs 88.13% 2 Missing and 12 partials ⚠️
.../physical-expr/src/equivalence/properties/joins.rs 50.00% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26042      +/-   ##
==========================================
+ Coverage   82.66%   82.72%   +0.06%     
==========================================
  Files        1147     1147              
  Lines      446357   448289    +1932     
  Branches   446357   448289    +1932     
==========================================
+ Hits       368971   370843    +1872     
+ Misses      54997    54900      -97     
- Partials    22389    22546     +157     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

limadog9 pushed a commit to limadog9/datafusion that referenced this pull request Oct 23, 2026
…ache#26570)

## Which issue does this PR close?

- None. This PR only adds tests, so there is no bug or feature issue to
close.

## Rationale for this change

The planner skips sorts and keeps streaming execution when it can prove
that the data already has the needed
order. It decides this from what each plan node knows about its output:
which orderings hold, which columns are
constant, and which columns are equal. Several of these decisions have
no test today:

- a union that stays sorted when each side has a cast of the sort key in
a different column,
- a constant from a filter that survives a sort with a limit or a cross
join,
- the order in which GROUP BY keys are matched against the input's
orderings,
- an order-preserving repartition over an unbounded input that keeps a
cast of its sort key sorted.

Two open PRs show why this matters. apache#26459 and apache#26042 change how
constants pass through outer joins. Cross
join derives its output facts as if it were a full outer join, so both
would make cross-join queries sort on
columns that are still constant, and no existing test fails. The
cross-join cases added here fail with either PR
merged in.

Without tests, a change to how the planner tracks these facts could add
sorts, change the aggregation mode, or
make a streaming query fail to plan, and nothing would catch it. This PR
pins the plans that main produces today.

## What changes are included in this PR?

A new `datafusion/sqllogictest/test_files/equivalence_properties.slt`,
with one comment per case that says which
planner decision it pins:

- A union whose sides each have a cast of `a` in a different column is
still known to be sorted by `a`.
- `GROUP BY d, c, b, a` over data sorted by `(a, b)` and by `c` finds
`c, a, b` sorted and runs in partially
  sorted mode on them.
- A filter constant survives an `ORDER BY ... LIMIT`, so an outer sort
that starts with that constant is removed.
- A cross join keeps the constants of its left input.
- A cross join keeps a right-side constant set by an equality filter.
- Over an unbounded input, an order-preserving repartition keeps a cast
of its sort key sorted, so a window
  partitioned by that cast needs no sort.

In `datafusion/core/tests/physical_optimizer/enforce_distribution.rs`, a
new test checks that sort-merge join
keys are realigned to a range partitioning when the second of two
orderings of an unbounded input matches it, not
only the first. The existing `ordered_stream` helper now takes a list of
orderings so the new test can reuse it.

## What is the testing strategy for this PR?

This PR adds tests only. All new cases pass on the current main branch,
and the new sqllogictest file gives the
same result over repeated runs.

## Are there any user-facing changes?

No.
mbutrovich pushed a commit to mbutrovich/datafusion that referenced this pull request Oct 23, 2026
…#26459)

## Which issue does this PR close?

- Closes apache#26283.

## Rationale for this change

A query that orders by a column that is constant on the probe side of a
join fails to plan:

```text
SanityCheckPlan
caused by
Error during planning: Plan: ["SortPreservingMergeExec: [k@0 DESC]", "  HashJoinExec: ..."] does not satisfy order requirements: [k@0 DESC]. Child-0 order: []
```

`EquivalenceClass::try_with_offset` builds a new class from the
expressions and does not copy `constant`. `EquivalenceGroup::join` uses
it for every class of the right side, so the join output forgets that
`k` is constant. `EnsureRequirements` removes the sort because `k` is
constant below the join, and `SanityCheckPlan` then rejects the plan
because the join output no longer says so.

## What changes are included in this PR?

- `try_with_offset` keeps the class's constant value.
- `EquivalenceGroup::join` clears the right side's constants for `Left`
and `Full` joins. Unmatched rows pad that side with NULLs, so a constant
there no longer holds. Without this, copying the constant would drop a
sort that is needed.

The first version of this PR added a sort in `EnsureRequirements`
wherever the requirement could not be proven. Following review, it now
fixes the join instead. The plan for apache#26283 then has no sort at all,
instead of a sort on a constant column.

The same problem exists the other way round: the left side's constants
are kept for `Right` and `Full` joins, where that side is padded with
NULLs too. For example, this returns `5` before the NULLs:

```sql
CREATE TABLE t(a INT) AS VALUES (1), (2), (3);
CREATE TABLE u(a INT) AS VALUES (9), (10), (1);
SELECT s.k, u.a FROM (SELECT a, 5 AS k FROM t) s RIGHT JOIN u ON s.a = u.a
ORDER BY s.k DESC NULLS FIRST;
```

This PR does not change the left side, because apache#26042 already rebuilds
the classes of the side that is padded with NULLs, which drops these
constants as well.

## What is the testing strategy for this PR?

- `class.rs`: a unit test that a right-side constant stays constant
after `Inner` and `Right` joins, and not after `Left` and `Full` joins.
- `order.slt`: both queries from apache#26283, with results and `EXPLAIN`.
Without the change, both fail to plan.
- `order.slt`: a `LEFT JOIN` where the probe-side constant is padded
with NULLs. If the constant is not cleared, the sort on it is dropped
and the rows come back in the wrong order.

## Are there any user-facing changes?

Queries like the one in apache#26283 plan and run instead of failing. There
are no API changes.
@alamb

alamb commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Thanks for the contribution! I am closing this under DataFusion's AI Dump policy.

https://datafusion.apache.org/contributor-guide/ai-policy.html#what-is-an-ai-dump-and-why-it-is-not-helpful

This has the hallmarks of AI code that was not reviewed by the author: an initial version that evaluated expressions against a synthetic all-NULL batch at plan time, bullet-list replies ending in command runs, and reviewer questions left unanswered.

If you would like to continue, please open a focused PR in your own words that you have reviewed and understand, and we are happy to help with review.

@alamb alamb closed this Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change physical-expr Changes to the physical-expr crates Prolific Author author opened PRs or issues in more than 20 repositories in the last 30 days sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong ORDER BY results after an outer join when the nullable side equates a column to coalesce/CASE

5 participants