From 2bf9bd6b52d5fd2111a775ffba47a41ed2c1cf08 Mon Sep 17 00:00:00 2001 From: Bruno Cunha Date: Mon, 16 Mar 2026 11:59:10 -0400 Subject: [PATCH] feat(core,isthmus): add DynamicParameter expression support Implement full support for Substrait DynamicParameter expressions, enabling parameterized placeholders in plan bodies instead of embedded literals. This maps bidirectionally to Calcite's RexDynamicParam (JDBC ? bind parameters). Changes: - Add Expression.DynamicParameter POJO with type and parameterReference - Wire visitor pattern across all expression visitors - Add proto conversion (POJO<->Proto) in both directions - Add Calcite conversion (RexDynamicParam<->DynamicParameter) - Replace UnsupportedOperationException in visitDynamicParam - Add debug stringification in ExpressionStringify - Add 20 tests covering proto roundtrips, Calcite conversions, and full end-to-end roundtrips --- .../expression/AbstractExpressionVisitor.java | 13 ++ .../io/substrait/expression/Expression.java | 22 ++ .../expression/ExpressionVisitor.java | 10 + .../proto/ExpressionProtoConverter.java | 12 + .../proto/ProtoExpressionConverter.java | 9 + .../ExpressionCopyOnWriteVisitor.java | 6 + .../proto/DynamicParameterRoundtripTest.java | 107 +++++++++ .../examples/util/ExpressionStringify.java | 6 + .../expression/ExpressionRexConverter.java | 6 + .../expression/RexExpressionConverter.java | 5 +- .../DynamicParameterRoundtripTest.java | 220 ++++++++++++++++++ .../isthmus/DynamicParameterTest.java | 96 ++++++++ 12 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/io/substrait/type/proto/DynamicParameterRoundtripTest.java create mode 100644 isthmus/src/test/java/io/substrait/isthmus/DynamicParameterRoundtripTest.java create mode 100644 isthmus/src/test/java/io/substrait/isthmus/DynamicParameterTest.java diff --git a/core/src/main/java/io/substrait/expression/AbstractExpressionVisitor.java b/core/src/main/java/io/substrait/expression/AbstractExpressionVisitor.java index 340aa5b04..a33edb740 100644 --- a/core/src/main/java/io/substrait/expression/AbstractExpressionVisitor.java +++ b/core/src/main/java/io/substrait/expression/AbstractExpressionVisitor.java @@ -590,4 +590,17 @@ public O visit(Expression.ScalarSubquery expr, C context) throws E { public O visit(Expression.InPredicate expr, C context) throws E { return visitFallback(expr, context); } + + /** + * Visits a dynamic parameter expression. + * + * @param expr the dynamic parameter + * @param context the visitation context + * @return the visit result + * @throws E if visitation fails + */ + @Override + public O visit(Expression.DynamicParameter expr, C context) throws E { + return visitFallback(expr, context); + } } diff --git a/core/src/main/java/io/substrait/expression/Expression.java b/core/src/main/java/io/substrait/expression/Expression.java index 2197b0bd0..8634227e4 100644 --- a/core/src/main/java/io/substrait/expression/Expression.java +++ b/core/src/main/java/io/substrait/expression/Expression.java @@ -1226,6 +1226,28 @@ public R accept( } } + @Value.Immutable + abstract class DynamicParameter implements Expression { + public abstract Type type(); + + public abstract int parameterReference(); + + @Override + public Type getType() { + return type(); + } + + public static ImmutableExpression.DynamicParameter.Builder builder() { + return ImmutableExpression.DynamicParameter.builder(); + } + + @Override + public R accept( + ExpressionVisitor visitor, C context) throws E { + return visitor.visit(this, context); + } + } + enum PredicateOp { PREDICATE_OP_UNSPECIFIED( io.substrait.proto.Expression.Subquery.SetPredicate.PredicateOp.PREDICATE_OP_UNSPECIFIED), diff --git a/core/src/main/java/io/substrait/expression/ExpressionVisitor.java b/core/src/main/java/io/substrait/expression/ExpressionVisitor.java index 05540a924..271f7d9fb 100644 --- a/core/src/main/java/io/substrait/expression/ExpressionVisitor.java +++ b/core/src/main/java/io/substrait/expression/ExpressionVisitor.java @@ -460,4 +460,14 @@ public interface ExpressionVisitor { private static final BoundConverter TO_BOUND_VISITOR = new BoundConverter(); diff --git a/core/src/main/java/io/substrait/expression/proto/ProtoExpressionConverter.java b/core/src/main/java/io/substrait/expression/proto/ProtoExpressionConverter.java index e4a9fffea..be26fa1be 100644 --- a/core/src/main/java/io/substrait/expression/proto/ProtoExpressionConverter.java +++ b/core/src/main/java/io/substrait/expression/proto/ProtoExpressionConverter.java @@ -260,6 +260,15 @@ public Type visit(Type.Struct type) throws RuntimeException { } } + case DYNAMIC_PARAMETER: + { + io.substrait.proto.DynamicParameter dp = expr.getDynamicParameter(); + return Expression.DynamicParameter.builder() + .type(protoTypeConverter.from(dp.getType())) + .parameterReference(dp.getParameterReference()) + .build(); + } + // TODO enum. case ENUM: throw new UnsupportedOperationException("Unsupported type: " + expr.getRexTypeCase()); diff --git a/core/src/main/java/io/substrait/relation/ExpressionCopyOnWriteVisitor.java b/core/src/main/java/io/substrait/relation/ExpressionCopyOnWriteVisitor.java index 1e9254716..a9c4878aa 100644 --- a/core/src/main/java/io/substrait/relation/ExpressionCopyOnWriteVisitor.java +++ b/core/src/main/java/io/substrait/relation/ExpressionCopyOnWriteVisitor.java @@ -439,6 +439,12 @@ public Optional visit( .build()); } + @Override + public Optional visit( + Expression.DynamicParameter expr, EmptyVisitationContext context) throws E { + return Optional.empty(); + } + // utilities protected Optional> visitExprList( diff --git a/core/src/test/java/io/substrait/type/proto/DynamicParameterRoundtripTest.java b/core/src/test/java/io/substrait/type/proto/DynamicParameterRoundtripTest.java new file mode 100644 index 000000000..b7b02750d --- /dev/null +++ b/core/src/test/java/io/substrait/type/proto/DynamicParameterRoundtripTest.java @@ -0,0 +1,107 @@ +package io.substrait.type.proto; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.substrait.TestBase; +import io.substrait.expression.Expression; +import io.substrait.type.TypeCreator; +import org.junit.jupiter.api.Test; + +class DynamicParameterRoundtripTest extends TestBase { + + @Test + void dynamicParameterI64() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder() + .type(TypeCreator.REQUIRED.I64) + .parameterReference(0) + .build(); + + assertEquals(TypeCreator.REQUIRED.I64, dp.getType()); + verifyRoundTrip(dp); + } + + @Test + void dynamicParameterNullableString() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder() + .type(TypeCreator.NULLABLE.STRING) + .parameterReference(1) + .build(); + + assertEquals(TypeCreator.NULLABLE.STRING, dp.getType()); + verifyRoundTrip(dp); + } + + @Test + void dynamicParameterFP64() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder() + .type(TypeCreator.REQUIRED.FP64) + .parameterReference(2) + .build(); + + assertEquals(TypeCreator.REQUIRED.FP64, dp.getType()); + verifyRoundTrip(dp); + } + + @Test + void dynamicParameterI32Nullable() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder() + .type(TypeCreator.NULLABLE.I32) + .parameterReference(42) + .build(); + + assertEquals(42, dp.parameterReference()); + verifyRoundTrip(dp); + } + + @Test + void dynamicParameterDate() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder() + .type(TypeCreator.REQUIRED.DATE) + .parameterReference(3) + .build(); + + assertEquals(TypeCreator.REQUIRED.DATE, dp.getType()); + verifyRoundTrip(dp); + } + + @Test + void dynamicParameterBoolean() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder() + .type(TypeCreator.REQUIRED.BOOLEAN) + .parameterReference(0) + .build(); + + assertEquals(TypeCreator.REQUIRED.BOOLEAN, dp.getType()); + verifyRoundTrip(dp); + } + + @Test + void dynamicParameterDecimal() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder() + .type(TypeCreator.REQUIRED.decimal(10, 2)) + .parameterReference(5) + .build(); + + assertEquals(TypeCreator.REQUIRED.decimal(10, 2), dp.getType()); + verifyRoundTrip(dp); + } + + @Test + void dynamicParameterTimestamp() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder() + .type(TypeCreator.NULLABLE.TIMESTAMP) + .parameterReference(7) + .build(); + + assertEquals(TypeCreator.NULLABLE.TIMESTAMP, dp.getType()); + verifyRoundTrip(dp); + } +} diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java index a22756cc9..a1333c590 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java @@ -352,4 +352,10 @@ public String visit(EmptyMapLiteral expr, EmptyVisitationContext context) throws RuntimeException { return ""; } + + @Override + public String visit(Expression.DynamicParameter expr, EmptyVisitationContext context) + throws RuntimeException { + return ""; + } } diff --git a/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java b/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java index aba780f7d..b0765fb22 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java @@ -789,6 +789,12 @@ public RexNode visit(SetPredicate expr, Context context) throws RuntimeException } } + @Override + public RexNode visit(Expression.DynamicParameter expr, Context context) throws RuntimeException { + RelDataType calciteType = typeConverter.toCalcite(typeFactory, expr.type()); + return rexBuilder.makeDynamicParam(calciteType, expr.parameterReference()); + } + /** * Helper method to create a Calcite ROW expression for encoding UDT struct literals. * diff --git a/isthmus/src/main/java/io/substrait/isthmus/expression/RexExpressionConverter.java b/isthmus/src/main/java/io/substrait/isthmus/expression/RexExpressionConverter.java index 6993c8451..80afeca4e 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/expression/RexExpressionConverter.java +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/RexExpressionConverter.java @@ -116,7 +116,10 @@ public Expression visitCorrelVariable(RexCorrelVariable correlVariable) { @Override public Expression visitDynamicParam(RexDynamicParam dynamicParam) { - throw new UnsupportedOperationException("RexDynamicParam not supported"); + return Expression.DynamicParameter.builder() + .type(typeConverter.toSubstrait(dynamicParam.getType())) + .parameterReference(dynamicParam.getIndex()) + .build(); } @Override diff --git a/isthmus/src/test/java/io/substrait/isthmus/DynamicParameterRoundtripTest.java b/isthmus/src/test/java/io/substrait/isthmus/DynamicParameterRoundtripTest.java new file mode 100644 index 000000000..287426af8 --- /dev/null +++ b/isthmus/src/test/java/io/substrait/isthmus/DynamicParameterRoundtripTest.java @@ -0,0 +1,220 @@ +package io.substrait.isthmus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import io.substrait.expression.Expression; +import io.substrait.extension.ExtensionCollector; +import io.substrait.isthmus.sql.SubstraitCreateStatementParser; +import io.substrait.relation.Filter; +import io.substrait.relation.Project; +import io.substrait.relation.Rel; +import io.substrait.relation.Rel.Remap; +import io.substrait.relation.RelProtoConverter; +import java.util.List; +import org.apache.calcite.prepare.Prepare; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalFilter; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexDynamicParam; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.parser.SqlParseException; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.tools.RelBuilder; +import org.junit.jupiter.api.Test; + +class DynamicParameterRoundtripTest extends PlanTestBase { + static final String CREATES = "CREATE TABLE items (id INT, name VARCHAR, amount DOUBLE)"; + + final Prepare.CatalogReader itemsCatalog; + final RelCreator itemsRelCreator; + final RelBuilder itemsBuilder; + + DynamicParameterRoundtripTest() throws SqlParseException { + itemsCatalog = SubstraitCreateStatementParser.processCreateStatementsToCatalog(CREATES); + itemsRelCreator = new RelCreator(itemsCatalog); + itemsBuilder = itemsRelCreator.createRelBuilder(); + } + + @Test + void singleDynamicParamInFilter() { + Rel table = + sb.namedScan( + List.of("items"), List.of("id", "name", "amount"), List.of(R.I32, N.STRING, R.FP64)); + + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder().type(R.I32).parameterReference(0).build(); + + Rel filtered = sb.filter(input -> sb.equal(sb.fieldReference(input, 0), dp), table); + assertFullRoundTrip(filtered); + } + + @Test + void multipleDynamicParamsInFilter() { + Rel table = + sb.namedScan( + List.of("items"), List.of("id", "name", "amount"), List.of(R.I32, N.STRING, R.FP64)); + + Expression.DynamicParameter dpId = + Expression.DynamicParameter.builder().type(R.I32).parameterReference(0).build(); + Expression.DynamicParameter dpName = + Expression.DynamicParameter.builder().type(N.STRING).parameterReference(1).build(); + + Rel filtered = + sb.filter( + input -> + sb.and( + sb.equal(sb.fieldReference(input, 0), dpId), + sb.equal(sb.fieldReference(input, 1), dpName)), + table); + assertFullRoundTrip(filtered); + } + + @Test + void dynamicParamInProjection() { + Rel table = + sb.namedScan( + List.of("items"), List.of("id", "name", "amount"), List.of(R.I32, N.STRING, R.FP64)); + + Expression.DynamicParameter dpMultiplier = + Expression.DynamicParameter.builder().type(R.FP64).parameterReference(0).build(); + + Project project = + sb.project( + input -> List.of(sb.multiply(sb.fieldReference(input, 2), dpMultiplier)), + Remap.of(List.of(3)), + table); + assertFullRoundTrip(project); + } + + @Test + void dynamicParamWithDifferentTypes() { + Rel table = + sb.namedScan( + List.of("items"), List.of("id", "name", "amount"), List.of(R.I32, N.STRING, R.FP64)); + + Expression.DynamicParameter dpInt = + Expression.DynamicParameter.builder().type(R.I32).parameterReference(0).build(); + Expression.DynamicParameter dpString = + Expression.DynamicParameter.builder().type(N.STRING).parameterReference(1).build(); + Expression.DynamicParameter dpDouble = + Expression.DynamicParameter.builder().type(R.FP64).parameterReference(2).build(); + + Project project = + sb.project(input -> List.of(dpInt, dpString, dpDouble), Remap.of(List.of(3, 4, 5)), table); + assertFullRoundTrip(project); + } + + @Test + void calciteDynamicParamToSubstraitAndBack() { + RelDataType intType = itemsRelCreator.typeFactory().createSqlType(SqlTypeName.INTEGER); + RexDynamicParam dynamicParam = + (RexDynamicParam) itemsBuilder.getRexBuilder().makeDynamicParam(intType, 0); + + RelNode calcitePlan = + itemsBuilder + .scan("ITEMS") + .filter(itemsBuilder.equals(itemsBuilder.field("ID"), dynamicParam)) + .build(); + + Rel substraitRel = SubstraitRelVisitor.convert(calcitePlan, extensions); + assertInstanceOf(Filter.class, substraitRel); + Filter filter = (Filter) substraitRel; + assertContainsDynamicParameter(filter.getCondition(), 0); + + ExtensionCollector collector = new ExtensionCollector(); + io.substrait.proto.Rel proto = new RelProtoConverter(collector).toProto(substraitRel); + Rel roundtripped = + new io.substrait.relation.ProtoRelConverter(collector, extensions).from(proto); + assertEquals(substraitRel, roundtripped); + } + + @Test + void calciteMultipleDynamicParamsToSubstrait() { + RelDataType intType = itemsRelCreator.typeFactory().createSqlType(SqlTypeName.INTEGER); + RelDataType varcharType = itemsRelCreator.typeFactory().createSqlType(SqlTypeName.VARCHAR); + + RexDynamicParam idParam = + (RexDynamicParam) itemsBuilder.getRexBuilder().makeDynamicParam(intType, 0); + RexDynamicParam nameParam = + (RexDynamicParam) itemsBuilder.getRexBuilder().makeDynamicParam(varcharType, 1); + + RelNode calcitePlan = + itemsBuilder + .scan("ITEMS") + .filter( + itemsBuilder.and( + itemsBuilder.equals(itemsBuilder.field("ID"), idParam), + itemsBuilder.equals(itemsBuilder.field("NAME"), nameParam))) + .build(); + + Rel substraitRel = SubstraitRelVisitor.convert(calcitePlan, extensions); + assertInstanceOf(Filter.class, substraitRel); + + ExtensionCollector collector = new ExtensionCollector(); + io.substrait.proto.Rel proto = new RelProtoConverter(collector).toProto(substraitRel); + Rel roundtripped = + new io.substrait.relation.ProtoRelConverter(collector, extensions).from(proto); + assertEquals(substraitRel, roundtripped); + } + + @Test + void fullCalciteRoundtripWithDynamicParam() { + Rel table = + sb.namedScan( + List.of("items"), List.of("id", "name", "amount"), List.of(R.I32, N.STRING, R.FP64)); + + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder().type(R.I32).parameterReference(0).build(); + + Rel filtered = sb.filter(input -> sb.equal(sb.fieldReference(input, 0), dp), table); + + RelNode calciteNode = new SubstraitToCalcite(converterProvider).convert(filtered); + assertInstanceOf(LogicalFilter.class, calciteNode); + LogicalFilter calciteFilter = (LogicalFilter) calciteNode; + assertContainsRexDynamicParam(calciteFilter.getCondition(), 0); + + Rel backToSubstrait = SubstraitRelVisitor.convert(calciteNode, extensions); + assertEquals(filtered, backToSubstrait); + } + + private void assertContainsDynamicParameter(Expression expr, int expectedRef) { + if (!containsDynamicParameter(expr, expectedRef)) { + throw new AssertionError( + String.format( + "Expected a DynamicParameter with ref=%d in expression: %s", expectedRef, expr)); + } + } + + private boolean containsDynamicParameter(Expression expr, int expectedRef) { + if (expr instanceof Expression.DynamicParameter) { + return ((Expression.DynamicParameter) expr).parameterReference() == expectedRef; + } + if (expr instanceof Expression.ScalarFunctionInvocation) { + Expression.ScalarFunctionInvocation sfi = (Expression.ScalarFunctionInvocation) expr; + return sfi.arguments().stream() + .filter(arg -> arg instanceof Expression) + .anyMatch(arg -> containsDynamicParameter((Expression) arg, expectedRef)); + } + return false; + } + + private void assertContainsRexDynamicParam(RexNode rex, int expectedIndex) { + if (!containsRexDynamicParam(rex, expectedIndex)) { + throw new AssertionError( + String.format("Expected a RexDynamicParam with index=%d in: %s", expectedIndex, rex)); + } + } + + private boolean containsRexDynamicParam(RexNode rex, int expectedIndex) { + if (rex instanceof RexDynamicParam) { + return ((RexDynamicParam) rex).getIndex() == expectedIndex; + } + if (rex instanceof RexCall) { + return ((RexCall) rex) + .operands.stream().anyMatch(operand -> containsRexDynamicParam(operand, expectedIndex)); + } + return false; + } +} diff --git a/isthmus/src/test/java/io/substrait/isthmus/DynamicParameterTest.java b/isthmus/src/test/java/io/substrait/isthmus/DynamicParameterTest.java new file mode 100644 index 000000000..22abce7e9 --- /dev/null +++ b/isthmus/src/test/java/io/substrait/isthmus/DynamicParameterTest.java @@ -0,0 +1,96 @@ +package io.substrait.isthmus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import io.substrait.expression.Expression; +import io.substrait.isthmus.SubstraitRelNodeConverter.Context; +import io.substrait.isthmus.expression.ExpressionRexConverter; +import io.substrait.isthmus.expression.ScalarFunctionConverter; +import io.substrait.isthmus.expression.WindowFunctionConverter; +import io.substrait.relation.Project; +import io.substrait.relation.Rel; +import io.substrait.relation.Rel.Remap; +import io.substrait.type.Type; +import java.util.List; +import org.apache.calcite.rex.RexDynamicParam; +import org.apache.calcite.rex.RexNode; +import org.junit.jupiter.api.Test; + +class DynamicParameterTest extends PlanTestBase { + + final List commonTableType = List.of(R.I32, R.FP32, N.STRING, N.BOOLEAN); + + final ExpressionRexConverter expressionRexConverter = + new ExpressionRexConverter( + typeFactory, + new ScalarFunctionConverter(extensions.scalarFunctions(), typeFactory), + new WindowFunctionConverter(extensions.windowFunctions(), typeFactory), + TypeConverter.DEFAULT); + + @Test + void dynamicParameterToCalcite() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder().type(R.I64).parameterReference(0).build(); + + RexNode calciteExpr = dp.accept(expressionRexConverter, Context.newContext()); + + assertInstanceOf(RexDynamicParam.class, calciteExpr); + RexDynamicParam rexDp = (RexDynamicParam) calciteExpr; + assertEquals(0, rexDp.getIndex()); + assertEquals(TypeConverter.DEFAULT.toCalcite(typeFactory, R.I64), rexDp.getType()); + } + + @Test + void dynamicParameterNullableStringToCalcite() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder().type(N.STRING).parameterReference(1).build(); + + RexNode calciteExpr = dp.accept(expressionRexConverter, Context.newContext()); + + assertInstanceOf(RexDynamicParam.class, calciteExpr); + RexDynamicParam rexDp = (RexDynamicParam) calciteExpr; + assertEquals(1, rexDp.getIndex()); + assertEquals(TypeConverter.DEFAULT.toCalcite(typeFactory, N.STRING), rexDp.getType()); + } + + @Test + void dynamicParameterFP64ToCalcite() { + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder().type(R.FP64).parameterReference(5).build(); + + RexNode calciteExpr = dp.accept(expressionRexConverter, Context.newContext()); + + assertInstanceOf(RexDynamicParam.class, calciteExpr); + RexDynamicParam rexDp = (RexDynamicParam) calciteExpr; + assertEquals(5, rexDp.getIndex()); + assertEquals(TypeConverter.DEFAULT.toCalcite(typeFactory, R.FP64), rexDp.getType()); + } + + @Test + void dynamicParameterInProjectRoundTrip() { + Rel commonTable = + sb.namedScan(List.of("example"), List.of("a", "b", "c", "d"), commonTableType); + + Expression.DynamicParameter dp = + Expression.DynamicParameter.builder().type(R.I32).parameterReference(0).build(); + + Project project = sb.project(input -> List.of(dp), Remap.of(List.of(4)), commonTable); + assertFullRoundTrip(project); + } + + @Test + void dynamicParameterMultipleInProjectRoundTrip() { + Rel commonTable = + sb.namedScan(List.of("example"), List.of("a", "b", "c", "d"), commonTableType); + + Expression.DynamicParameter dp0 = + Expression.DynamicParameter.builder().type(R.I32).parameterReference(0).build(); + + Expression.DynamicParameter dp1 = + Expression.DynamicParameter.builder().type(N.STRING).parameterReference(1).build(); + + Project project = sb.project(input -> List.of(dp0, dp1), Remap.of(List.of(4, 5)), commonTable); + assertFullRoundTrip(project); + } +}