Skip to content

Commit fbf2930

Browse files
authored
fix(isthmus): avoid NPE from dangling correlations left by partial decorrelation
1 parent 52a32ba commit fbf2930

3 files changed

Lines changed: 103 additions & 1 deletion

File tree

isthmus/src/main/java/io/substrait/isthmus/OuterReferenceResolver.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,26 @@ public RelNode visit(Filter filter) throws RuntimeException {
6666
for (CorrelationId id : filter.getVariablesSet()) {
6767
nestedDepth.putIfAbsent(id, 0);
6868
}
69+
// Also register any CorrelationIds referenced directly in the condition expression.
70+
// This covers partially-decorrelated plans where a Calcite optimizer (e.g. HepPlanner)
71+
// rewrites correlated IN-subqueries into joins but leaves scalar-aggregate subquery
72+
// correlations as RexFieldAccess($corN.col) inside a Filter condition without a
73+
// surrounding Correlate node. In that case getVariablesSet() is empty but the condition
74+
// still contains RexCorrelVariable references whose IDs must be in nestedDepth before
75+
// rexVisitor.visitFieldAccess() is called.
76+
filter
77+
.getCondition()
78+
.accept(
79+
new RexShuttle() {
80+
@Override
81+
public RexNode visitFieldAccess(RexFieldAccess fieldAccess) {
82+
if (fieldAccess.getReferenceExpr() instanceof RexCorrelVariable) {
83+
CorrelationId id = ((RexCorrelVariable) fieldAccess.getReferenceExpr()).id;
84+
nestedDepth.putIfAbsent(id, 0);
85+
}
86+
return fieldAccess;
87+
}
88+
});
6989
filter.getCondition().accept(rexVisitor);
7090
return super.visit(filter);
7191
}

