Skip to content

fix: keep a sub-expression as the canonical expression of its equivalence class - #26967

Open
abp002 wants to merge 3 commits into
apache:mainfrom
abp002:smj-self-referencing-equality
Open

abp002 wants to merge 3 commits into
apache:mainfrom
abp002:smj-self-referencing-equality

Conversation

@abp002

@abp002 abp002 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

A sort-merge join on (a, b) fails with SanityCheckPlan when a filter below it equates an expression with one of its own sub-expressions, such as x.a + x.b = x.a.

The filter puts a + b and a in one equivalence class, with a + b as the canonical expression because it comes first. normalize_expr rewrites bottom-up, so it replaces the a inside a + b with a + b and turns a + b into (a + b) + b. The join requires [a, b], which normalizes to [a + b, b], while the sort below it is on [a + b, b], which normalizes to [(a + b) + b, b], so the sort no longer satisfies the join. Written as x.a = x.a + x.b, the query works, because a is the canonical expression.

What changes are included in this PR?

When EquivalenceGroup::add_equal_conditions adds to a class, a member that is a sub-expression of the canonical expression now becomes the canonical expression. normalize_expr is unchanged, so classes without such a pair normalize as before.

What is the testing strategy for this PR?

The query from the issue is added to sort_merge_join.slt. It fails without the fix.

equivalence_properties.slt gets a plan that main already gets right and that a fix in normalize_expr itself would break: with a = b AND c = a + 1 above ORDER BY a + 1 LIMIT 100, the outer ORDER BY a + 1 needs no sort.

It also gets a query with two IN lists that hold the same values in a different order. They compare equal but hash differently, so both stay in the class, and the promotion must still stop.

Are there any user-facing changes?

No API changes. Queries like the one in the issue now plan instead of failing in SanityCheckPlan.

@github-actions github-actions Bot added physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) labels Oct 31, 2026

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

@abp002 thanks for tracking this down! The fix works, and on current main it also fixes the window case and #27287. I left three small asks inline: run the promotion in EquivalenceClass::push/extend so classes built through EquivalenceGroup::new or extend get it too, and add tests for the window shape and for the merge/push arms of add_equal_conditions.

/// of this class. Otherwise, with `a + b` as the canonical expression of a
/// class that also holds `a`, normalization would replace the `a` inside
/// `a + b` and turn `a + b` into `(a + b) + b`.
fn promote_sub_expressions(&mut self) {

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.

@abp002 thanks for the fix! Classes built through EquivalenceGroup::new or merged by EquivalenceGroup::extend (via bridge_classes) skip this promotion, so EquivalenceGroup::new([EquivalenceClass::new([a + b, a])]) still normalizes a to (a + b) + b. Both are public. Could you run the promotion inside EquivalenceClass::push and extend instead? Then every way of building a class gets it, and the four calls in add_equal_conditions go away:

         self.exprs.insert(expr);
+        self.promote_sub_expressions();
     }
