Skip to content

fix: error instead of wrong result for LATERAL DISTINCT with a non-equality correlated filter - #27133

Open
CuteChuanChuan wants to merge 3 commits into
apache:mainfrom
CuteChuanChuan:issue-25808/lateral-distinct-error
Open

CuteChuanChuan wants to merge 3 commits into
apache:mainfrom
CuteChuanChuan:issue-25808/lateral-distinct-error

Conversation

@CuteChuanChuan

@CuteChuanChuan CuteChuanChuan commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

A LATERAL subquery with SELECT DISTINCT and a non-equality correlated filter returns duplicate rows. As suggested in the issue discussion, this PR makes the query error instead of returning wrong results (see @alamb 's comments).

What changes are included in this PR?

When the correlated filter is pulled above an Aggregate, its column is added to the group keys. For a DISTINCT, this only adds duplicate rows. EXISTS and IN ignore duplicates, so currently PullUpCorrelatedExpr skips the can_pull_over_aggregation check for every DISTINCT. However, LATERAL returns those duplicates.

This PR skips the check for a DISTINCT only when the duplicates cannot reach the result:

  • under EXISTS/IN, which ignore duplicates, or
  • when the pull up adds no group key that splits a group. A key is not added if it is already a DISTINCT key, and an added key splits no group if a correlated equality pins it to one value per outer row (decided by the existing is_partitionable_equality, as for the window pull up).

Otherwise, e.g. the issue's query, the LATERAL subquery is no longer decorrelated and fails with the same OuterReferenceColumn error that the GROUP BY form already gives.

What is the testing strategy for this PR?

  • lateral_join.slt: the issue's query now errors but the equality form still returns correct answer.
  • subquery.slt: EXISTS and IN with DISTINCT and a non-equality filter are still decorrelated and return the right rows. No existing test covered this case.

Are there any user-facing changes?

The LATERAL query in the issue now returns a This feature is not implemented error instead of wrong results.

@github-actions github-actions Bot added optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) labels Nov 5, 2026
@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

Hi @adriangb and @alamb ,
could you PTAL when you have a chance? 🙏 thanks!

@jayzhan211
jayzhan211 self-requested a review November 8, 2026 03:06

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

@CuteChuanChuan thanks for working on this! Erroring on the issue's query matches #25808. One change below keeps queries that are correct today from erroring too.

Comment thread datafusion/optimizer/src/decorrelate.rs Outdated
Comment on lines 576 to 586
// Only a DISTINCT under EXISTS/IN may skip this check. The pulled
// up column becomes an extra group key, which for a DISTINCT only
// adds duplicate rows, and EXISTS/IN ignore duplicates. Elsewhere,
// e.g. LATERAL, the duplicates reach the result. See #25808.
let is_distinct = aggregate.aggr_expr.is_empty();
if !is_distinct {
let is_exists_or_in =
self.exists_sub_query || self.in_predicate_opt.is_some();
let can_skip_check = is_distinct && is_exists_or_in;
if !can_skip_check {
self.can_pull_up = self.can_pull_up && self.can_pull_over_aggregation;
}

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.

This also turns LATERAL queries that are correct today into errors. When the correlated column is already a DISTINCT key, the pulled up filter reads only group keys, so it works like a HAVING and adds no duplicates. For example, SELECT DISTINCT t2.id FROM t2 WHERE t2.id > t1.id * 10 returns (1,20) (1,30) (2,30) on main and on 55.2.0, and an OuterReferenceColumn error here. The change below doesn't decorrelate anything main doesn't already handle correctly. It only skips the check when the pull up adds no group key:

-                // Only a DISTINCT under EXISTS/IN may skip this check. The pulled
-                // up column becomes an extra group key, which for a DISTINCT only
-                // adds duplicate rows, and EXISTS/IN ignore duplicates. Elsewhere,
-                // e.g. LATERAL, the duplicates reach the result. See #25808.
-                let is_distinct = aggregate.aggr_expr.is_empty();
-                let is_exists_or_in =
-                    self.exists_sub_query || self.in_predicate_opt.is_some();
-                let can_skip_check = is_distinct && is_exists_or_in;
-                if !can_skip_check {
-                    self.can_pull_up = self.can_pull_up && self.can_pull_over_aggregation;
-                }
                 let mut local_correlated_cols = BTreeSet::new();
@@
                     )?
                 };
 
+                // A DISTINCT may skip this check when the pull up adds no group
+                // key, or under EXISTS/IN. An added key only adds duplicate rows,
+                // which EXISTS/IN ignore. Elsewhere, e.g. LATERAL, the duplicates
+                // reach the result. See #25808.
+                let is_distinct = aggregate.aggr_expr.is_empty();
+                let is_exists_or_in =
+                    self.exists_sub_query || self.in_predicate_opt.is_some();
+                let adds_group_keys = missing_exprs
+                    .iter()
+                    .any(|expr| !aggregate.group_expr.contains(expr));
+                let can_skip_check = is_distinct && (is_exists_or_in || !adds_group_keys);
+                if !can_skip_check {
+                    self.can_pull_up = self.can_pull_up && self.can_pull_over_aggregation;
+                }
+
                 // if the original group expressions are empty, need to handle the Count bug
Per-query results on 55.2.0, main, this PR, and with the change; test for lateral_join.slt
LATERAL subquery (non-equality filter) 55.2.0 and main this PR with the change
issue: DISTINCT x WHERE y > k duplicates error error
DISTINCT y WHERE y > k correct error correct
DISTINCT x, y WHERE y > k correct error correct
LEFT JOIN LATERAL (DISTINCT y WHERE y > k) correct error correct
DISTINCT y + 1 WHERE y > k correct error error
# A non-equality filter on a column that is already a DISTINCT key adds no
# key, so the pull up acts as a HAVING and stays decorrelated.
query II
SELECT t1.id, sub.id
FROM t1, LATERAL (SELECT DISTINCT t2.id FROM t2 WHERE t2.id > t1.id * 10) AS sub
ORDER BY t1.id, sub.id;
----
1 20
1 30
2 30

With the change, lateral_join.slt, subquery.slt, joins.slt, the optimizer unit tests, and clippy pass, and your new tests still hold. The test fails on this PR as is.

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.

Thanks for pointing this out. I only considered whether duplicates matter, not whether they're created at all. Addressed in 5eed032.

# which both have t1_id = 1, are no longer merged.
# Expected result once supported: sub.t1_id = 1 and 2 once each, for every
# t1 row (6 rows).
statement error OuterReferenceColumn

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: two more shapes returned duplicates on main and now error, GROUP BY without aggregates and LEFT JOIN LATERAL. A statement error for each would pin them:

statement error OuterReferenceColumn
SELECT t1.id, sub.t1_id
FROM t1, LATERAL (SELECT t2.t1_id FROM t2 WHERE t2.id > t1.id GROUP BY t2.t1_id) AS sub;

statement error OuterReferenceColumn
SELECT t1.id, sub.t1_id
FROM t1 LEFT JOIN LATERAL (SELECT DISTINCT t2.t1_id FROM t2 WHERE t2.id > t1.id) AS sub ON true;

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 in 5eed032.

@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

@jayzhan211
PTAL when you have a chance, thanks 🙏.

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

@CuteChuanChuan thanks for the quick turnaround! One more case from my earlier suggestion needs the change inline. Optional: the description still says the check is skipped "only for a DISTINCT under EXISTS/IN"; it now also covers a pull up that adds no key.

Comment thread datafusion/optimizer/src/decorrelate.rs Outdated
Comment on lines +641 to +651
// A DISTINCT may skip this check when the pull up adds no group
// key, or under EXISTS/IN. An added key only adds duplicate rows,
// which EXISTS/IN ignore. Elsewhere, e.g. LATERAL, the duplicates
// reach the result. See #25808.
let is_distinct = aggregate.aggr_expr.is_empty();
let is_exists_or_in =
self.exists_sub_query || self.in_predicate_opt.is_some();
let adds_group_keys = missing_exprs
.iter()
.any(|expr| !aggregate.group_expr.contains(expr));
let can_skip_check = is_distinct && (is_exists_or_in || !adds_group_keys);

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.

@CuteChuanChuan thanks for the update! My earlier suggestion still misses one case, sorry about that: SELECT DISTINCT t2.id FROM t2 WHERE t2.t1_id = t1.id AND t2.id > t1.id * 10 + 5 returns (1, 20), (2, 30) on 55.2.0 and main, but errors here. The pull up adds t2.t1_id as a key, but the equality pins it to one value per outer row, so it splits no group. is_partitionable_equality already decides this for the window pull up, so the gate can reuse it:

Suggested change
// A DISTINCT may skip this check when the pull up adds no group
// key, or under EXISTS/IN. An added key only adds duplicate rows,
// which EXISTS/IN ignore. Elsewhere, e.g. LATERAL, the duplicates
// reach the result. See #25808.
let is_distinct = aggregate.aggr_expr.is_empty();
let is_exists_or_in =
self.exists_sub_query || self.in_predicate_opt.is_some();
let adds_group_keys = missing_exprs
.iter()
.any(|expr| !aggregate.group_expr.contains(expr));
let can_skip_check = is_distinct && (is_exists_or_in || !adds_group_keys);
// A DISTINCT may skip this check under EXISTS/IN, or when the pull
// up adds no group key that splits a group. An added key only adds
// duplicate rows, which EXISTS/IN ignore; elsewhere, e.g. LATERAL,
// they reach the result (#25808). A key that a correlated equality
// pins to one value per outer row splits no group.
let is_distinct = aggregate.aggr_expr.is_empty();
let is_exists_or_in =
self.exists_sub_query || self.in_predicate_opt.is_some();
let pinned_cols: Vec<&Expr> = self
.join_filters
.iter()
.filter(|f| is_partitionable_equality(f, aggregate.input.schema()))
.filter_map(|f| split_column_equality(f).map(|(col, _)| col))
.map(|col| match col {
Expr::Cast(Cast { expr, .. }) => expr.as_ref(),
_ => col,
})
.collect();
let adds_group_keys = missing_exprs.iter().any(|expr| {
!aggregate.group_expr.contains(expr) && !pinned_cols.contains(&expr)
});
let can_skip_check = is_distinct && (is_exists_or_in || !adds_group_keys);
Results on 55.2.0, main, this PR and with the change; tests for lateral_join.slt
LATERAL DISTINCT subquery 55.2.0 and main this PR with the change
DISTINCT t2.id WHERE t2.t1_id = t1.id AND t2.id > k correct error correct
same with CAST(t2.t1_id AS BIGINT) = t1.id correct error correct
same as LEFT JOIN LATERAL correct error correct
DISTINCT v WHERE k = t1.id AND g > t1.id - 3 (adds g) duplicates error error
lossy cast CAST(d AS INT) = t1.id duplicates error error
float key f = o.f AND v > k correct error error
issue query duplicates error error

Float keys still error because is_partitionable_equality rejects them (-0.0 = 0.0).

Each test below fails if its condition is removed. With the change, lateral_join.slt, subquery.slt, joins.slt, the optimizer unit tests and clippy pass.

@@ -898,6 +898,32 @@ ORDER BY t1.id, sub.id;
 1 30
 2 30
 
+# The pull up adds `t2.t1_id`, but the equality pins it to one value per t1
+# row, so it splits no group and the query stays decorrelated.
+query II
+SELECT t1.id, sub.id
+FROM t1, LATERAL (SELECT DISTINCT t2.id FROM t2 WHERE t2.t1_id = t1.id AND t2.id > t1.id * 10 + 5) AS sub
+ORDER BY t1.id, sub.id;
+----
+1 20
+2 30
+
+# The same through a cast that keeps distinct values distinct.
+query II
+SELECT t1.id, sub.id
+FROM t1, LATERAL (SELECT DISTINCT t2.id FROM t2 WHERE CAST(t2.t1_id AS BIGINT) = t1.id AND t2.id > t1.id * 10 + 5) AS sub
+ORDER BY t1.id, sub.id;
+----
+1 20
+2 30
+
+# A lossy cast does not pin `d`: 1.2 and 1.4 both match t1.id = 1, so the
+# added key would return v = 7 twice.
+# Expected result once supported: (1, 7) once.
+statement error OuterReferenceColumn
+SELECT t1.id, sub.v
+FROM t1, LATERAL (SELECT DISTINCT td.v FROM (VALUES (1.2, 7), (1.4, 7)) AS td(d, v) WHERE CAST(td.d AS INT) = t1.id AND td.v > t1.id) AS sub;
+
 # --- Implicit LATERAL (comma without LATERAL keyword) ---

SELECT t1_id FROM t1 WHERE t1_int IN (SELECT DISTINCT t2_int FROM t2 WHERE t2_id > t1_id)
----
11
33

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 covers the is_distinct part of the gate. Without it, this query returns 11 instead of 11, 22. A statement error would pin it:

Suggested change
33
33
#in_subquery_with_non_equality_filter_below_group_by
#not de-correlated: the pulled up `t2_id` would become an extra group key and
#change the counts. Expected result once supported: 11, 22.
statement error InSubquery
SELECT t1_id FROM t1 WHERE t1_int IN (SELECT count(*) FROM t2 WHERE t2_id > t1_id GROUP BY t2_int)

@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

@jayzhan211
thanks for the review and the following are addressed in 89532b1.

  1. updated the PR description
  2. used is_partitionable_equality to find the added keys that a correlated equality pins to one value per outer row
  3. added tests to lateral_join and subquery slt.

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

Labels

optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong results: LATERAL with SELECT DISTINCT and a non-equality correlated filter returns duplicate rows

2 participants