isthmus/src/main/java/io/substrait/isthmus/expression/RexExpressionConverter.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,23 @@ public Expression visitFieldAccess(RexFieldAccess fieldAccess) {
224224
switch (kind) {
225225
case CORREL_VARIABLE:
226226
{
227-
int stepsOut = relVisitor.getFieldAccessDepth(fieldAccess);
227+
Integer stepsOut = relVisitor.getFieldAccessDepth(fieldAccess);
228+
// Substrait requires steps_out >= 1 for an outer reference
229+
// (proto/substrait/algebra.proto, OuterReference.steps_out: "Must be >= 1.").
230+
// A null or 0 depth means the correlation does not step out of any subquery,
231+
// typically a plan that was only partially decorrelated (the owning Correlate
232+
// was rewritten into a join but the $cor reference remains in this Filter).
233+
// We can't represent that with steps_out, so fail clearly rather than emit
234+
// an invalid steps_out=0 reference. Id-based outer-reference resolution
235+
// would handle this and is the proper long-term fix.
236+
if (stepsOut == null || stepsOut == 0) {
237+
throw new UnsupportedOperationException(
238+
String.format(
239+
"Cannot convert outer reference %s: it does not step out of any "
240+
+ "subquery (steps_out=%s). This usually indicates a plan that was "
241+
+ "only partially decorrelated; Substrait requires steps_out >= 1.",
242+
fieldAccess, stepsOut));
243+
}
228244

229245
return FieldReference.newRootStructOuterReference(
230246
fieldAccess.getField().getIndex(),

isthmus/src/test/java/io/substrait/isthmus/OuterReferenceResolverTest.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.apache.calcite.rel.core.JoinRelType;
88
import org.apache.calcite.rex.RexCorrelVariable;
99
import org.apache.calcite.rex.RexFieldAccess;
10+
import org.apache.calcite.rex.RexNode;
1011
import org.apache.calcite.sql.parser.SqlParseException;
1112
import org.apache.calcite.tools.RelBuilder;
1213
import org.apache.calcite.util.Holder;
@@ -142,4 +143,69 @@ void nestedApplyJoinQuery() throws SqlParseException {
142143
validateOuterRef(fieldAccessDepthMap, "$cor0", "SS_ITEM_SK", 2);
143144
validateOuterRef(fieldAccessDepthMap, "$cor1", "I_ITEM_SK", 1);
144145
}
146+
147+
/**
148+
* Regression test for the partially-decorrelated Filter case.
149+
*
150+
* <p>When a Calcite optimizer (e.g. HepPlanner) rewrites a correlated IN-subquery into a join, it
151+
* can produce a {@link org.apache.calcite.rel.core.Filter} whose condition contains a {@link
152+
* RexFieldAccess} referencing a {@link RexCorrelVariable} ({@code $cor0.field}), but whose {@link
153+
* RelNode#getVariablesSet()} is empty — because the enclosing {@link
154+
* org.apache.calcite.rel.core.Correlate} node has already been replaced by a regular join.
155+
*
156+
* <p>Before the fix, {@code OuterReferenceResolver.visit(Filter)} only iterated {@code
157+
* filter.getVariablesSet()}, so the {@link org.apache.calcite.rel.core.CorrelationId} was never
158+
* registered in {@code nestedDepth}. The subsequent {@code rexVisitor.visitFieldAccess()} call
159+
* found {@code !nestedDepth.containsKey(id)} and silently skipped the access, producing an empty
160+
* {@code fieldAccessDepthMap} instead of the expected entry.
161+
*
162+
* <p>The fix pre-scans the Filter condition for {@link RexCorrelVariable} references and
163+
* registers their IDs before delegating to the rex visitor.
164+
*/
165+
@Test
166+
void filterWithCorrelVariableButEmptyVariablesSet() throws SqlParseException {
167+
// Build the partially-decorrelated pattern:
168+
// JOIN (inner side has a Filter whose condition references $cor0 but variablesSet is empty)
169+
//
170+
// SQL equivalent of what a post-optimisation plan looks like:
171+
// SELECT ss_sold_date_sk FROM store_sales
172+
// JOIN item ON item.i_item_sk = store_sales.ss_item_sk ← decorrelated join
173+
// WHERE item.i_item_sk = $cor0.SS_ITEM_SK ← Filter still has the correl ref
174+
//
175+
// We deliberately call .filter(condition) without passing the CorrelationId so that
176+
// getVariablesSet() returns an empty set, reproducing the post-decorrelation shape.
177+
178+
final Holder<RexCorrelVariable> cor0 = Holder.empty();
179+
180+
// Push STORE_SALES and capture the correlation variable for it.
181+
tpcDsRelBuilder.scan("tpcds", "STORE_SALES").variable(cor0::set);
182+
183+
// Push ITEM on top, then build the filter condition while ITEM is the top-of-stack.
184+
// The condition equates item.I_ITEM_SK to the correlated $cor0.SS_ITEM_SK.
185+
tpcDsRelBuilder.scan("tpcds", "ITEM");
186+
RexNode correlCondition =
187+
tpcDsRelBuilder.equals(
188+
tpcDsRelBuilder.field("I_ITEM_SK"), tpcDsRelBuilder.field(cor0.get(), "SS_ITEM_SK"));
189+
190+
final RelNode calciteRel =
191+
tpcDsRelBuilder
192+
// Intentionally NOT passing cor0.get().id to filter() → getVariablesSet() is empty.
193+
.filter(correlCondition)
194+
.join(JoinRelType.INNER, tpcDsRelBuilder.literal(true))
195+
.project(tpcDsRelBuilder.field("SS_SOLD_DATE_SK"))
196+
.build();
197+
198+
// Resolver registers the dangling correlation (no longer silently dropped → no NPE).
199+
final Map<RexFieldAccess, Integer> fieldAccessDepthMap = buildOuterFieldRefMap(calciteRel);
200+
Assertions.assertEquals(1, fieldAccessDepthMap.size());
201+
validateOuterRef(fieldAccessDepthMap, "$cor0", "SS_ITEM_SK", 0);
202+
203+
// steps_out=0 is not a valid Substrait outer reference (proto requires >= 1),
204+
// so conversion must fail clearly rather than emit an invalid plan.
205+
UnsupportedOperationException ex =
206+
Assertions.assertThrows(
207+
UnsupportedOperationException.class,
208+
() -> SubstraitRelVisitor.convert(calciteRel, converterProvider));
209+
Assertions.assertTrue(ex.getMessage().contains("steps_out"));
210+
}
145211
}

0 commit comments

Comments
 (0)