@@
     pub fn extend(&mut self, other: Self) {
         self.exprs.extend(other.exprs);
+        self.promote_sub_expressions();
@@
                 self.classes[first_idx].extend(other_class);
-                self.classes[first_idx].promote_sub_expressions();
@@
                 self.classes[group_idx].push(right);
-                self.classes[group_idx].promote_sub_expressions();
@@
                 self.classes[group_idx].push(left);
-                self.classes[group_idx].promote_sub_expressions();
@@
-                let mut class = EquivalenceClass::new([left, right]);
-                class.promote_sub_expressions();
+                let class = EquivalenceClass::new([left, right]);

With this change on current main, the physical-expr unit tests and the full sqllogictest suite pass.

Unit test for both paths (fails on the current PR, passes with the change)
    #[test]
    fn test_sub_expression_is_canonical_on_every_path() -> Result<()> {
        let schema = abc_schema();
        let (a, b, c) = (col("a", &schema)?, col("b", &schema)?, col("c", &schema)?);
        let a_plus_b = binary(Arc::clone(&a), Operator::Plus, b, &schema)?;

        // A class built directly, with the larger expression first
        let group = EquivalenceGroup::new([EquivalenceClass::new([
            Arc::clone(&a_plus_b),
            Arc::clone(&a),
        ])]);
        assert!(group.normalize_expr(Arc::clone(&a_plus_b)).eq(&a));

        // Two classes bridged by `extend` through the shared `c`
        let mut group = EquivalenceGroup::default();
        group.add_equal_conditions(Arc::clone(&a_plus_b), Arc::clone(&c));
        let mut other = EquivalenceGroup::default();
        other.add_equal_conditions(c, Arc::clone(&a));
        group.extend(other);
        assert!(group.normalize_expr(a_plus_b).eq(&a));
        Ok(())
    }

Comment on lines +247 to +248
statement ok
DROP TABLE eq_in;

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.

@abp002 the same bug also breaks a window on main, without a sort-merge join: PARTITION BY a + b ORDER BY a under WHERE a + b = a fails at runtime with Expects PARTITION BY expression to be ordered. This PR fixes it, but nothing tests that path. Could you add it?

Suggested change
statement ok
DROP TABLE eq_in;
statement ok
DROP TABLE eq_in;
# With a + b = a, the window's PARTITION BY a + b is the same key as the
# ORDER BY a below it
statement ok
CREATE TABLE eq_sub (a INT, b INT) AS VALUES (1, 0), (2, 3), (1, 0), (5, 0);
query II
SELECT a, sum(b) OVER (PARTITION BY a + b ORDER BY a) FROM eq_sub WHERE a + b = a ORDER BY a;
----
1 0
1 0
5 0
statement ok
DROP TABLE eq_sub;

1 0 1 0

statement ok
DROP TABLE smj_self_eq;

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.

Removing the promotion after the merge (extend) or after push(right) in add_equal_conditions still passes the tests here, because the issue query only reaches the new-class arm. These two queries reach the other arms. Both fail with SanityCheckPlan on main, and each catches one of those removals:

Suggested change
DROP TABLE smj_self_eq;
DROP TABLE smj_self_eq;
statement ok
CREATE TABLE smj_self_eq_cd (a INT, b INT, c INT, d INT) AS VALUES (1, 0, 1, 1), (2, 3, 5, 2);
# a joins the class of a + b after the class exists
query IIII
SELECT x.a, x.b, y.a, y.b FROM smj_self_eq_cd x JOIN smj_self_eq_cd y ON x.a = y.a AND x.b = y.b WHERE x.a + x.b = x.c AND x.c = x.a;
----
1 0 1 0
# a reaches the class of a + b when two classes merge
query IIII
SELECT x.a, x.b, y.a, y.b FROM smj_self_eq_cd x JOIN smj_self_eq_cd y ON x.a = y.a AND x.b = y.b WHERE x.a + x.b = x.c AND x.a = x.d AND x.d = x.c;
----
1 0 1 0
statement ok
DROP TABLE smj_self_eq_cd;

statement ok
DROP TABLE eq_t;

# The two IN lists hold the same values in a different order, so they compare

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.

Optional cleanup: this test doesn't reach the loop bound in promote_sub_expressions. With the bound replaced by loop, the query still finishes, because neither IN list is inside the other. A promoted expression is always strictly inside the previous canonical expression, so it can't cycle. I'd drop this test, or change its comment so it doesn't claim to cover termination.

@abp002
abp002 force-pushed the smj-self-referencing-equality branch from 7b2d9b2 to a2bc6db Compare November 12, 2026 21:06
@abp002

abp002 commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review, and for the tests. I moved the promotion into EquivalenceClass::push and extend, so the four calls in add_equal_conditions are gone, and added the unit test, the window case and the two sort-merge join queries. All four queries fail on main and pass here.

I also dropped the IN test: as you say, it still finishes with the bound replaced by loop, so it wasn't covering termination.

Rebased on main.

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

@abp002 thanks for the updates, all four look good. The new queries fail on main and pass here, and the full sqllogictest suite passes. I left one optional test suggestion inline.

fn promote_sub_expressions(&mut self) {
// Each promoted expression is inside the previous canonical
// expression, so there are at most as many rounds as members.
for _ in 0..self.exprs.len() {

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.

Optional: no test needs a second round of this loop. With for _ in 0..1 every test still passes. A second round happens when extend brings in a + b and a behind an unrelated canonical expression. This test passes here and fails with one round:

#[test]
fn test_sub_expression_promotion_takes_several_rounds() -> Result<()> {
    let schema = abc_schema();
    let (a, b, c) = (col("a", &schema)?, col("b", &schema)?, col("c", &schema)?);
    let a_plus_b = binary(Arc::clone(&a), Operator::Plus, Arc::clone(&b), &schema)?;
    let a_plus_b_plus_c = binary(
        Arc::clone(&a_plus_b),
        Operator::Plus,
        Arc::clone(&c),
        &schema,
    )?;

    // `b * c` is canonical in `other`, so `a + b` stays ahead of `a` there
    let b_times_c = binary(b, Operator::Multiply, c, &schema)?;
    let other = EquivalenceClass::new([b_times_c, a_plus_b, Arc::clone(&a)]);
    let mut class = EquivalenceClass::new([a_plus_b_plus_c]);
    class.extend(other);
    assert!(class.canonical_expr().unwrap().eq(&a));
    Ok(())
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SanityCheckPlan error with sort-merge join when a filter says a + b = a

2 participants