Skip to content

Commit d86b8b1

Browse files
committed
fix(core): resolve reference segments totally, and scope join keys per side
Two defects in the field-reference retyping, both found reviewing it. Segment resolution was guarded one segment deep. The guard checked only that the outermost segment selected a field the new record type has, so a reference into a nested type that had been reshaped reached the derivation anyway and threw — a bare IndexOutOfBoundsException out of a struct-field finder, or an IllegalArgumentException or UnsupportedOperationException for a list or map segment. That contradicted the guard's own promise to leave an unresolvable reference alone, and it made a narrowing rewrite fail where it previously produced a stale type. Resolution is now total: FieldReference.resolveType reports the type a chain of segments selects, or nothing, at any depth and for every kind of segment. It lives beside the finders whose rules it mirrors, so the two cannot drift apart unnoticed without the parity test that pins them going red. It mirrors those rules exactly, including the two asymmetries that matter: a list element offset is not bounds checked, because the length of a list is not part of its type, and a map key type is compared exactly, nullability included. The visitor's one-deep guard and its defensive segment copy both go, and the same guarantee now covers a reference rooted at another expression, which had no guard at all. Join keys were retyped against the wrong scope. The offsets of a hash or merge join key are relative to the side of the join the key selects from, not to the two inputs combined — proto conversion types each side with its own converter, and only the condition, post-join filter and residual expression use the combined type. Retyping both sides against the combined type silently resolved a right-side offset to a left column. Each side is now rewritten against its own input, which changes the signature of visitComparisonJoinKey; the method could not previously return a usable reference at all, so nothing can have depended on the old one. Two things this deliberately does not do: the off-by-one bound check in StructFieldFinder stays, because the exception it produces is part of what ProtoExpressionConverter reports for a malformed plan; and MergeJoin.deriveRecordType reads its right input for both sides, which is a separate bug in a relation this change only passes through.
1 parent f2d5c65 commit d86b8b1

5 files changed

Lines changed: 746 additions & 59 deletions

File tree

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

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ public FieldReference constructOnRoot(Type.Struct struct) {
480480
* Creates a field reference rooted at an expression and navigating through the given segments.
481481
*
482482
* @param expression the expression to reference into
483-
* @param segments the navigation segments, outermost first
483+
* @param segments the navigation segments, innermost first
484484
* @return the field reference
485485
*/
486486
public static FieldReference ofExpression(
@@ -509,13 +509,82 @@ private static FieldReference of(
509509
* Creates a field reference rooted at a struct and navigating through the given segments.
510510
*
511511
* @param struct the root struct type
512-
* @param segments the navigation segments, outermost first
512+
* @param segments the navigation segments, innermost first
513513
* @return the field reference
514514
*/
515515
public static FieldReference ofRoot(Type.Struct struct, List<ReferenceSegment> segments) {
516516
return of(struct, null, segments);
517517
}
518518

519+
/**
520+
* Resolves the type that the given reference segments select out of the given type, or reports
521+
* that they select nothing out of it.
522+
*
523+
* <p>The segments are given innermost first, in the order {@link #segments()} holds them, and are
524+
* applied outermost first: the last segment selects out of {@code rootType}, the one before it
525+
* out of the type that segment selected, and so on inwards. The given list is not modified.
526+
*
527+
* <p>Resolution is total. A segment that does not fit the type it is applied to, at any depth — a
528+
* struct field offset the struct does not have, a list element or a map key on a type that is not
529+
* a list or a map, a map key whose type is not the map's key type — yields an empty result
530+
* instead of throwing. This lets a caller that re-derives the type cached on a reference, against
531+
* a type that has since changed, tell a reference that no longer resolves from a failure of its
532+
* own work, and leave such a reference as it is. An empty segment list selects nothing and also
533+
* yields an empty result, matching {@link #ofRoot} and {@link #ofExpression}, which build no
534+
* reference for it.
535+
*
536+
* @param rootType the type the outermost segment selects out of
537+
* @param segments the navigation segments, innermost first
538+
* @return the type the segments select, or empty if they do not all resolve against {@code
539+
* rootType}
540+
*/
541+
public static Optional<Type> resolveType(Type rootType, List<ReferenceSegment> segments) {
542+
if (segments.isEmpty()) {
543+
return Optional.empty();
544+
}
545+
Type resolved = rootType;
546+
for (int i = segments.size() - 1; i >= 0; i--) {
547+
Optional<Type> selected = resolveSegmentType(segments.get(i), resolved);
548+
if (!selected.isPresent()) {
549+
return Optional.empty();
550+
}
551+
resolved = selected.get();
552+
}
553+
return Optional.of(resolved);
554+
}
555+
556+
/**
557+
* Resolves the type a single segment selects out of the given type, or reports that it selects
558+
* nothing out of it.
559+
*
560+
* <p>This mirrors the type each segment derives when it is applied: a struct field selects the
561+
* field at its offset, a list element selects the element type whatever its offset, as the length
562+
* of a list is not part of its type, and a map key selects the value type of a map whose key type
563+
* it matches, nullability included. A segment applied to a type that is not the container it
564+
* navigates into, and any other kind of segment, select nothing.
565+
*/
566+
private static Optional<Type> resolveSegmentType(ReferenceSegment segment, Type type) {
567+
if (segment instanceof StructField && type instanceof Type.Struct) {
568+
int offset = ((StructField) segment).offset();
569+
List<Type> fields = ((Type.Struct) type).fields();
570+
return offset >= 0 && offset < fields.size()
571+
? Optional.of(fields.get(offset))
572+
: Optional.empty();
573+
}
574+
if (segment instanceof ListElement && type instanceof Type.ListType) {
575+
return Optional.of(((Type.ListType) type).elementType());
576+
}
577+
if (segment instanceof MapKey && type instanceof Type.Map) {
578+
// The type of the key literal is only read once the type is known to be a map, which is also
579+
// the only case in which applying the segment reads it.
580+
Type.Map map = (Type.Map) type;
581+
return map.key().equals(((MapKey) segment).key().getType())
582+
? Optional.of(map.value())
583+
: Optional.empty();
584+
}
585+
return Optional.empty();
586+
}
587+
519588
private static class StructFieldFinder
520589
extends TypeVisitor.TypeThrowsVisitor<Type, RuntimeException> {
521590

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

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import io.substrait.expression.ImmutableFieldReference;
1313
import io.substrait.type.Type;
1414
import io.substrait.util.EmptyVisitationContext;
15-
import java.util.ArrayList;
1615
import java.util.List;
1716
import java.util.Optional;
1817

@@ -435,11 +434,21 @@ public Optional<Expression> visit(FieldReference fieldReference, EmptyVisitation
435434
* cached type from the root it resolves against. Re-deriving the type is what keeps references
436435
* correct when a relation's input is replaced by one emitting a different record type.
437436
*
437+
* <p>A reference that no longer resolves against its root, at any segment depth, is left exactly
438+
* as it is, including the type it has cached. Such a reference makes the relation tree invalid
439+
* whatever this visitor does with it, so re-deriving its type is not turned into a failure of the
440+
* rewrite.
441+
*
438442
* <p>The type of a reference to a lambda parameter, or of an outer reference identified by the
439443
* {@link io.substrait.relation.Rel#getRelAnchor() rel anchor} of the relation it is rooted on, is
440444
* left as it is: neither resolves against a relation in the enclosing scopes tracked during the
441445
* traversal.
442446
*
447+
* <p>Override this rather than {@link #visit(FieldReference, EmptyVisitationContext)} to change
448+
* how references are rewritten: this is what the positions that hold a reference rather than an
449+
* arbitrary expression — a {@link io.substrait.relation.physical.ScatterExchange}'s fields and a
450+
* {@link io.substrait.relation.physical.ComparisonJoinKey}'s sides — are rewritten through.
451+
*
443452
* @param fieldReference the field reference to visit
444453
* @param context the visitation context
445454
* @return Optional containing the modified field reference, or empty if no changes
@@ -453,12 +462,12 @@ public Optional<FieldReference> visitFieldReference(
453462
if (!inputExpression.isPresent()) {
454463
return Optional.empty();
455464
}
465+
// The reference is returned rewritten even when its type could not be re-derived: the
466+
// expression it is rooted at changed, and that change is not lost because the type is stale.
456467
ImmutableFieldReference.Builder rewritten =
457468
ImmutableFieldReference.builder().from(fieldReference).inputExpression(inputExpression);
458-
if (!fieldReference.segments().isEmpty()) {
459-
rewritten.type(
460-
FieldReference.ofExpression(inputExpression.get(), segmentsOf(fieldReference)).type());
461-
}
469+
FieldReference.resolveType(inputExpression.get().getType(), fieldReference.segments())
470+
.ifPresent(rewritten::type);
462471
return Optional.of(rewritten.build());
463472
}
464473
return retypeRootReference(fieldReference);
@@ -476,40 +485,21 @@ private Optional<FieldReference> retypeRootReference(FieldReference fieldReferen
476485
Type.Struct rootType =
477486
getRelCopyOnWriteVisitor()
478487
.inputTypeStepsOut(fieldReference.outerReferenceStepsOut().orElse(0));
479-
// A rewrite that drops fields can leave a reference selecting a field its input no longer has.
480-
// The resulting relation tree is invalid either way, so leave the reference as it is rather
481-
// than turn re-deriving its type into a failure of the rewrite.
482-
if (rootType == null || !selectsFieldOf(fieldReference, rootType)) {
488+
if (rootType == null) {
483489
return Optional.empty();
484490
}
485-
Type type = FieldReference.ofRoot(rootType, segmentsOf(fieldReference)).type();
486-
if (type.equals(fieldReference.type())) {
491+
// A rewrite that drops a field, or that reshapes a nested one, can leave a reference selecting
492+
// something its input no longer has. The resulting relation tree is invalid either way, so
493+
// leave
494+
// the reference as it is rather than turn re-deriving its type into a failure of the rewrite.
495+
// Resolution is total, at every segment depth and for every kind of segment, so this is a
496+
// decision the rewrite makes rather than an exception it has to recover from.
497+
Optional<Type> type = FieldReference.resolveType(rootType, fieldReference.segments());
498+
if (!type.isPresent() || type.get().equals(fieldReference.type())) {
487499
return Optional.empty();
488500
}
489-
return Optional.of(ImmutableFieldReference.builder().from(fieldReference).type(type).build());
490-
}
491-
492-
/**
493-
* Returns whether the outermost segment of the given reference selects a field that the given
494-
* record type has. The segments are held innermost first, so the outermost one is the last.
495-
*/
496-
private static boolean selectsFieldOf(FieldReference fieldReference, Type.Struct rootType) {
497-
List<FieldReference.ReferenceSegment> segments = fieldReference.segments();
498-
if (segments.isEmpty()) {
499-
return false;
500-
}
501-
FieldReference.ReferenceSegment outermost = segments.get(segments.size() - 1);
502-
return outermost instanceof FieldReference.StructField
503-
&& ((FieldReference.StructField) outermost).offset() < rootType.fields().size();
504-
}
505-
506-
/**
507-
* Returns the segments of the given reference as a mutable list, which is what {@link
508-
* FieldReference#ofRoot} and {@link FieldReference#ofExpression} require: they reverse the list
509-
* they are given.
510-
*/
511-
private static List<FieldReference.ReferenceSegment> segmentsOf(FieldReference fieldReference) {
512-
return new ArrayList<>(fieldReference.segments());
501+
return Optional.of(
502+
ImmutableFieldReference.builder().from(fieldReference).type(type.get()).build());
513503
}
514504

515505
@Override

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -609,12 +609,15 @@ public Optional<Rel> visit(ExtensionTable extensionTable, EmptyVisitationContext
609609
public Optional<Rel> visit(HashJoin hashJoin, EmptyVisitationContext context) throws E {
610610
Optional<Rel> left = hashJoin.getLeft().accept(this, context);
611611
Optional<Rel> right = hashJoin.getRight().accept(this, context);
612+
Type.Struct leftType = recordTypeOf(left.orElse(hashJoin.getLeft()));
613+
Type.Struct rightType = recordTypeOf(right.orElse(hashJoin.getRight()));
612614
Type.Struct inputType =
613615
recordTypeOf(left.orElse(hashJoin.getLeft()), right.orElse(hashJoin.getRight()));
614616
Optional<List<ComparisonJoinKey>> keys =
615-
inInputScope(
616-
inputType,
617-
() -> transformList(hashJoin.getKeys(), context, this::visitComparisonJoinKey));
617+
transformList(
618+
hashJoin.getKeys(),
619+
context,
620+
(key, c) -> visitComparisonJoinKey(key, leftType, rightType, c));
618621
Optional<Expression> postFilter =
619622
inInputScope(
620623
inputType, () -> visitOptionalExpression(hashJoin.getPostJoinFilter(), context));
@@ -640,12 +643,15 @@ public Optional<Rel> visit(HashJoin hashJoin, EmptyVisitationContext context) th
640643
public Optional<Rel> visit(MergeJoin mergeJoin, EmptyVisitationContext context) throws E {
641644
Optional<Rel> left = mergeJoin.getLeft().accept(this, context);
642645
Optional<Rel> right = mergeJoin.getRight().accept(this, context);
646+
Type.Struct leftType = recordTypeOf(left.orElse(mergeJoin.getLeft()));
647+
Type.Struct rightType = recordTypeOf(right.orElse(mergeJoin.getRight()));
643648
Type.Struct inputType =
644649
recordTypeOf(left.orElse(mergeJoin.getLeft()), right.orElse(mergeJoin.getRight()));
645650
Optional<List<ComparisonJoinKey>> keys =
646-
inInputScope(
647-
inputType,
648-
() -> transformList(mergeJoin.getKeys(), context, this::visitComparisonJoinKey));
651+
transformList(
652+
mergeJoin.getKeys(),
653+
context,
654+
(key, c) -> visitComparisonJoinKey(key, leftType, rightType, c));
649655
Optional<Expression> postFilter =
650656
inInputScope(
651657
inputType, () -> visitOptionalExpression(mergeJoin.getPostJoinFilter(), context));
@@ -761,6 +767,8 @@ protected Optional<ConsistentPartitionWindow.WindowRelFunctionInvocation> visitW
761767
* Returns the record type that the root field references of a relation's own expressions resolve
762768
* against: the record types of the given inputs, concatenated in order.
763769
*
770+
* <p>Only the fields of the result are ever read, so its own nullability is not meaningful.
771+
*
764772
* @param inputs the relations the expressions are evaluated over, in field order
765773
* @return the combined record type
766774
*/
@@ -872,15 +880,27 @@ public Optional<FieldReference> visitFieldReference(
872880
/**
873881
* Rewrites a comparison join key, returning a new one if either side changed.
874882
*
883+
* <p>Each side is rewritten against its own input, because the field offsets of a join key are
884+
* relative to the side of the join they select from — unlike those of a join condition or
885+
* post-join filter, which are relative to the two inputs combined.
886+
*
875887
* @param key the comparison join key to rewrite
888+
* @param leftType the record type the key's left side selects from
889+
* @param rightType the record type the key's right side selects from
876890
* @param context the visitation context
877891
* @return the rewritten comparison join key, or empty if unchanged
878892
* @throws E if the visit fails
879893
*/
880894
public Optional<ComparisonJoinKey> visitComparisonJoinKey(
881-
ComparisonJoinKey key, EmptyVisitationContext context) throws E {
882-
Optional<FieldReference> left = visitFieldReference(key.getLeft(), context);
883-
Optional<FieldReference> right = visitFieldReference(key.getRight(), context);
895+
ComparisonJoinKey key,
896+
Type.Struct leftType,
897+
Type.Struct rightType,
898+
EmptyVisitationContext context)
899+
throws E {
900+
Optional<FieldReference> left =
901+
inInputScope(leftType, () -> visitFieldReference(key.getLeft(), context));
902+
Optional<FieldReference> right =
903+
inInputScope(rightType, () -> visitFieldReference(key.getRight(), context));
884904
if (allEmpty(left, right)) {
885905
return Optional.empty();
886906
}

0 commit comments

Comments
 (0)