From 5f7c3928454655b7c372e1c06d4fea52daf7ba69 Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Thu, 3 Sep 2026 15:45:47 +0300 Subject: [PATCH 1/5] fix(core)!: visit a window relation's input when rewriting it RelCopyOnWriteVisitor.visit(ConsistentPartitionWindow) rewrote the window functions, the partition expressions and the sorts, and never visited its input. It was the only SingleInputRel in core that did not; Expand and ExtensionWrite throw instead, which is a separate defect. The input is now visited alongside the three lists and folded into the allEmpty check, so a genuinely unchanged relation still comes back as Optional.empty. The existing window tests put a bare namedScan under the relation, which has nothing to rewrite; the new one nests a Project over a literal. Closes #1231 BREAKING CHANGE: RelCopyOnWriteVisitor now descends into a ConsistentPartitionWindow's input. A rewrite belonging below a window relation used to be discarded, and a rewrite that applied only there collapsed the whole traversal to Optional.empty; both now give back the window relation with its input rewritten. --- .../relation/RelCopyOnWriteVisitor.java | 4 +- .../relation/RelCopyOnWriteVisitorTest.java | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/io/substrait/relation/RelCopyOnWriteVisitor.java b/core/src/main/java/io/substrait/relation/RelCopyOnWriteVisitor.java index 13961775d..767789eb5 100644 --- a/core/src/main/java/io/substrait/relation/RelCopyOnWriteVisitor.java +++ b/core/src/main/java/io/substrait/relation/RelCopyOnWriteVisitor.java @@ -611,6 +611,7 @@ public Optional visit(NestedLoopJoin nestedLoopJoin, EmptyVisitationContext public Optional visit( ConsistentPartitionWindow consistentPartitionWindow, EmptyVisitationContext context) throws E { + Optional input = consistentPartitionWindow.getInput().accept(this, context); Optional> windowFunctions = transformList( consistentPartitionWindow.getWindowFunctions(), context, this::visitWindowRelFunction); @@ -622,13 +623,14 @@ public Optional visit( Optional> sorts = transformList(consistentPartitionWindow.getSorts(), context, this::visitSortField); - if (allEmpty(windowFunctions, partitionExpressions, sorts)) { + if (allEmpty(input, windowFunctions, partitionExpressions, sorts)) { return Optional.empty(); } return Optional.of( ConsistentPartitionWindow.builder() .from(consistentPartitionWindow) + .input(input.orElse(consistentPartitionWindow.getInput())) .partitionExpressions( partitionExpressions.orElse(consistentPartitionWindow.getPartitionExpressions())) .sorts(sorts.orElse(consistentPartitionWindow.getSorts())) diff --git a/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java b/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java index 7856a81d1..6925f636d 100644 --- a/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java +++ b/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java @@ -104,4 +104,45 @@ void consistentPartitionWindowWithUnchangedBoundsIsNotCopied() { Optional.empty(), window.accept(negateI32LiteralsVisitor(), EmptyVisitationContext.INSTANCE)); } + + /** + * The window relation's own lists are not the only thing below it. A rewrite that applies to the + * input has nowhere else to be found: a window relation is the only single-input relation in this + * visitor that used to decide "unchanged" without asking its input. + */ + @Test + void consistentPartitionWindowRewritesItsInput() { + SimpleExtension.WindowFunctionVariant declaration = + extensions.getWindowFunction( + SimpleExtension.FunctionAnchor.of( + DefaultExtensionCatalog.FUNCTIONS_ARITHMETIC, "lead:any")); + Rel scan = sb.namedScan(Arrays.asList("test"), Arrays.asList("a"), Arrays.asList(R.I32)); + Rel input = sb.project(in -> Arrays.asList(sb.i32(5)), sb.remap(1), scan); + ConsistentPartitionWindow window = + ConsistentPartitionWindow.builder() + .input(input) + .windowFunctions( + Arrays.asList( + ConsistentPartitionWindow.WindowRelFunctionInvocation.builder() + .declaration(declaration) + .arguments(Collections.emptyList()) + .outputType(R.I64) + .aggregationPhase(Expression.AggregationPhase.INITIAL_TO_RESULT) + .invocation(Expression.AggregationInvocation.ALL) + .lowerBound(WindowBound.UNBOUNDED) + .upperBound(WindowBound.CURRENT_ROW) + .boundsType(Expression.WindowBoundsType.RANGE) + .build())) + .build(); + + Optional rewritten = + window.accept(negateI32LiteralsVisitor(), EmptyVisitationContext.INSTANCE); + + ConsistentPartitionWindow expected = + ConsistentPartitionWindow.builder() + .from(window) + .input(sb.project(in -> Arrays.asList(sb.i32(-5)), sb.remap(1), scan)) + .build(); + assertEquals(Optional.of(expected), rewritten); + } } From 7f385e45a8dc8c33a2170446acb8cae0035405e3 Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Thu, 3 Sep 2026 18:39:20 +0300 Subject: [PATCH 2/5] fix(core): fold the expression into a multi-bucket exchange's guard visit(MultiBucketExchange) computed the rewritten expression and then decided "nothing changed" from the input alone, so an expression-only rewrite was discarded the same way the window relation's input was. visit(SingleBucketExchange) already had the form. The test fails without it, and the window Javadoc now says what the test asserts rather than what the visitor used to do. --- .../relation/RelCopyOnWriteVisitor.java | 2 +- .../relation/RelCopyOnWriteVisitorTest.java | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/substrait/relation/RelCopyOnWriteVisitor.java b/core/src/main/java/io/substrait/relation/RelCopyOnWriteVisitor.java index 767789eb5..a991ea6b8 100644 --- a/core/src/main/java/io/substrait/relation/RelCopyOnWriteVisitor.java +++ b/core/src/main/java/io/substrait/relation/RelCopyOnWriteVisitor.java @@ -392,7 +392,7 @@ public Optional visit(MultiBucketExchange exchange, EmptyVisitationContext Optional expression = exchange.getExpression().accept(getExpressionCopyOnWriteVisitor(), context); - if (allEmpty(input)) { + if (allEmpty(input, expression)) { return Optional.empty(); } diff --git a/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java b/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java index 6925f636d..0f7d8c95e 100644 --- a/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java +++ b/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java @@ -7,6 +7,7 @@ import io.substrait.expression.WindowBound; import io.substrait.extension.DefaultExtensionCatalog; import io.substrait.extension.SimpleExtension; +import io.substrait.relation.physical.MultiBucketExchange; import io.substrait.util.EmptyVisitationContext; import java.util.Arrays; import java.util.Collections; @@ -105,11 +106,7 @@ void consistentPartitionWindowWithUnchangedBoundsIsNotCopied() { window.accept(negateI32LiteralsVisitor(), EmptyVisitationContext.INSTANCE)); } - /** - * The window relation's own lists are not the only thing below it. A rewrite that applies to the - * input has nowhere else to be found: a window relation is the only single-input relation in this - * visitor that used to decide "unchanged" without asking its input. - */ + /** A rewrite that applies only below a window relation comes back with the input replaced. */ @Test void consistentPartitionWindowRewritesItsInput() { SimpleExtension.WindowFunctionVariant declaration = @@ -145,4 +142,27 @@ void consistentPartitionWindowRewritesItsInput() { .build(); assertEquals(Optional.of(expected), rewritten); } + + /** + * The same guard, one relation over: a rewrite that touches only the exchange's own expression + * comes back, where the input alone used to decide whether anything changed. + */ + @Test + void multiBucketExchangeRewritesItsExpression() { + Rel scan = sb.namedScan(Arrays.asList("test"), Arrays.asList("a"), Arrays.asList(R.I32)); + MultiBucketExchange exchange = + MultiBucketExchange.builder() + .input(scan) + .expression(sb.i32(5)) + .constrainedToCount(true) + .partitionCount(1) + .build(); + + Optional rewritten = + exchange.accept(negateI32LiteralsVisitor(), EmptyVisitationContext.INSTANCE); + + assertEquals( + Optional.of(MultiBucketExchange.builder().from(exchange).expression(sb.i32(-5)).build()), + rewritten); + } } From 6a9d16b94665becfd9a316ed5bd9ed59f933cfd9 Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Thu, 3 Sep 2026 20:11:26 +0300 Subject: [PATCH 3/5] test(core): hold every relation with inputs to propagating a rewrite Drives the check from the shared samples instead of one hand-picked relation, so a relation added without visiting its input fails here too rather than dropping rewrites silently. On fb6a54a it names ConsistentPartitionWindow. Expand and ExtensionWrite are exempt because the visitor refuses them outright rather than descending into them. The three window fixtures in the file differed only in the input and the bounds, so they collapse into one windowOver helper, which takes the no-op empty argument list with them. --- .../relation/RelCopyOnWriteVisitorTest.java | 115 +++++++++--------- 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java b/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java index 0f7d8c95e..ddebfdd25 100644 --- a/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java +++ b/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java @@ -1,6 +1,7 @@ package io.substrait.relation; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.substrait.TestBase; import io.substrait.expression.Expression; @@ -9,8 +10,10 @@ import io.substrait.extension.SimpleExtension; import io.substrait.relation.physical.MultiBucketExchange; import io.substrait.util.EmptyVisitationContext; +import io.substrait.utils.RelSamples; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -36,29 +39,33 @@ public Optional visitLiteral(Expression.Literal literal) { }); } - @Test - void consistentPartitionWindowBoundOffsetsAreRewritten() { + /** A window over {@code input} whose one function carries the given bounds. */ + private ConsistentPartitionWindow windowOver(Rel input, WindowBound lower, WindowBound upper) { SimpleExtension.WindowFunctionVariant declaration = extensions.getWindowFunction( SimpleExtension.FunctionAnchor.of( DefaultExtensionCatalog.FUNCTIONS_ARITHMETIC, "lead:any")); + return ConsistentPartitionWindow.builder() + .input(input) + .windowFunctions( + Arrays.asList( + ConsistentPartitionWindow.WindowRelFunctionInvocation.builder() + .declaration(declaration) + .outputType(R.I64) + .aggregationPhase(Expression.AggregationPhase.INITIAL_TO_RESULT) + .invocation(Expression.AggregationInvocation.ALL) + .lowerBound(lower) + .upperBound(upper) + .boundsType(Expression.WindowBoundsType.RANGE) + .build())) + .build(); + } + + @Test + void consistentPartitionWindowBoundOffsetsAreRewritten() { Rel input = sb.namedScan(Arrays.asList("test"), Arrays.asList("a"), Arrays.asList(R.I64)); ConsistentPartitionWindow window = - ConsistentPartitionWindow.builder() - .input(input) - .windowFunctions( - Arrays.asList( - ConsistentPartitionWindow.WindowRelFunctionInvocation.builder() - .declaration(declaration) - .arguments(Collections.emptyList()) - .outputType(R.I64) - .aggregationPhase(Expression.AggregationPhase.INITIAL_TO_RESULT) - .invocation(Expression.AggregationInvocation.ALL) - .lowerBound(WindowBound.Preceding.of(sb.i32(5))) - .upperBound(WindowBound.Following.of(sb.i32(7))) - .boundsType(Expression.WindowBoundsType.RANGE) - .build())) - .build(); + windowOver(input, WindowBound.Preceding.of(sb.i32(5)), WindowBound.Following.of(sb.i32(7))); Optional rewritten = window.accept(negateI32LiteralsVisitor(), EmptyVisitationContext.INSTANCE); @@ -79,27 +86,9 @@ void consistentPartitionWindowBoundOffsetsAreRewritten() { @Test void consistentPartitionWindowWithUnchangedBoundsIsNotCopied() { - SimpleExtension.WindowFunctionVariant declaration = - extensions.getWindowFunction( - SimpleExtension.FunctionAnchor.of( - DefaultExtensionCatalog.FUNCTIONS_ARITHMETIC, "lead:any")); Rel input = sb.namedScan(Arrays.asList("test"), Arrays.asList("a"), Arrays.asList(R.I64)); ConsistentPartitionWindow window = - ConsistentPartitionWindow.builder() - .input(input) - .windowFunctions( - Arrays.asList( - ConsistentPartitionWindow.WindowRelFunctionInvocation.builder() - .declaration(declaration) - .arguments(Collections.emptyList()) - .outputType(R.I64) - .aggregationPhase(Expression.AggregationPhase.INITIAL_TO_RESULT) - .invocation(Expression.AggregationInvocation.ALL) - .lowerBound(WindowBound.UNBOUNDED) - .upperBound(WindowBound.CURRENT_ROW) - .boundsType(Expression.WindowBoundsType.RANGE) - .build())) - .build(); + windowOver(input, WindowBound.UNBOUNDED, WindowBound.CURRENT_ROW); assertEquals( Optional.empty(), @@ -109,28 +98,10 @@ void consistentPartitionWindowWithUnchangedBoundsIsNotCopied() { /** A rewrite that applies only below a window relation comes back with the input replaced. */ @Test void consistentPartitionWindowRewritesItsInput() { - SimpleExtension.WindowFunctionVariant declaration = - extensions.getWindowFunction( - SimpleExtension.FunctionAnchor.of( - DefaultExtensionCatalog.FUNCTIONS_ARITHMETIC, "lead:any")); Rel scan = sb.namedScan(Arrays.asList("test"), Arrays.asList("a"), Arrays.asList(R.I32)); Rel input = sb.project(in -> Arrays.asList(sb.i32(5)), sb.remap(1), scan); ConsistentPartitionWindow window = - ConsistentPartitionWindow.builder() - .input(input) - .windowFunctions( - Arrays.asList( - ConsistentPartitionWindow.WindowRelFunctionInvocation.builder() - .declaration(declaration) - .arguments(Collections.emptyList()) - .outputType(R.I64) - .aggregationPhase(Expression.AggregationPhase.INITIAL_TO_RESULT) - .invocation(Expression.AggregationInvocation.ALL) - .lowerBound(WindowBound.UNBOUNDED) - .upperBound(WindowBound.CURRENT_ROW) - .boundsType(Expression.WindowBoundsType.RANGE) - .build())) - .build(); + windowOver(input, WindowBound.UNBOUNDED, WindowBound.CURRENT_ROW); Optional rewritten = window.accept(negateI32LiteralsVisitor(), EmptyVisitationContext.INSTANCE); @@ -165,4 +136,38 @@ void multiBucketExchangeRewritesItsExpression() { Optional.of(MultiBucketExchange.builder().from(exchange).expression(sb.i32(-5)).build()), rewritten); } + + /** + * Every relation that has inputs hands back a rewrite made below it, so a relation added without + * visiting its input fails here rather than dropping rewrites silently. Driven by the shared + * samples, whose own test keeps them exhaustive over the model. + */ + @Test + void everyRelationWithInputsPropagatesARewriteBelowIt() { + // The two the visitor refuses outright rather than descending into. + List> notVisited = Arrays.asList(Expand.class, ExtensionWrite.class); + RelCopyOnWriteVisitor renameScans = + new RelCopyOnWriteVisitor() { + @Override + public Optional visit(NamedScan namedScan, EmptyVisitationContext context) { + return Optional.of( + NamedScan.builder() + .from(namedScan) + .names(Collections.singletonList("renamed")) + .build()); + } + }; + + new RelSamples(sb, extensions) + .samples() + .forEach( + (type, rel) -> { + if (rel.getInputs().isEmpty() || notVisited.contains(type)) { + return; + } + assertTrue( + rel.accept(renameScans, EmptyVisitationContext.INSTANCE).isPresent(), + type.getSimpleName()); + }); + } } From 0bb8a82892b8ac030a29521f1ce5f481a42d4b9c Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Thu, 3 Sep 2026 20:11:26 +0300 Subject: [PATCH 4/5] test(core): pin the outer reference a window relation used to hide A correlated subquery in a window relation's input was invisible to OuterReferenceConverter in both directions, so a round trip through it was an identity and could not tell the fix from the bug. The case asserts the anchor and the rel_reference on the id-based plan instead, and fails on fb6a54a. --- .../relation/OuterReferenceConverterTest.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/core/src/test/java/io/substrait/relation/OuterReferenceConverterTest.java b/core/src/test/java/io/substrait/relation/OuterReferenceConverterTest.java index 3c70bee82..fd3c779ea 100644 --- a/core/src/test/java/io/substrait/relation/OuterReferenceConverterTest.java +++ b/core/src/test/java/io/substrait/relation/OuterReferenceConverterTest.java @@ -332,6 +332,74 @@ void outerReferenceInsideWindowBoundOffsetIsConverted() { assertEquals(stepsOut, OuterReferenceConverter.toStepsOut(idBased)); } + @Test + void outerReferenceUnderAWindowRelationIsConverted() { + // The correlated filter sits in the window relation's input rather than in its bounds, so the + // reference is reachable only once the rewrite descends into that input. Asserting on the + // id-based plan rather than on a round trip, which was an identity in both directions before. + SimpleExtension.WindowFunctionVariant declaration = + extensions.getWindowFunction( + SimpleExtension.FunctionAnchor.of( + DefaultExtensionCatalog.FUNCTIONS_ARITHMETIC, "lead:any")); + + Rel correlatedFilter = + sb.filter( + input -> + sb.equal( + sb.fieldReference(input, 0), + FieldReference.newRootStructOuterReference(1, TypeCreator.REQUIRED.I64, 1)), + customerTableScan); + Rel window = + ConsistentPartitionWindow.builder() + .input(correlatedFilter) + .windowFunctions( + List.of( + ConsistentPartitionWindow.WindowRelFunctionInvocation.builder() + .declaration(declaration) + .arguments(List.of(sb.fieldReference(correlatedFilter, 0))) + .outputType(TypeCreator.NULLABLE.I64) + .aggregationPhase(Expression.AggregationPhase.INITIAL_TO_RESULT) + .invocation(Expression.AggregationInvocation.ALL) + .lowerBound(WindowBound.UNBOUNDED) + .upperBound(WindowBound.CURRENT_ROW) + .boundsType(Expression.WindowBoundsType.RANGE) + .build())) + .build(); + + Rel stepsOut = + sb.project( + input -> + List.of( + sb.fieldReference(input, 0), + sb.scalarSubquery( + sb.project( + input2 -> List.of(sb.fieldReference(input2, 1)), + Remap.of(List.of(1)), + window), + TypeCreator.NULLABLE.I64)), + Remap.of(List.of(2, 3)), + orderTableScan); + + Rel idBased = OuterReferenceConverter.toIdBased(stepsOut); + + assertNotEquals(stepsOut, idBased); + + Project outerProject = (Project) idBased; + assertEquals(1, outerProject.getInput().getRelAnchor().orElseThrow(AssertionError::new)); + + Expression.ScalarSubquery subquery = + (Expression.ScalarSubquery) outerProject.getExpressions().get(1); + Filter filter = + (Filter) ((ConsistentPartitionWindow) ((Project) subquery.input()).getInput()).getInput(); + Expression.ScalarFunctionInvocation equal = + (Expression.ScalarFunctionInvocation) filter.getCondition(); + FieldReference outerRef = (FieldReference) equal.arguments().get(1); + assertEquals(1, outerRef.outerReferenceRelReference().orElseThrow(AssertionError::new)); + assertFalse(outerRef.outerReferenceStepsOut().isPresent()); + + assertEquals(stepsOut, OuterReferenceConverter.toStepsOut(idBased)); + } + /** A one-step correlated-subquery plan whose outer reference binds to {@code bindingScan}. */ private Rel oneStepPlanBoundTo(Rel bindingScan) { return sb.project( From 5e47bfd2c8327e452b4d526e2bdf9dfcc29a695c Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Fri, 4 Sep 2026 10:43:22 +0300 Subject: [PATCH 5/5] test(core): sweep the samples for the expression half of the guard too The rename-a-scan sweep exercises only each guard's input branch, so it would not have caught the MultiBucketExchange half of this PR: on the base visitor it reports ConsistentPartitionWindow and the exchange passes. A second sweep marks every field reference instead and pins the relations whose samples carry one, so a relation whose visit computes an expression and then decides from its input alone drops out of that set. On the base MultiBucketExchange is the one missing. negateI32LiteralsVisitor could not do it. No sample carries an i32 literal, and widening it to i64 reaches only Fetch, Filter and TopN -- not the exchange, whose sample expression is a field reference. --- .../relation/RelCopyOnWriteVisitorTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java b/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java index ddebfdd25..f4a56b062 100644 --- a/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java +++ b/core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java @@ -5,16 +5,19 @@ import io.substrait.TestBase; import io.substrait.expression.Expression; +import io.substrait.expression.FieldReference; import io.substrait.expression.WindowBound; import io.substrait.extension.DefaultExtensionCatalog; import io.substrait.extension.SimpleExtension; import io.substrait.relation.physical.MultiBucketExchange; import io.substrait.util.EmptyVisitationContext; import io.substrait.utils.RelSamples; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; class RelCopyOnWriteVisitorTest extends TestBase { @@ -170,4 +173,53 @@ public Optional visit(NamedScan namedScan, EmptyVisitationContext context) type.getSimpleName()); }); } + + /** + * The sweep above exercises only the input branch of each guard: a rewrite that reaches an + * expression and leaves the input alone has to come back too, which is the second half of this + * fix. The relations whose samples carry a field reference are pinned by name, so a relation + * whose visit computes an expression and then decides from its input drops out of the set rather + * than passing quietly -- {@code MultiBucketExchange} does exactly that on the base. + */ + @Test + void everyRelationCarryingAnExpressionPropagatesARewriteInIt() { + List> notVisited = Arrays.asList(Expand.class, ExtensionWrite.class); + RelCopyOnWriteVisitor markEveryReference = + new RelCopyOnWriteVisitor<>( + relVisitor -> + new ExpressionCopyOnWriteVisitor(relVisitor) { + @Override + public Optional visit( + FieldReference reference, EmptyVisitationContext context) { + return Optional.of(reference); + } + }); + + List rewritten = new ArrayList<>(); + new RelSamples(sb, extensions) + .samples() + .forEach( + (type, rel) -> { + if (rel.getInputs().isEmpty() || notVisited.contains(type)) { + return; + } + if (rel.accept(markEveryReference, EmptyVisitationContext.INSTANCE).isPresent()) { + rewritten.add(type.getSimpleName()); + } + }); + + assertEquals( + Arrays.asList( + "Aggregate", + "ConsistentPartitionWindow", + "Filter", + "Join", + "MultiBucketExchange", + "NestedLoopJoin", + "Project", + "SingleBucketExchange", + "Sort", + "TopN"), + rewritten.stream().sorted().collect(Collectors.toList())); + } }