Repository navigation
[CALCITE-7882] JOIN ON condition with an uncorrelated sub-query followed by a correlated EXISTS fails with "Unable to implement" - #5356
Conversation
105d10b to
ae4176b
Compare
0f05aee to
7fa9335
Compare
7fa9335 to
2e550ed
Compare
|
I see this is still a draft. Should I wait with the review? |
I'd like more time to consider the most appropriate implementation approach. |
97ede6d to
5ef1f21
Compare
|
The high-level plan makes sense, but this won't be easy to review. |
|
|
||
| !ok | ||
|
|
||
| # [CALCITE-7882] JOIN ON condition with an uncorrelated sub-query followed by |
There was a problem hiding this comment.
Please describe how these results were validated in a comment.
| /** Throws {@link Util.FoundOne} if it finds a non-deterministic call in an | ||
| * expression, in the relational expressions of its sub-queries, or in a | ||
| * relational expression and its inputs. */ | ||
| private static class NonDeterministicFinder extends RexShuttle { |
There was a problem hiding this comment.
Should we also consider non-repeatable Sample inputs? Reading them twice can produce different samples and incorrect results.
There was a problem hiding this comment.
Thx. I'll look into it later.
| } | ||
| // The joins that replace "join" keep its hints. | ||
| builder.push( | ||
| LogicalJoin.create(leftDomain, rightDomain, join.getHints(), |
There was a problem hiding this comment.
Should we use the configured JoinFactory here to support custom join types?
| // both inputs). Not Join#copy either, because that would keep the original, | ||
| // now stale, variablesSet. | ||
| builder.push( | ||
| LogicalJoin.create(left, right, join.getHints(), newCondition, |
There was a problem hiding this comment.
Should we use the configured JoinFactory here as well?
There was a problem hiding this comment.
I'll look into it later.
|
While looking at [CALCITE-7894] I came up with this test which seems to be fixed by this PR: select (select count(*) from (values (0, 1), (1, 2), (2, 3)) as l(k, a)
where exists (select 1 from (values (0), (1), (2)) as s(v) where s.v = l.k)) as c1,
(select count(*) from (values (0, 1), (1, 2), (5, 3)) as r(k, b)
where exists (select 1 from (values (0), (1), (2)) as s(v) where s.v = r.k)) as c2; |
This case was already working before; it is not affected by this change. |
Sorry, I've bundled several fixes into one commit. I'll split them into multiple smaller PRs later so they're easier to review. |
93b39f8 to
a98740a
Compare
…wed by a correlated EXISTS fails with "Unable to implement" SubQueryRemoveRule rewrites one sub-query of a JOIN condition per call. The join that it creates now keeps defining the correlation variables that the remaining sub-queries use, remapped to its new row type, and the rewritten sub-query gets fresh ids for the variables that it shares with them. Add DeduplicateCorrelateVariables.go for a RexNode, which also visits operands and relational expressions of nested sub-queries.
a98740a to
7b5b926
Compare
|
|
checkstyle is unhappy |



CALCITE-7882
1. Symptom
With
expand=false, aJOIN ... ONcondition containing multiple sub-queries can fail with “Unable to implement”:The scalar sub-query is uncorrelated, while the
EXISTSreferences the current join scope. Both remain in the ON condition asRexSubQuery, and the Join declares the correlation variable used by theEXISTS, for examplevariablesSet=[$cor0].2. Root cause: rewriting one sub-query loses the scope of the remaining ones
SubQueryRemoveRule.matchJoinprocesses one selected sub-query per invocation and relies on repeated rule application. Equal occurrences of the selected sub-query are replaced together.In the original implementation:
The first selected sub-query is the uncorrelated scalar query. It has no operands or local correlation dependencies, so the rule takes the right-input branch.
That branch rebuilds the Join using the two-argument
RelBuilder.joinoverload, which supplies an emptyvariablesSet.The rebuilt Join therefore stops declaring
$cor0, although its ON condition still contains the correlatedEXISTSreferencing$cor0.EMPNO.On the next invocation, the rule intersects the variables used by the
EXISTSwith the rebuilt Join’s now-emptyvariablesSet. The result is empty, so the sub-query is incorrectly treated as uncorrelated.The rewrite creates a plain Join rather than the required Correlate. The correlation reference survives into the executable plan without a binding, causing implementation to fail.
The underlying problem is therefore loss of correlation-variable scope across incremental sub-query rewrites, rather than an incorrectly computed scalar expression.
3. Preserve the scope and remap correlation references
(a) Keep declaring variables needed by the remaining sub-queries
The rule computes
remainingVariablesby intersecting:Both single-side branches rebuild the Join through
pushJoin.When
remainingVariablesis non-empty,pushJoinexplicitly creates aLogicalJoindeclaring those variables. It does not use:RelBuilder.joinwith a non-empty variable set, because that may create a Correlate, which cannot represent an arbitrary ON condition referencing both inputs;Join.copy, because that would retain the original, potentially stale variable set.When no variables need to remain defined,
pushJoincan use the ordinary builder overload.For the cross-product branch, the remaining variables are instead declared on the replacement Filter.
(b) Update both the correlation row type and field indexes
A correlation variable declared by the Join references the combined input row. Rewriting a sub-query may append auxiliary fields, changing that row’s shape.
remapCorrelationVariablesuses aMappingandRelOptUtil.RexCorrelVariableMapShuttleto update the correlation row type and field-access indexes, including references inside nested sub-queries.The selected sub-query also needs its correlation references adapted to the input on which it is rewritten:
nFieldsLeftfrom the relevant indexes.Indexes shifted outside the target row type are left unmapped, so invalid references fail rather than silently resolving to the wrong field.
The correlation row type is constructed from both inputs, not simply from the Join’s output type. This distinction matters for SEMI and ANTI joins, whose ON conditions can reference the right input even though their output does not contain it.
(c) Separate correlation IDs shared by the selected and remaining sub-queries
Preserving
remainingVariablesis not sufficient when multiple correlated sub-queries share an ID.For example, two
EXISTSsub-queries may both reference$cor0. Rewriting one of them moves its binding to an auxiliary Correlate, while the other still needs the original join scope. Reusing$cor0for both would associate the same ID with different binding contexts and row types.renameSharedVariablesassigns fresh IDs only to variables shared with the remaining sub-queries. Non-shared variables and outer-scope correlations are left unchanged.The uncorrelated scalar query in the original example does not share an ID with the correlated
EXISTS; it demonstrates scope preservation, while shared-ID handling addresses an additional case.4. Use one complete-expression renaming implementation
Correlation renaming is now shared between:
matchJoin; andrewriteOnDomain.renameSharedVariablesaccepts aRexNodeand returns aPair<RexNode, Set<CorrelationId>>containing the rewritten expression and its updated variable set.It delegates to the Rex-expression overload of
DeduplicateCorrelateVariables.go, which reuses the existing traversal and covers:This removes the separate renaming implementations and prevents traversal-depth inconsistencies.
In particular, renaming only
subQuery.relis insufficient when anINoperand contains a correlated scalar sub-query. If that operand retains the old ID while the domain declares only the new one, its correlation reference becomes undefined. The complete-expression traversal handles both locations consistently.5. Rewrite operand-nested sub-queries innermost first
The current implementation uses
findInnermostSubQuery: it visits a sub-query’s operands before selecting the enclosing sub-query.This ensures that an inner sub-query becomes an input-field reference before the enclosing query is rewritten. Otherwise, rewriting the outer query can move the inner query into an auxiliary Join condition and leave it outside the current expression-rewrite loop.
This selection order is used by the ordinary Project, Filter and Join paths, and by
applyEnableMarkJoin.“Innermost” here refers to Rex operand nesting. The finder does not traverse the relational expression stored in
RexSubQuery.rel.LogicVisitor.visitSubQueryalso visits operands usingTRUE_FALSE_UNKNOWN, because their values are compared with sub-query rows. FALSE and UNKNOWN must not be discarded merely because the enclosing expression appears in a filtering context.6. Handle dependencies spanning both join inputs
A selected sub-query may depend on both inputs through its operands, correlation references, or both.
For an INNER join, the rule can rewrite the join as a cross-product followed by a Filter, preserving the remaining correlation scope on that Filter.
For supported non-inner join types,
rewriteOnDomaininstead moves the relevant predicate to an INNER join of duplicate-free input domains and joins the original inputs back to the matching bindings:This domain transformation does not itself remove the sub-queries. Subsequent rule invocations remove them from the domain’s INNER join condition.
These changes preserve correlation scope, binding identity and field geometry throughout incremental rewrites. They do not establish that every join-type-specific row-preservation case is solved; the separately discussed single-side ANTI/LEFT_MARK Logic selection remains outside this fix.