Skip to content

feat: support ASOF JOIN SQL - #23830

Open
Xuanwo wants to merge 9 commits into
apache:mainfrom
Xuanwo:xuanwo/asof-sql
Open

feat: support ASOF JOIN SQL#23830
Xuanwo wants to merge 9 commits into
apache:mainfrom
Xuanwo:xuanwo/asof-sql

Conversation

@Xuanwo

@Xuanwo Xuanwo commented Jul 23, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

This is the SQL frontend layer of the ASOF JOIN stack. It adds syntax and
unparsing on top of the merged physical and logical contracts. The PR now
contains only the isolated SQL frontend diff.

What changes are included in this PR?

  • Plan ASOF JOIN ... MATCH_CONDITION (...) with optional ON or USING
    equality keys.
  • Reject unsupported match shapes and non-equality ON predicates.
  • Unparse ASOF joins while preserving right-side candidate preselection and
    nested join scope.
  • Document the supported SQL syntax and semantics, including qualified
    USING keys, left-partitioned broadcast execution, the full-right memory
    requirement, repeated scans, and the absence of spill/repartitioned ASOF.
  • Add SQL integration and sqllogictest coverage for all four match directions,
    coercion, equality-free joins, USING, invalid contracts, EXPLAIN, boundedness,
    and optimized-plan round trips.
  • Verify the broadcast topology with a multi-partition left input: left
    partitioning is preserved while the right input is single-partitioned.

Are these changes tested?

Yes:

  • cargo fmt --all
  • ./ci/scripts/doc_prettier_check.sh --write --allow-dirty
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test -p datafusion --test core_integration asof --all-features
  • cargo test -p datafusion-sqllogictest --test sqllogictests --all-features -- asof_join
  • The extended workspace test command from the contributor guide

Are there any user-facing changes?

Users can express Snowflake-style ASOF joins in SQL with
MATCH_CONDITION, optional equality keys, and <, <=, >, or >= match
directions. With USING, wildcard output exposes one unqualified key while
both qualified input keys remain addressable. The user guide also documents
the initial broadcast strategy and its memory/no-spill limitations.

#23829 and #23828 are merged. This is the next core layer in the ASOF stack and
does not depend on the optional floating-point follow-up #24375.

@github-actions github-actions Bot added documentation Improvements or additions to documentation sql SQL Planner logical-expr Logical plan and expressions optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) substrait Changes to the substrait crate proto Related to proto crate physical-plan Changes to the physical-plan crate labels Jul 23, 2026
@codecov-commenter

codecov-commenter commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.80967% with 90 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.68%. Comparing base (9fc7a4d) to head (e6f6858).
⚠️ Report is 21 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/sql/src/unparser/plan.rs 61.93% 57 Missing and 10 partials ⚠️
datafusion/sql/src/relation/join.rs 77.35% 5 Missing and 7 partials ⚠️
datafusion/expr/src/logical_plan/builder.rs 87.77% 3 Missing and 8 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23830      +/-   ##
==========================================
+ Coverage   81.57%   81.68%   +0.10%     
==========================================
  Files        1123     1124       +1     
  Lines      410789   413484    +2695     
  Branches   410789   413484    +2695     
==========================================
+ Hits       335120   337752    +2632     
+ Misses      55913    55834      -79     
- Partials    19756    19898     +142     

☔ 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.

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Jul 23, 2026
@github-actions github-actions Bot added the physical-expr Changes to the physical-expr crates label Aug 14, 2026
@github-actions github-actions Bot removed physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate labels Aug 28, 2026
@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Aug 30, 2026
# Conflicts:
#	datafusion/expr/src/logical_plan/builder.rs
#	datafusion/expr/src/logical_plan/plan.rs
@github-actions github-actions Bot removed optimizer Optimizer rules substrait Changes to the substrait crate labels Sep 4, 2026
@github-actions github-actions Bot removed proto Related to proto crate physical-plan Changes to the physical-plan crate labels Sep 4, 2026
@Xuanwo
Xuanwo marked this pull request as ready for review September 4, 2026 03:47
@Xuanwo

Xuanwo commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

cc @2010YOUY01, this PR is good to go! 🚀

Also CC @jayzhan211: would you like to follow this stack's review? Happy to ping you if you're interested. 😄

@github-actions github-actions Bot removed the auto detected api change Auto detected API change label Sep 4, 2026
@jayzhan211

Copy link
Copy Markdown
Contributor

cc @2010YOUY01, this PR is good to go! 🚀

Also CC @jayzhan211: would you like to follow this stack's review? Happy to ping you if you're interested. 😄

Of course!

@jayzhan211 jayzhan211 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.

Thanks @Xuanwo Minor issues left, others LGTM

Comment thread datafusion/sql/src/unparser/plan.rs Outdated
Comment thread datafusion/sql/src/unparser/plan.rs

@2010YOUY01 2010YOUY01 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, this is great as always. Here is some sugestions:

Before merging

  • I left a few suggestions on the logical plan APIs — curious what you think. If we can agree on that, let's update them before merging.
  • Add some simple tests for the unparser.

Potential follow-ups

  • Consolidate the Rust integration tests into slt.
  • More slt coverage. Below is the coverage gap suggested by AI:
Click to expand

ASOF slt: extra coverage

  1. Outer WHERE on a right column must stay above the join
    Only correctness property with no guard. If filter pushdown later learns
    about ASOF and pushes this into the right input, candidate selection changes.
    SELECT l.id, r.val FROM asof_left l ASOF JOIN asof_right r
    MATCH_CONDITION (l.ts >= r.ts) ON l.grp = r.grp WHERE r.val <> 'a6';
    Expect: EXPLAIN shows Filter above AsOf Join; ids 2,4,5 (id 3 dropped, not remapped to a4).

  2. Projection pruning through the node
    Existing EXPLAINs select every column, so they can't show pruning.
    EXPLAIN SELECT l.id FROM asof_left l ASOF JOIN asof_right r
    MATCH_CONDITION (l.ts >= r.ts) ON l.grp = r.grp;
    Expect: TableScan: asof_right projection=[grp, ts]

  3. SELECT * with USING
    Docs promise the key appears once; file only selects named columns.
    SELECT * FROM asof_left l ASOF JOIN asof_right r
    MATCH_CONDITION (l.ts >= r.ts) USING (grp);
    Expect: 5 columns (id, grp, ts, ts, val), not 6.

  4. Expression operands
    Every test uses bare columns; side-ownership check on expressions is untested.
    MATCH_CONDITION (l.ts >= r.ts + INTERVAL '1 second')
    ON lower(l.grp) = lower(r.grp)

  5. Reversed equality sides in ON
    The main thing asof_join_on adds; only unit-tested in the builder.
    ON r.grp = l.grp -- same result as l.grp = r.grp

  6. Ordered types beyond timestamp/int
    String, DATE vs TIMESTAMP, float, tz/precision mismatch, dictionary keys.
    ON l.grp = r.grp where l.grp is arrow_cast(grp, 'Dictionary(Int32, Utf8)')
    Dictionary matters most: Parquet produces it.

  7. Empty inputs
    ASOF JOIN (SELECT * FROM asof_right WHERE false) r ... -- all r.* NULL
    FROM (SELECT * FROM asof_left WHERE false) l ... -- zero rows

  8. Duplicate left rows emitted once each
    FROM (SELECT * FROM asof_left UNION ALL SELECT * FROM asof_left) l ...
    Expect: 14 rows, each id twice with same match.

  9. Self join
    FROM asof_left a ASOF JOIN asof_left b MATCH_CONDITION (a.ts > b.ts) ON a.grp = b.grp

  10. Composition through the planner
    ASOF then inner join, GROUP BY over output, CREATE VIEW, WITH ... AS.
    ... ASOF JOIN asof_right r MATCH_CONDITION (...) ON ... JOIN asof_right r2 ON r.val = r2.val

  11. USING with multiple keys
    USING (venue, grp)

  12. Unpinned error messages
    Rust test only asserts "is_err"; none of these messages are checked anywhere.
    MATCH_CONDITION (1 >= r.ts) -> left operand must reference only the left input
    ON 1 = 1 -> must compare one left expression with one right
    MATCH_CONDITION (l.ts >= r.ts AND ...) -> requires <, <=, >, or >=, found AND
    ON l.grp = l.grp -> must compare one left expression with one right
    ON l.grp = r.grp AND l.ts >= r.ts -> accepts only equality conditions combined with AND
    SELECT ts ... USING (grp) -> Ambiguous reference to unqualified field ts
    unbounded left table -> AsOfJoinExec requires bounded inputs
    Note: NATURAL ASOF JOIN is rejected by sqlparser, so the planner branch is unreachable.

  13. EXPLAIN for the other two forms
    USING shows constraint=Using; no-key form shows on=[] in the physical plan.

  14. Comment on deferred optimizations
    Filter/limit pushdown into the left input and order reuse by ORDER BY
    don't happen yet. A note saves the next person from thinking it's a bug.

