Skip to content

fix: keep the OFFSET of a correlated EXISTS subquery - #25284

Open
fornwall wants to merge 1 commit into
apache:mainfrom
fornwall:offset-of-correlated-exists
Open

fornwall wants to merge 1 commit into
apache:mainfrom
fornwall:offset-of-correlated-exists

Conversation

@fornwall

@fornwall fornwall commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #25283.

Rationale for this change

A correlated EXISTS subquery with an OFFSET returns wrong results, because the OFFSET is dropped when the subquery is rewritten into a semi join:

CREATE TABLE t1(k INT) AS VALUES (1), (2), (3);
CREATE TABLE t2(v INT) AS VALUES (1), (1), (3);

SELECT k FROM t1 WHERE EXISTS (SELECT * FROM t2 WHERE t2.v = t1.k OFFSET 1);

Only k = 1 has two matching rows, so the expected result is a single row with 1 (as SQLite returns). DataFusion returns 1 and 3. NOT EXISTS is wrong the same way.

PullUpCorrelatedExpr removes the LIMIT above the correlated filter of an EXISTS subquery, since LIMIT n with n > 0 cannot change whether any row exists. But the same node carries the OFFSET, which can turn a non-empty subquery into an empty one.

The decision to remove a Limit was also keyed on whether any correlated predicate had been collected so far, not on whether one sat below this Limit. In a join inside the EXISTS the correlated side is visited first, so a LIMIT on an uncorrelated sibling branch was removed too, changing the result:

SELECT t1_id FROM t1 WHERE EXISTS (
  SELECT 1 FROM (SELECT * FROM t2 WHERE t2_id = t1_id) a
  JOIN (SELECT * FROM t3 ORDER BY t3_id LIMIT 1) b ON a.t2_int = b.t3_int)

With the subquery.slt fixtures this returned 11, 22, 44 instead of 11, 44.

What changes are included in this PR?

PullUpCorrelatedExpr now decides per Limit, in f_down, whether it sits above correlated expressions, using the outer references of its own subtree (as it already did for IN subqueries), and rewrites the Limit right there.

For a correlated EXISTS, the Limit is only removed when it cannot change whether the subquery is empty: a zero offset with a literal fetch. A zero fetch still becomes an empty relation. Anything else (a positive or non-literal offset, or a non-literal fetch) marks the subquery as not pull-up-able, so it stays correlated and fails as unsupported instead of returning wrong rows.

A Limit with no correlated expressions below it is left alone.

What is the testing strategy for this PR?

New sqllogictest cases in subquery.slt:

  • LIMIT 1 OFFSET 0 is still decorrelated.
  • OFFSET 1 keeps the subquery correlated, and running the query fails with a not-implemented error. The explain case fails without the fix. The same for NOT EXISTS, and for EXISTS in a disjunction.
  • LIMIT 0 OFFSET 1 still becomes an empty relation.
  • A non-literal LIMIT keeps the subquery correlated.
  • A LIMIT or OFFSET on an uncorrelated branch of a join inside the EXISTS is kept and the query returns the correct rows. Both fail without the per-Limit decision.

Are there any user-facing changes?

  • A correlated EXISTS / NOT EXISTS subquery with a positive OFFSET, or with a non-literal LIMIT, now fails with a not-implemented error instead of returning wrong results (previously the LIMIT / OFFSET was silently dropped). I can file an issue and take a stab at supporting these in a follow-up once this is merged.
  • A LIMIT or OFFSET on an uncorrelated branch inside a correlated EXISTS subquery is no longer dropped, so such queries now return correct results.

Disclaimer: Created with fable 5.1 in claude code. I have reviewed the code.

@github-actions github-actions Bot added optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) labels Sep 14, 2026
@codecov-commenter

codecov-commenter commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.87097% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.90%. Comparing base (15f32dd) to head (ff7c7c2).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/optimizer/src/decorrelate.rs 83.87% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25284      +/-   ##
==========================================
- Coverage   81.90%   81.90%   -0.01%     
==========================================
  Files        1134     1134              
  Lines      425200   425227      +27     
  Branches   425200   425227      +27     
==========================================
+ Hits       348268   348290      +22     
- Misses      56291    56294       +3     
- Partials    20641    20643       +2     

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

@fornwall
fornwall force-pushed the offset-of-correlated-exists branch 7 times, most recently from 3d862ad to 9b21f10 Compare September 14, 2026 02:06
A correlated subquery refers to columns of the query it is nested in, so
in principle it has to be evaluated once per outer row:

```sql
SELECT k FROM t1 WHERE EXISTS (SELECT * FROM t2 WHERE t2.v = t1.k)
```

Re-running the subquery for every outer row would be slow, and DataFusion
has no operator that does so. Instead the optimizer rewrites it into a
join, which is how analytical engines execute correlated subqueries
efficiently: the condition that mentions the outer column (`t2.v = t1.k`)
is pulled up out of the subquery and becomes the join condition of a
semi join between `t1` and `t2`. Any operator that sits between that
condition and the top of the subquery is in the way of the pull up, and
a `LIMIT` is one such operator.

For an `EXISTS` subquery a limit was simply deleted to clear the way,
because `EXISTS` only asks whether the subquery returns any row, and a
`LIMIT n` with `n > 0` cannot change that (a `LIMIT 0` was turned into an
empty relation). This overlooked that the same plan node also carries the
`OFFSET`, and an offset does change the answer: it skips rows, so a
subquery that would return one row returns none after `OFFSET 1`. The
offset was dropped along with the fetch and the query answered wrongly.
With two rows of `v = 1` and one row of `v = 3` in `t2`

```sql
SELECT k FROM t1 WHERE EXISTS (SELECT * FROM t2 WHERE t2.v = t1.k OFFSET 1)
```

returned both `1` and `3`, although only `k = 1` has a second row to
skip past.

Only remove the limit when it cannot change whether the subquery is
empty, which is a zero offset with a literal fetch. A zero fetch still
becomes an empty relation. Anything else, a positive offset or an offset
or fetch that is not a literal, marks the subquery as one that cannot be
pulled up, so it stays a correlated subquery in the plan and is reported
as unsupported rather than answered wrongly.

The decision to remove a limit was also made from the wrong information.
It keyed on whether any correlated condition had been collected so far
in the subquery, not on whether one sat below this particular limit. In
a join inside the `EXISTS` the correlated side is visited first, so a
`LIMIT` on an unrelated, uncorrelated sibling branch was deleted too and
changed the result. Decide per limit instead, in `f_down`, from the outer
references of that limit's own subtree, which is how `IN` subqueries
were already handled. It has to happen in `f_down`: by `f_up` the filters
below have been rewritten and their outer references are gone. The limit
is rewritten right there in `f_down`, and the unsupported shapes now bail
in `f_down` like the other unsupported shapes in this rewriter.

Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
@fornwall
fornwall force-pushed the offset-of-correlated-exists branch from 9b21f10 to ff7c7c2 Compare September 14, 2026 02:20
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.

Correlated EXISTS subquery with OFFSET returns wrong results

2 participants