Skip to content

Commit 19035d1

Browse files
committed
fix: harden id-based outer-reference conversion
Address review feedback on the id-based outer-reference work: - core: OuterReferenceConverter clears currentInput inside a subquery scope so a correlated reference reached through a multi-input host (join/set) is rejected as unsupported instead of silently binding to the wrong relation; share one anchor counter across all plan roots so assigned rel_anchors are unique plan-wide. - core: simplify FieldReference#check() to an int sum and drop fully-qualified names in OuterReferenceConverter. - isthmus: reject duplicate rel_anchors on consume, surface an unresolvable rel_reference as UnsupportedOperationException (consistent with core), and report an actionable error when a custom Rel binding target cannot carry an anchor. Add regression tests for multi-root anchor uniqueness, multi-input scope rejection, duplicate-anchor rejection, and custom-Rel anchor support.
1 parent 3760af7 commit 19035d1

7 files changed

Lines changed: 232 additions & 20 deletions

File tree

core/src/main/java/io/substrait/expression/FieldReference.java

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,10 @@ public <R, C extends VisitationContext, E extends Throwable> R accept(
104104
*/
105105
@Value.Check
106106
protected void check() {
107-
long formsSet =
108-
java.util.stream.Stream.of(
109-
outerReferenceStepsOut(),
110-
outerReferenceRelReference(),
111-
lambdaParameterReferenceStepsOut())
112-
.filter(Optional::isPresent)
113-
.count();
107+
int formsSet =
108+
(outerReferenceStepsOut().isPresent() ? 1 : 0)
109+
+ (outerReferenceRelReference().isPresent() ? 1 : 0)
110+
+ (lambdaParameterReferenceStepsOut().isPresent() ? 1 : 0);
114111
if (formsSet > 1) {
115112
throw new IllegalArgumentException(
116113
"FieldReference can set at most one of outerReferenceStepsOut, "

core/src/main/java/io/substrait/relation/OuterReferenceConverter.java

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import io.substrait.plan.Plan;
77
import io.substrait.util.EmptyVisitationContext;
88
import java.util.ArrayList;
9+
import java.util.HashSet;
910
import java.util.IdentityHashMap;
1011
import java.util.List;
1112
import java.util.Map;
1213
import java.util.Optional;
14+
import java.util.Set;
15+
import java.util.function.Supplier;
1316

1417
/**
1518
* Converts a Substrait plan between the two outer-reference resolution encodings:
@@ -88,15 +91,22 @@ public static Plan toStepsOut(Plan plan) {
8891
}
8992

9093
private static Plan convert(Plan plan, Direction direction) {
94+
// A single State (and therefore a single anchor counter) is shared across all roots so that
95+
// assigned rel_anchors are unique plan-wide, as required by Rel#getRelAnchor(). The per-tree
96+
// traversal state (scope stack, currentInput) is push/pop balanced and empty between roots.
97+
State state = new State(direction);
9198
List<Plan.Root> roots = new ArrayList<>(plan.getRoots().size());
9299
for (Plan.Root root : plan.getRoots()) {
93-
roots.add(Plan.Root.builder().from(root).input(convert(root.getInput(), direction)).build());
100+
roots.add(Plan.Root.builder().from(root).input(convert(root.getInput(), state)).build());
94101
}
95102
return Plan.builder().from(plan).roots(roots).build();
96103
}
97104

98105
private static Rel convert(Rel root, Direction direction) {
99-
State state = new State(direction);
106+
return convert(root, new State(direction));
107+
}
108+
109+
private static Rel convert(Rel root, State state) {
100110
RelRewriter relRewriter = new RelRewriter(state);
101111
return root.accept(relRewriter, EmptyVisitationContext.INSTANCE).orElse(root);
102112
}
@@ -107,8 +117,9 @@ private static final class State {
107117

108118
/**
109119
* Stack of enclosing scope relations, one entry per subquery boundary. A {@code null} entry
110-
* marks a multi-input (unsupported) scope. {@code steps_out=N} resolves to the entry {@code N}
111-
* from the top.
120+
* marks a scope whose expressions are not hosted by a single-input {@link Filter}/{@link
121+
* Project} (e.g. a multi-input join/set condition), which is unsupported. {@code steps_out=N}
122+
* resolves to the entry {@code N} from the top.
112123
*/
113124
final List<Rel> outerScopes = new ArrayList<>();
114125

@@ -118,9 +129,15 @@ private static final class State {
118129
final Map<Rel, Integer> anchorByRel = new IdentityHashMap<>();
119130

120131
/** Anchors that were resolved to a {@code steps_out} value and should be stripped from rels. */
121-
final java.util.Set<Integer> resolvedAnchors = new java.util.HashSet<>();
132+
final Set<Integer> resolvedAnchors = new HashSet<>();
122133

123-
/** The input relation whose expressions are currently being rewritten (RootReference scope). */
134+
/**
135+
* The input relation whose expressions are currently being rewritten, or {@code null} when the
136+
* expressions being rewritten are not those of a single-input host (a {@link Filter} or {@link
137+
* Project}). Pushed onto {@link #outerScopes} at each subquery boundary; a {@code null} scope
138+
* is therefore what makes an outer reference into a multi-input (or otherwise unsupported)
139+
* scope fail rather than bind to the wrong relation.
140+
*/
124141
Rel currentInput;
125142

126143
int nextAnchor = 1;
@@ -209,7 +226,7 @@ public Optional<Rel> visit(NamedDdl ddl, EmptyVisitationContext context) {
209226
* Runs the given expression rewrite with {@code currentInput} temporarily set to {@code input},
210227
* so that subqueries entered while rewriting push the correct scope.
211228
*/
212-
private <T> T rewriteInScope(Rel input, java.util.function.Supplier<T> rewrite) {
229+
private <T> T rewriteInScope(Rel input, Supplier<T> rewrite) {
213230
Rel saved = state.currentInput;
214231
state.currentInput = input;
215232
try {
@@ -297,8 +314,9 @@ private int anchorForStepsOut(int stepsOut) {
297314
Rel binding = state.outerScopes.get(index);
298315
if (binding == null) {
299316
throw new UnsupportedOperationException(
300-
"Cannot assign a rel_anchor for an outer reference that resolves to a multi-input "
301-
+ "(e.g. join) scope; only single-input scopes are supported");
317+
"Cannot assign a rel_anchor for an outer reference that resolves to a scope that is not "
318+
+ "the input of a single-input host (Filter/Project); multi-input scopes (e.g. a "
319+
+ "join/set condition) are not supported");
302320
}
303321
return state.allocateAnchor(binding);
304322
}
@@ -337,7 +355,7 @@ public Optional<Expression> visit(
337355
Expression.InPredicate inPredicate, EmptyVisitationContext context) {
338356
// needles are evaluated in the current (outer) scope; the haystack is the subquery boundary.
339357
Optional<List<Expression>> needles = visitExprList(inPredicate.needles(), context);
340-
Optional<io.substrait.relation.Rel> haystack =
358+
Optional<Rel> haystack =
341359
withSubqueryScope(
342360
() -> inPredicate.haystack().accept(getRelCopyOnWriteVisitor(), context));
343361

@@ -353,9 +371,15 @@ public Optional<Expression> visit(
353371
}
354372

355373
/** Pushes {@code currentInput} as a new subquery scope, runs the action, then pops it. */
356-
private <T> T withSubqueryScope(java.util.function.Supplier<T> action) {
374+
private <T> T withSubqueryScope(Supplier<T> action) {
357375
Rel savedInput = state.currentInput;
358376
state.outerScopes.add(state.currentInput);
377+
// Clear currentInput for the duration of the subquery so that only a single-input host
378+
// (Filter/Project, which re-sets it via rewriteInScope) contributes a non-null scope. Without
379+
// this, a subquery reached through a multi-input host (join/set) or any other relation would
380+
// inherit this scope's currentInput and silently bind an outer reference to the wrong
381+
// relation instead of failing as an unsupported multi-input scope.
382+
state.currentInput = null;
359383
try {
360384
return action.get();
361385
} finally {

core/src/test/java/io/substrait/relation/OuterReferenceConverterTest.java

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.substrait.TestBase;
99
import io.substrait.expression.Expression;
1010
import io.substrait.expression.FieldReference;
11+
import io.substrait.plan.Plan;
1112
import io.substrait.relation.Rel.Remap;
1213
import io.substrait.type.TypeCreator;
1314
import java.util.List;
@@ -212,6 +213,96 @@ void unresolvableRelReferenceIsRejected() {
212213
UnsupportedOperationException.class, () -> OuterReferenceConverter.toStepsOut(dangling));
213214
}
214215

216+
@Test
217+
void multiRootPlanAssignsPlanWideUniqueAnchors() {
218+
// Two roots, each with a correlated subquery binding to a distinct relation. Anchors must be
219+
// unique plan-wide (Rel#getRelAnchor()), so the second root's binding relation cannot reuse the
220+
// first root's anchor.
221+
Plan stepsOut =
222+
Plan.builder()
223+
// from(...) supplies a valid executionBehavior (and the first root); add the second.
224+
.from(sb.plan(sb.root(oneStepPlanBoundTo(orderTableScan))))
225+
.addRoots(sb.root(oneStepPlanBoundTo(nationTableScan)))
226+
.build();
227+
228+
Plan idBased = OuterReferenceConverter.toIdBased(stepsOut);
229+
230+
int anchor0 =
231+
((Project) idBased.getRoots().get(0).getInput())
232+
.getInput()
233+
.getRelAnchor()
234+
.orElseThrow(AssertionError::new);
235+
int anchor1 =
236+
((Project) idBased.getRoots().get(1).getInput())
237+
.getInput()
238+
.getRelAnchor()
239+
.orElseThrow(AssertionError::new);
240+
assertNotEquals(anchor0, anchor1);
241+
242+
assertEquals(stepsOut, OuterReferenceConverter.toStepsOut(idBased));
243+
}
244+
245+
@Test
246+
void correlatedReferenceIntoJoinScopeIsRejected() {
247+
// A correlated reference whose binding scope is a multi-input (join) relation, reached through
248+
// a
249+
// subquery nested in the join condition, is unsupported and must fail rather than silently bind
250+
// the reference to the wrong (single-input) enclosing relation.
251+
Rel plan =
252+
sb.project(
253+
input ->
254+
List.of(
255+
sb.scalarSubquery(
256+
sb.innerJoin(
257+
joinInputs ->
258+
sb.equal(
259+
sb.fieldReference(joinInputs, 0),
260+
sb.scalarSubquery(
261+
sb.project(
262+
input3 -> List.of(sb.fieldReference(input3, 1)),
263+
Remap.of(List.of(1)),
264+
sb.filter(
265+
input3 ->
266+
sb.equal(
267+
sb.fieldReference(input3, 0),
268+
// steps_out=1 resolves to the enclosing
269+
// join scope, which is multi-input.
270+
FieldReference.newRootStructOuterReference(
271+
0, TypeCreator.REQUIRED.I64, 1)),
272+
customerTableScan)),
273+
TypeCreator.NULLABLE.I64)),
274+
nationTableScan,
275+
orderTableScan),
276+
TypeCreator.NULLABLE.I64)),
277+
Remap.of(List.of(2)),
278+
orderTableScan);
279+
280+
assertThrows(
281+
UnsupportedOperationException.class, () -> OuterReferenceConverter.toIdBased(plan));
282+
}
283+
284+
/** A one-step correlated-subquery plan whose outer reference binds to {@code bindingScan}. */
285+
private Rel oneStepPlanBoundTo(Rel bindingScan) {
286+
return sb.project(
287+
input ->
288+
List.of(
289+
sb.fieldReference(input, 0),
290+
sb.scalarSubquery(
291+
sb.project(
292+
input2 -> List.of(sb.fieldReference(input2, 1)),
293+
Remap.of(List.of(1)),
294+
sb.filter(
295+
input2 ->
296+
sb.equal(
297+
sb.fieldReference(input2, 0),
298+
FieldReference.newRootStructOuterReference(
299+
1, TypeCreator.REQUIRED.I64, 1)),
300+
customerTableScan)),
301+
TypeCreator.NULLABLE.I64)),
302+
Remap.of(List.of(2, 3)),
303+
bindingScan);
304+
}
305+
215306
/** Extracts the outer field reference from the one-step plan's scalar-subquery filter. */
216307
private FieldReference outerReferenceOf(Project outerProject) {
217308
Expression.ScalarSubquery subquery =

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,15 @@ public static class Context implements VisitationContext {
841841
/** Maps a {@code rel_anchor} to the single {@link CorrelationId} minted for it. */
842842
private final Map<Integer, CorrelationId> correlationIdByAnchor = new HashMap<>();
843843

844+
/**
845+
* Every {@code rel_anchor} that has entered a scope. Resolution keys {@link #scopeByAnchor} and
846+
* {@link #correlationIdByAnchor} purely by anchor value, which is only sound if anchors are
847+
* unique plan-wide (as required by {@link io.substrait.relation.Rel#getRelAnchor()}). This set
848+
* lets {@link #enterScope} reject a plan that reuses an anchor for two distinct relations
849+
* rather than silently mis-resolving references.
850+
*/
851+
private final java.util.Set<Integer> seenAnchors = new HashSet<>();
852+
844853
/** One correlation scope per enclosing relational operator. */
845854
private static final class Scope {
846855
final Map<Integer, RelDataType> rowTypeByAnchor = new HashMap<>();
@@ -867,6 +876,13 @@ public void enterScope(final AnchoredInput... inputs) {
867876
for (final AnchoredInput input : inputs) {
868877
if (input.anchor.isPresent()) {
869878
final int anchor = input.anchor.get();
879+
if (!seenAnchors.add(anchor)) {
880+
throw new UnsupportedOperationException(
881+
"Duplicate rel_anchor="
882+
+ anchor
883+
+ "; rel_anchors must be unique plan-wide for id-based outer references to "
884+
+ "resolve unambiguously");
885+
}
870886
scope.rowTypeByAnchor.put(anchor, input.rowType);
871887
scopeByAnchor.put(anchor, scope);
872888
}
@@ -919,7 +935,10 @@ public CorrelationId correlationIdForAnchor(
919935
private Scope requireScope(final int anchor) {
920936
final Scope scope = scopeByAnchor.get(anchor);
921937
if (scope == null) {
922-
throw new IllegalStateException(
938+
// The anchor is not on the active scope stack: the referenced relation is not an enclosing
939+
// single-input host. This includes forward references and shared subtrees reached via a
940+
// ReferenceRel. Signalled as unsupported, consistent with OuterReferenceConverter.
941+
throw new UnsupportedOperationException(
923942
"Outer reference rel_reference="
924943
+ anchor
925944
+ " has no enclosing relation with that anchor");

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,20 @@ public Rel apply(RelNode r) {
805805
if (outerReferenceResolver != null) {
806806
Integer anchor = outerReferenceResolver.anchorForTarget(r);
807807
if (anchor != null) {
808-
return rel.withRelAnchor(anchor);
808+
try {
809+
return rel.withRelAnchor(anchor);
810+
} catch (UnsupportedOperationException e) {
811+
// rel is the binding point of a correlated outer reference but cannot carry a rel_anchor
812+
// (a custom, non-Immutables Rel that does not override withRelAnchor). Fail with context
813+
// rather than propagating the bare "does not support setting a relation anchor" default.
814+
throw new UnsupportedOperationException(
815+
"Relation "
816+
+ rel.getClass()
817+
+ " is the binding point of an id-based outer reference but does not support "
818+
+ "setting a rel_anchor; override Rel#withRelAnchor to convert correlated "
819+
+ "subqueries into this relation type",
820+
e);
821+
}
809822
}
810823
}
811824
return rel;

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package io.substrait.isthmus;
22

3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
35
import static org.junit.jupiter.api.Assertions.assertTrue;
46

7+
import io.substrait.dsl.SubstraitBuilder;
58
import io.substrait.extension.AdvancedExtension;
69
import io.substrait.extension.DefaultExtensionCatalog;
710
import io.substrait.extension.SimpleExtension;
@@ -10,6 +13,7 @@
1013
import io.substrait.relation.RelVisitor;
1114
import io.substrait.relation.SingleInputRel;
1215
import io.substrait.type.Type;
16+
import io.substrait.type.TypeCreator;
1317
import io.substrait.util.VisitationContext;
1418
import java.util.List;
1519
import java.util.Optional;
@@ -115,6 +119,18 @@ public Optional<Integer> getRelAnchor() {
115119
return input.getRelAnchor();
116120
}
117121

122+
@Override
123+
public Rel withRelAnchor(final int relAnchor) {
124+
// Delegate to the input, mirroring getRelAnchor(), so this custom Rel can serve as the
125+
// binding point of an id-based outer reference instead of inheriting Rel's throwing default.
126+
return new SubstraitRepeatRel(input.withRelAnchor(relAnchor), repeatCount);
127+
}
128+
129+
@Override
130+
public Rel withRelAnchor(final Optional<Integer> relAnchor) {
131+
return new SubstraitRepeatRel(input.withRelAnchor(relAnchor), repeatCount);
132+
}
133+
118134
@Override
119135
public <O, C extends VisitationContext, E extends Exception> O accept(
120136
final RelVisitor<O, C, E> visitor, final C context) throws E {
@@ -257,4 +273,22 @@ void test() {
257273
findNode(rel, SubstraitRepeatRel.class),
258274
"substrait plan must contain SubstraitRepeatRel relation");
259275
}
276+
277+
@Test
278+
void customRelSupportsRelAnchor() {
279+
// A custom (non-Immutables) Rel completes the Rel contract by delegating withRelAnchor, so
280+
// SubstraitRelVisitor#apply can stamp an id-based outer-reference anchor on it rather than
281+
// hitting Rel's throwing withRelAnchor default.
282+
final SubstraitBuilder sb = new SubstraitBuilder();
283+
final Rel scan = sb.namedScan(List.of("t"), List.of("a"), List.of(TypeCreator.REQUIRED.I64));
284+
final SubstraitRepeatRel repeat = new SubstraitRepeatRel(scan, 3);
285+
286+
final Rel anchored = repeat.withRelAnchor(7);
287+
assertTrue(anchored instanceof SubstraitRepeatRel);
288+
assertEquals(3, ((SubstraitRepeatRel) anchored).getRepeatCount());
289+
assertEquals(7, anchored.getRelAnchor().orElseThrow(AssertionError::new));
290+
291+
// Clearing the anchor works too.
292+
assertFalse(anchored.withRelAnchor(Optional.empty()).getRelAnchor().isPresent());
293+
}
260294
}

0 commit comments

Comments
 (0)