Ok(())
}

fn register_asof_test_tables(ctx: &SessionContext) -> Result<()> {

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.

Can we unify those tests into sqllogictests, this way I think it's easier to maintain. Unless there are some setups that is not possible to do in slt, we might want extra coverage here.

In order to move those tests checking partition properties, I think we can assert the end behavior (final plan shape), rather than internal properties.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed for the SQL-level cases. I kept the Rust tests that need multi-partition plans, unbounded inputs, or plan-to-SQL roundtrips. I’ll consolidate the remaining overlap in a follow-up.

@Xuanwo Xuanwo Sep 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Follow-up is in #24964.

Comment thread datafusion/expr/src/logical_plan/builder.rs Outdated
Comment thread datafusion/expr/src/logical_plan/builder.rs Outdated
Comment thread datafusion/expr/src/logical_plan/builder.rs Outdated
Comment thread datafusion/expr/src/logical_plan/builder.rs Outdated

// Keep ASOF-specific locals out of the recursive plan unparser's stack frame.
#[inline(never)]
fn asof_join_to_sql(

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.

I don't fully understand the unparser now, but my AI tool suggest it is good to go, with some cleanup advices:

AI review

The unparser is heavy relative to the join it mirrors. About 120 lines of branching on "already projected" versus not, and "needs a derived subquery" versus not, for each side. The regular join unparser has the same structural problem, so this is inherited rather than introduced. A cleaner alternative would be a small shared helper that takes a join input and returns both the relation and its projection items, used by both join kinds. That refactor is bigger than this PR should carry, but it would be a natural follow-up given the branch already touched the shared nesting helper.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Will treat this change as a follow up

Comment thread datafusion/sql/src/unparser/plan.rs
Comment thread docs/source/user-guide/sql/select.md
MATCH_CONDITION (l.ts = r.ts)
ON l.grp = r.grp;

query error ASOF MATCH_CONDITION left operand must reference only the left input

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.

Should this query get normalized internally, and make it valid? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I’d keep this invalid. Snowflake defines MATCH_CONDITION operand order semantically, so the left input must remain on the left. ON equality operands are still accepted in either order.

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.

I see, that's the snowflake behavior.

Comment thread datafusion/sqllogictest/test_files/asof_join.slt
@github-actions github-actions Bot added the substrait Changes to the substrait crate label Sep 6, 2026
@Xuanwo

Xuanwo commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Thank you @jayzhan211 @2010YOUY01 for the review!

I have updated the builder API and added direct unparser coverage, including nested ASOF inputs. I kept the setup-dependent Rust tests here and will handle broader SLT consolidation separately.

@Xuanwo
Xuanwo requested a review from 2010YOUY01 September 6, 2026 06:51
@github-actions

github-actions Bot commented Sep 6, 2026

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 v55.0.0 (current)
       Built [  59.523s] (current)
     Parsing datafusion v55.0.0 (current)
      Parsed [   0.036s] (current)
    Building datafusion v55.0.0 (baseline)
       Built [  57.835s] (baseline)
     Parsing datafusion v55.0.0 (baseline)
      Parsed [   0.035s] (baseline)
    Checking datafusion v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.636s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 119.745s] datafusion
    Building datafusion-expr v55.0.0 (current)
       Built [  29.044s] (current)
     Parsing datafusion-expr v55.0.0 (current)
      Parsed [   0.078s] (current)
    Building datafusion-expr v55.0.0 (baseline)
       Built [  29.270s] (baseline)
     Parsing datafusion-expr v55.0.0 (baseline)
      Parsed [   0.084s] (baseline)
    Checking datafusion-expr v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.170s] 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:
  LogicalPlanBuilder::asof_join, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/13b4144fa032bf81b7ddcf1c2dc2dc618eedbec2/datafusion/expr/src/logical_plan/builder.rs:1012
  LogicalPlanBuilder::asof_join, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/13b4144fa032bf81b7ddcf1c2dc2dc618eedbec2/datafusion/expr/src/logical_plan/builder.rs:1012
  LogicalPlanBuilder::asof_join, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/13b4144fa032bf81b7ddcf1c2dc2dc618eedbec2/datafusion/expr/src/logical_plan/builder.rs:1012
  LogicalPlanBuilder::asof_join, previously in file /home/runner/work/datafusion/datafusion/target/semver-checks/git-apache_main/13b4144fa032bf81b7ddcf1c2dc2dc618eedbec2/datafusion/expr/src/logical_plan/builder.rs:1012

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  60.589s] datafusion-expr
    Building datafusion-sql v55.0.0 (current)
       Built [  42.871s] (current)
     Parsing datafusion-sql v55.0.0 (current)
      Parsed [   0.032s] (current)
    Building datafusion-sql v55.0.0 (baseline)
       Built [  42.697s] (baseline)
     Parsing datafusion-sql v55.0.0 (baseline)
      Parsed [   0.032s] (baseline)
    Checking datafusion-sql v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.227s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  86.857s] datafusion-sql
    Building datafusion-sqllogictest v55.0.0 (current)
       Built [ 100.600s] (current)
     Parsing datafusion-sqllogictest v55.0.0 (current)
      Parsed [   0.021s] (current)
    Building datafusion-sqllogictest v55.0.0 (baseline)
       Built [ 101.892s] (baseline)
     Parsing datafusion-sqllogictest v55.0.0 (baseline)
      Parsed [   0.023s] (baseline)
    Checking datafusion-sqllogictest v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.097s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 205.196s] datafusion-sqllogictest
    Building datafusion-substrait v55.0.0 (current)
       Built [ 317.646s] (current)
     Parsing datafusion-substrait v55.0.0 (current)
      Parsed [   0.021s] (current)
    Building datafusion-substrait v55.0.0 (baseline)
       Built [ 317.616s] (baseline)
     Parsing datafusion-substrait v55.0.0 (baseline)
      Parsed [   0.018s] (baseline)
    Checking datafusion-substrait v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.227s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 637.471s] datafusion-substrait

@2010YOUY01

Copy link
Copy Markdown
Contributor

Thank you @jayzhan211 @2010YOUY01 for the review!

I have updated the builder API and added direct unparser coverage, including nested ASOF inputs. I kept the setup-dependent Rust tests here and will handle broader SLT consolidation separately.

Sounds like a good plan!

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 core Core DataFusion crate documentation Improvements or additions to documentation logical-expr Logical plan and expressions sql SQL Planner sqllogictest SQL Logic Tests (.slt) substrait Changes to the substrait crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants