Skip to content

Fix subqueries in interpolated join wheres#4765

Draft
lukaszsamson wants to merge 4 commits into
elixir-ecto:masterfrom
lukaszsamson:codex/where-join-subquery-boolean-expr
Draft

Fix subqueries in interpolated join wheres#4765
lukaszsamson wants to merge 4 commits into
elixir-ecto:masterfrom
lukaszsamson:codex/where-join-subquery-boolean-expr

Conversation

@lukaszsamson

Copy link
Copy Markdown
Contributor

Summary

Fix planning an interpolated join query whose where expression contains a subquery.

This is an alternative implementation to #4764 that incorporates the review suggestion to keep subqueries off QueryExpr:

  • Fold interpolated join filters into a BooleanExpr, which already owns subqueries.
  • Plan, merge, reindex, and cache those subqueries with the join expression.
  • Convert the temporary BooleanExpr back to QueryExpr after normalization, preserving the contract expected by SQL adapters.
  • Allow query inspection while the temporary boolean join expression is present.

Root cause

Interpolated query filters are folded into the join on. Previously that produced a QueryExpr without the subqueries metadata required to resolve {:subquery, index} placeholders, causing a KeyError during parameter planning.

The join cache key also needs the nested subquery cache keys; otherwise structurally different subqueries can collide and reuse the wrong prepared SQL.

Validation

  • mix compile --warnings-as-errors
  • mix test — 1,581 tests passed

Regression coverage verifies planning, subquery parameter/index merging, cache-key differentiation, normalization-time inlining, and restoration of JoinExpr.on to QueryExpr.

Comment thread lib/ecto/query/planner.ex Outdated
Comment on lines +55 to +56
# The right side may be a QueryExpr (an explicit join `on`),
# which holds no subqueries

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we make the right-side be a BooleanExpr as well always?

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.

done

Comment thread lib/ecto/query/planner.ex Outdated
{source, acc} = prewalk_source(join.source, :join, query, join, acc, adapter)
{on, acc} = prewalk(:join, query, join.on, acc, adapter)
{%{join | on: on, source: source, params: nil}, acc}
{%{join | on: on_to_query_expr(on), source: source, params: nil}, acc}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we need to rollback? Let's keep it as boolean everywhere... if necessary, we change ecto_sql.

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.

Currently ecto_sql pattern matches on QueryExpr. Relaxing that is doable. I created a draft PR. The tests pass but I'm not convinced this is the right way. Providers copy the code patterns from ecto and will remain with silent failures (for expressions will filter out %BooleanExpr{}) if ecto_sql is loosly pinned.

Besides, after this change join.on would be mixed-type: BooleanExpr/QueryExpr. If you want booleans everywhere we would need a bigger refactor making the join builder emit BooleanExpr for all ons

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Besides, after this change join.on would be mixed-type: BooleanExpr/QueryExpr. If you want booleans everywhere we would need a bigger refactor making the join builder emit BooleanExpr for all ons

Correct. That's what we should do. We already did this change a while ago to support subqueries in where. This is just a natural continuation of it.

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.

Is that what you had in mind?

lukaszsamson and others added 2 commits July 25, 2026 18:01
Make the join builder construct every JoinExpr.on as a BooleanExpr,
instead of only the ons synthesized from interpolated join queries.
This removes the mixed QueryExpr/BooleanExpr on types and the
conversion back to QueryExpr during normalization: adapters now
receive a BooleanExpr on for every join.

With a single on type, merge_expr_and_params collapses to one clause
and attach_on no longer needs to upgrade the explicit on.

Join entries in cache keys change shape from `expr` to `{op, expr}`
as they now go through the BooleanExpr expr_to_cache clause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The map patterns and comment date from the intermediate design where
join.on was mixed-type; it is always a BooleanExpr now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@josevalim josevalim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@greg-rychlewski you also happy with this one?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants