From b2dc2bec1daa8174f2ed23cb7a1ec27a85cfa4a8 Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Mon, 7 Sep 2026 17:10:32 +0300 Subject: [PATCH] feat(core): evaluate return-type programs and inline arithmetic Decimal arithmetic functions declare their return type as assignments followed by a final type expression. The evaluator currently rejects these programs, so their precision and scale cannot be derived from the function declaration. Evaluate sequential assignments and arithmetic inside type parameters using the existing argument bindings. Support integer arithmetic, min/max, comparisons, boolean operations and conditionals from [spec v0.102.0](https://github.com/substrait-io/substrait/blob/v0.102.0/site/docs/expressions/scalar_functions.md#return-type-expressions). Use signed 64-bit intermediate values and reject arithmetic overflow or narrowing outside the type parameter's 32-bit range. Integer division truncates toward zero. AND/OR evaluate both operands, while conditionals evaluate only the selected branch, matching [substrait-go's evaluator](https://github.com/substrait-io/substrait-go/blob/cb2d6e648bc0ee1549e354dbfe705d9f8a1ce2b1/types/type_derivation.go). This enables 11 of the 15 catalog return programs: eight decimal arithmetic and three rounding variants. Four temporal variants still reference integer_parameter(precision) without binding precision from an argument type and remain unsupported. Refs #1242 --- .../type/TypeExpressionEvaluator.java | 216 +++++++++++++++--- .../io/substrait/type/parser/ParseToPojo.java | 6 + .../FunctionBindingResolverTest.java | 24 ++ .../type/ParameterizedReturnTypeTest.java | 42 +++- .../substrait/type/ReturnProgramTypeTest.java | 156 +++++++++++++ 5 files changed, 403 insertions(+), 41 deletions(-) create mode 100644 core/src/test/java/io/substrait/type/ReturnProgramTypeTest.java diff --git a/core/src/main/java/io/substrait/type/TypeExpressionEvaluator.java b/core/src/main/java/io/substrait/type/TypeExpressionEvaluator.java index 45f7e5e98..a1101c462 100644 --- a/core/src/main/java/io/substrait/type/TypeExpressionEvaluator.java +++ b/core/src/main/java/io/substrait/type/TypeExpressionEvaluator.java @@ -27,16 +27,19 @@ * type classes whose parameter is an integer to substitute: {@code DECIMAL}, {@code * varchar}, {@code fixedchar}, {@code fixedbinary}, {@code precision_time

}, {@code * precision_timestamp

}, {@code precision_timestamp_tz

}, {@code interval_day

} and {@code - * interval_compound

}. No standard extension declares a parameterized {@code fixedbinary} or - * {@code interval_compound} at all, as an argument or as a return -- those two are supported for + * interval_compound

}. Integer arithmetic, comparisons, boolean operations and conditionals can + * appear in those parameters or in the assignments of a multi-line return program. Arithmetic uses + * signed 64-bit values; overflow and narrowing to a type parameter's 32-bit representation are + * checked rather than wrapped. No standard extension declares a parameterized {@code fixedbinary} + * or {@code interval_compound} at all, as an argument or as a return -- those two are supported for * symmetry, and pinned against hand-written declarations rather than the catalog. * *

A {@code list} return still fails whatever its element, because the evaluator does not descend * into a container -- so an element parameter it would otherwise substitute, as in {@code - * list>}, is out of reach just as an element type to evaluate is. A multi-line return - * program still fails because evaluating one needs integer arithmetic over the bound parameters - * rather than substitution. And a plain {@code any} cannot be derived at all: unlike {@code any1} - * it names nothing, so there is no identity to bind. + * list>}, is out of reach just as an element type to evaluate is. A program referring + * to an argument's value rather than a parameter of its type still fails: this API receives only + * argument types. A plain {@code any} cannot be derived at all: unlike {@code any1} it names + * nothing, so there is no identity to bind. Type-covering expressions are not supported either. * *

Which shipped variants those cover is pinned by {@code ParameterizedReturnTypeTest} against * the declarations the catalog ships, and deliberately not repeated here -- the catalog is owned @@ -85,7 +88,13 @@ public static Type evaluateExpression( // The declared return type is already concrete; nothing to derive. return (Type) returnExpression; } - return returnExpression.accept(new ReturnTypeEvaluator(returnExpression, bindings)); + try { + return new ReturnTypeEvaluator(returnExpression, bindings) + .evaluate(returnExpression, Type.class); + } catch (ArithmeticException e) { + throw new UnsupportedOperationException( + "Cannot evaluate return-type arithmetic: " + e.getMessage(), e); + } } /** @@ -311,9 +320,12 @@ private static OptionalInt parseIntegerLiteral(String token) { * throwing base, keeping unsupported derivations fail-closed. */ private static final class ReturnTypeEvaluator - extends TypeExpressionVisitor.TypeExpressionThrowsVisitor { + extends TypeExpressionVisitor.TypeExpressionThrowsVisitor { private final ParameterBindings bindings; + // The derivation language has three value kinds: integer, boolean and type. Local assignments + // can hold any of them; each operation checks the kind it consumes. + private final Map locals = new HashMap<>(); private ReturnTypeEvaluator(TypeExpression returnExpression, ParameterBindings bindings) { super("Cannot evaluate return-type expression: " + returnExpression); @@ -322,60 +334,73 @@ private ReturnTypeEvaluator(TypeExpression returnExpression, ParameterBindings b @Override public Type visit(ParameterizedType.Decimal decimal) { - int precision = resolveInteger(decimal.precision().value()); - int scale = resolveInteger(decimal.scale().value()); + int precision = resolveInteger(decimal.precision()); + int scale = resolveInteger(decimal.scale()); return TypeCreator.of(decimal.nullable()).decimal(precision, scale); } @Override public Type visit(ParameterizedType.FixedChar fixedChar) { - return TypeCreator.of(fixedChar.nullable()) - .fixedChar(resolveInteger(fixedChar.length().value())); + return TypeCreator.of(fixedChar.nullable()).fixedChar(resolveInteger(fixedChar.length())); } @Override public Type visit(ParameterizedType.VarChar varChar) { - return TypeCreator.of(varChar.nullable()).varChar(resolveInteger(varChar.length().value())); + return TypeCreator.of(varChar.nullable()).varChar(resolveInteger(varChar.length())); } @Override public Type visit(ParameterizedType.FixedBinary fixedBinary) { return TypeCreator.of(fixedBinary.nullable()) - .fixedBinary(resolveInteger(fixedBinary.length().value())); + .fixedBinary(resolveInteger(fixedBinary.length())); } @Override public Type visit(ParameterizedType.PrecisionTime precisionTime) { return TypeCreator.of(precisionTime.nullable()) - .precisionTime(resolveInteger(precisionTime.precision().value())); + .precisionTime(resolveInteger(precisionTime.precision())); } @Override public Type visit(ParameterizedType.PrecisionTimestamp precisionTimestamp) { return TypeCreator.of(precisionTimestamp.nullable()) - .precisionTimestamp(resolveInteger(precisionTimestamp.precision().value())); + .precisionTimestamp(resolveInteger(precisionTimestamp.precision())); } @Override public Type visit(ParameterizedType.PrecisionTimestampTZ precisionTimestampTZ) { return TypeCreator.of(precisionTimestampTZ.nullable()) - .precisionTimestampTZ(resolveInteger(precisionTimestampTZ.precision().value())); + .precisionTimestampTZ(resolveInteger(precisionTimestampTZ.precision())); } @Override public Type visit(ParameterizedType.IntervalDay intervalDay) { return TypeCreator.of(intervalDay.nullable()) - .intervalDay(resolveInteger(intervalDay.precision().value())); + .intervalDay(resolveInteger(intervalDay.precision())); } @Override public Type visit(ParameterizedType.IntervalCompound intervalCompound) { return TypeCreator.of(intervalCompound.nullable()) - .intervalCompound(resolveInteger(intervalCompound.precision().value())); + .intervalCompound(resolveInteger(intervalCompound.precision())); } @Override - public Type visit(ParameterizedType.StringLiteral stringLiteral) { + public Object visit(ParameterizedType.StringLiteral stringLiteral) { + Object local = locals.get(stringLiteral.value()); + if (local != null) { + return local instanceof Type + ? ((Type) local).withNullable(stringLiteral.nullable()) + : local; + } + Integer integer = bindings.boundInteger(stringLiteral.value()); + if (integer != null) { + return integer.longValue(); + } + OptionalInt literal = parseIntegerLiteral(stringLiteral.value()); + if (literal.isPresent()) { + return (long) literal.getAsInt(); + } // A wildcard return (e.g. min(any1) -> any1) resolves to the bound argument type, taking the // nullability declared on the return expression in both directions (a required return forces // the type non-null, a nullable one forces it nullable). MIRROR policy, if any, is applied @@ -388,16 +413,149 @@ public Type visit(ParameterizedType.StringLiteral stringLiteral) { return bound.withNullable(stringLiteral.nullable()); } - private int resolveInteger(String token) { - Integer bound = bindings.boundInteger(token); - if (bound != null) { - return bound; + @Override + public Type visit(TypeExpression.Decimal decimal) { + return TypeCreator.of(decimal.nullable()) + .decimal(resolveInteger(decimal.precision()), resolveInteger(decimal.scale())); + } + + @Override + public Type visit(TypeExpression.FixedChar fixedChar) { + return TypeCreator.of(fixedChar.nullable()).fixedChar(resolveInteger(fixedChar.length())); + } + + @Override + public Type visit(TypeExpression.VarChar varChar) { + return TypeCreator.of(varChar.nullable()).varChar(resolveInteger(varChar.length())); + } + + @Override + public Type visit(TypeExpression.FixedBinary fixedBinary) { + return TypeCreator.of(fixedBinary.nullable()) + .fixedBinary(resolveInteger(fixedBinary.length())); + } + + @Override + public Type visit(TypeExpression.PrecisionTime precisionTime) { + return TypeCreator.of(precisionTime.nullable()) + .precisionTime(resolveInteger(precisionTime.precision())); + } + + @Override + public Type visit(TypeExpression.PrecisionTimestamp precisionTimestamp) { + return TypeCreator.of(precisionTimestamp.nullable()) + .precisionTimestamp(resolveInteger(precisionTimestamp.precision())); + } + + @Override + public Type visit(TypeExpression.PrecisionTimestampTZ precisionTimestampTZ) { + return TypeCreator.of(precisionTimestampTZ.nullable()) + .precisionTimestampTZ(resolveInteger(precisionTimestampTZ.precision())); + } + + @Override + public Type visit(TypeExpression.IntervalDay intervalDay) { + return TypeCreator.of(intervalDay.nullable()) + .intervalDay(resolveInteger(intervalDay.precision())); + } + + @Override + public Type visit(TypeExpression.IntervalCompound intervalCompound) { + return TypeCreator.of(intervalCompound.nullable()) + .intervalCompound(resolveInteger(intervalCompound.precision())); + } + + @Override + public Object visit(TypeExpression.ReturnProgram program) { + for (TypeExpression.ReturnProgram.Assignment assignment : program.assignments()) { + locals.put(assignment.name(), evaluate(assignment.expr(), Object.class)); + } + return evaluate(program.finalExpression(), Type.class); + } + + @Override + public Long visit(TypeExpression.IntegerLiteral literal) { + return (long) literal.value(); + } + + @Override + public Object visit(TypeExpression.IfOperation conditional) { + return evaluate( + evaluate(conditional.ifCondition(), Boolean.class) + ? conditional.thenExpr() + : conditional.elseExpr(), + Object.class); + } + + @Override + public Boolean visit(TypeExpression.NotOperation operation) { + return !evaluate(operation.inner(), Boolean.class); + } + + @Override + public Object visit(TypeExpression.BinaryOperation operation) { + switch (operation.opType()) { + case AND: + case OR: + boolean left = evaluate(operation.left(), Boolean.class); + boolean right = evaluate(operation.right(), Boolean.class); + return operation.opType() == TypeExpression.BinaryOperation.OpType.AND + ? left && right + : left || right; + case COVERS: + throw new UnsupportedOperationException("Cannot evaluate type-covering expressions"); + default: + break; + } + long left = evaluate(operation.left(), Long.class); + long right = evaluate(operation.right(), Long.class); + switch (operation.opType()) { + case ADD: + return Math.addExact(left, right); + case SUBTRACT: + return Math.subtractExact(left, right); + case MULTIPLY: + return Math.multiplyExact(left, right); + case DIVIDE: + if (left == Long.MIN_VALUE && right == -1) { + throw new ArithmeticException("long overflow"); + } + return left / right; + case MIN: + return Math.min(left, right); + case MAX: + return Math.max(left, right); + case LT: + return left < right; + case GT: + return left > right; + case LTE: + return left <= right; + case GTE: + return left >= right; + case EQ: + return left == right; + case NOT_EQ: + return left != right; + default: + throw new UnsupportedOperationException( + "Cannot evaluate operation " + operation.opType()); + } + } + + private int resolveInteger(TypeExpression expression) { + return Math.toIntExact(evaluate(expression, Long.class)); + } + + private T evaluate(TypeExpression expression, Class expectedKind) { + Object result = expression instanceof Type ? expression : expression.accept(this); + if (!expectedKind.isInstance(result)) { + throw new UnsupportedOperationException( + String.format( + "Expected %s in return-type expression, got %s", + expectedKind.getSimpleName(), result)); } - return parseIntegerLiteral(token) - .orElseThrow( - () -> - new UnsupportedOperationException( - "Unbound type parameter '" + token + "' in return-type expression")); + return expectedKind.cast(result); } } } diff --git a/core/src/main/java/io/substrait/type/parser/ParseToPojo.java b/core/src/main/java/io/substrait/type/parser/ParseToPojo.java index 7cad8c004..d3c92a76d 100644 --- a/core/src/main/java/io/substrait/type/parser/ParseToPojo.java +++ b/core/src/main/java/io/substrait/type/parser/ParseToPojo.java @@ -675,14 +675,20 @@ private TypeExpression.BinaryOperation.OpType getBinaryExpressionType(Token toke return TypeExpression.BinaryOperation.OpType.DIVIDE; case ">": return TypeExpression.BinaryOperation.OpType.GT; + case ">=": + return TypeExpression.BinaryOperation.OpType.GTE; case "<": return TypeExpression.BinaryOperation.OpType.LT; + case "<=": + return TypeExpression.BinaryOperation.OpType.LTE; case "AND": return TypeExpression.BinaryOperation.OpType.AND; case "OR": return TypeExpression.BinaryOperation.OpType.OR; case "=": return TypeExpression.BinaryOperation.OpType.EQ; + case "!=": + return TypeExpression.BinaryOperation.OpType.NOT_EQ; case ":=": return TypeExpression.BinaryOperation.OpType.COVERS; default: diff --git a/core/src/test/java/io/substrait/extension/FunctionBindingResolverTest.java b/core/src/test/java/io/substrait/extension/FunctionBindingResolverTest.java index af3e60601..b3cc824c1 100644 --- a/core/src/test/java/io/substrait/extension/FunctionBindingResolverTest.java +++ b/core/src/test/java/io/substrait/extension/FunctionBindingResolverTest.java @@ -9,6 +9,7 @@ import com.google.common.io.Resources; import io.substrait.expression.FunctionOption; +import io.substrait.type.Type; import io.substrait.type.TypeCreator; import java.io.IOException; import java.io.UncheckedIOException; @@ -67,6 +68,29 @@ void resolvesConcreteIntegerSum() { assertEquals(sum.getAnchor(), binding.anchor()); } + @Test + void decimalDivisionDerivesIndependentlyOfTheDeclaredOutputType() { + SimpleExtension.ScalarFunctionVariant divide = + scalar(DefaultExtensionCatalog.FUNCTIONS_ARITHMETIC_DECIMAL, "divide:dec_dec"); + List arguments = + List.of(ResolvedArgument.value(R.decimal(10, 2)), ResolvedArgument.value(R.decimal(5, 1))); + assertEquals(R.decimal(21, 8), FunctionBindingResolver.deriveOutputType(divide, arguments)); + assertEquals( + R.decimal(21, 8), + FunctionBindingResolver.resolveAndValidate(divide, arguments, List.of(), R.decimal(21, 8)) + .outputType()); + + for (Type declared : List.of(R.decimal(20, 2), R.decimal(21, 7), N.decimal(21, 8))) { + InvalidFunctionBindingException error = + assertThrows( + InvalidFunctionBindingException.class, + () -> + FunctionBindingResolver.resolveAndValidate( + divide, arguments, List.of(), declared)); + assertTrue(error.getMessage().contains("output type"), error.getMessage()); + } + } + @Test void resolvesDecimalSumWidth() { SimpleExtension.AggregateFunctionVariant sum = diff --git a/core/src/test/java/io/substrait/type/ParameterizedReturnTypeTest.java b/core/src/test/java/io/substrait/type/ParameterizedReturnTypeTest.java index ba4566df7..8291d45a5 100644 --- a/core/src/test/java/io/substrait/type/ParameterizedReturnTypeTest.java +++ b/core/src/test/java/io/substrait/type/ParameterizedReturnTypeTest.java @@ -156,10 +156,8 @@ void mirrorNullabilityStillApplies() { } /** - * The census of what the evaluator does not derive: a {@code list} return is the first shape, a - * multi-line return program the second. {@link TypeExpressionEvaluator}'s Javadoc describes those - * shapes and points here rather than naming variants, so this test is the only place a {@code - * substrait-packaging} bump can make the two disagree. + * The census of list returns the evaluator does not derive. The catalog is owned upstream, so + * this catches declarations added by a {@code substrait-packaging} bump. */ @Test void theReturnShapesThatAreNotDerivedYet() { @@ -174,6 +172,13 @@ void theReturnShapesThatAreNotDerivedYet() { "transform:list_func"), variantsReturning(ParameterizedType.ListType.class)); + assertThrows( + UnsupportedOperationException.class, + () -> resolve("string_split:vchar_vchar", R.varChar(20), R.varChar(20))); + } + + @Test + void catalogReturnProgramsAreCovered() { assertEquals( List.of( "add:dec_dec", @@ -193,14 +198,27 @@ void theReturnShapesThatAreNotDerivedYet() { "subtract:dec_dec"), variantsReturning(TypeExpression.ReturnProgram.class)); - // The lists above pin which variants carry each shape; these pin that the shapes actually fail, - // so making one derivable cannot leave the census passing and the Javadoc stale. - assertThrows( - UnsupportedOperationException.class, - () -> resolve("string_split:vchar_vchar", R.varChar(20), R.varChar(20))); - assertThrows( - UnsupportedOperationException.class, - () -> resolve("add:dec_dec", R.decimal(10, 2), R.decimal(10, 2))); + // Arithmetic programs are exercised with exact expectations in ReturnProgramTypeTest. + // The rounding programs derive from the input's precision and scale too, including round: + // the pinned declaration does not read its value argument s. + assertEquals(R.decimal(9, 0), resolve("ceil:dec", R.decimal(10, 2))); + assertEquals(R.decimal(9, 0), resolve("floor:dec", R.decimal(10, 2))); + assertEquals(N.decimal(11, 2), resolve("round:dec_i32", R.decimal(10, 2), R.I32)); + + // These four declarations read integer_parameter(precision) without binding precision from + // any argument type. Supplying an i8 type cannot provide that argument's value. + for (String key : List.of("strptime_time:str_str_i8", "strptime_timestamp:str_str_i8")) { + assertUnboundPrecision(key, R.STRING, R.STRING, R.I8); + } + assertUnboundPrecision("strptime_timestamp:str_str_str_i8", R.STRING, R.STRING, R.STRING, R.I8); + assertUnboundPrecision("assume_timezone:date_str_i8", R.DATE, R.STRING, R.I8); + } + + private static void assertUnboundPrecision(String key, Type... arguments) { + UnsupportedOperationException error = + assertThrows(UnsupportedOperationException.class, () -> resolve(key, arguments)); + assertTrue( + error.getMessage().contains("Unbound type parameter 'precision'"), error.getMessage()); } private static List variantsReturning(Class returnShape) { diff --git a/core/src/test/java/io/substrait/type/ReturnProgramTypeTest.java b/core/src/test/java/io/substrait/type/ReturnProgramTypeTest.java new file mode 100644 index 000000000..45d71fb5c --- /dev/null +++ b/core/src/test/java/io/substrait/type/ReturnProgramTypeTest.java @@ -0,0 +1,156 @@ +package io.substrait.type; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.substrait.extension.DefaultExtensionCatalog; +import io.substrait.extension.SimpleExtension; +import io.substrait.function.ParameterizedTypeCreator; +import io.substrait.type.parser.TypeStringParser; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ReturnProgramTypeTest { + + private static final TypeCreator R = TypeCreator.REQUIRED; + private static final TypeCreator N = TypeCreator.NULLABLE; + private static final String URN = DefaultExtensionCatalog.FUNCTIONS_ARITHMETIC_DECIMAL; + + private static Type resolve(String key, Type... arguments) { + return DefaultExtensionCatalog.DEFAULT_COLLECTION + .getScalarFunction(SimpleExtension.FunctionAnchor.of(URN, key)) + .resolveType(List.of(arguments)); + } + + @ParameterizedTest + @CsvSource({ + "add,10,2,5,1,11,2", + "add,38,10,38,10,38,9", + "subtract,10,2,5,1,11,2", + "subtract,38,10,38,10,38,9", + "multiply,10,2,5,1,16,3", + "multiply,38,10,38,10,38,6", + "multiply,30,20,30,20,38,17", + "divide,10,2,5,1,21,8", + "divide,38,10,38,10,38,6", + "modulus,10,2,5,1,6,2" + }) + void decimalProgramsDeriveTheCatalogFormula( + String name, int p1, int s1, int p2, int s2, int precision, int scale) { + // Expected types follow the extension's spec v0.102.0 formulas. In particular, divide's + // precision uses P2, where the separate prose example uses S2. + assertEquals( + R.decimal(precision, scale), + resolve(name + ":dec_dec", R.decimal(p1, s1), R.decimal(p2, s2))); + } + + @Test + void decimalProgramsPreserveMirrorNullabilityAndLiteralConstraints() { + assertEquals(N.decimal(11, 2), resolve("add:dec_dec", N.decimal(10, 2), R.decimal(5, 1))); + for (String name : List.of("bitwise_and", "bitwise_or", "bitwise_xor")) { + assertEquals( + R.decimal(20, 0), resolve(name + ":dec_dec", R.decimal(10, 0), R.decimal(20, 0))); + assertThrows( + UnsupportedOperationException.class, + () -> resolve(name + ":dec_dec", R.decimal(10, 1), R.decimal(20, 0))); + } + } + + private static Type evaluate(String expression) { + return TypeExpressionEvaluator.evaluateExpression( + TypeStringParser.parseExpression(expression, URN), + List.of( + SimpleExtension.ValueArgument.builder() + .name("input") + .value(ParameterizedTypeCreator.REQUIRED.varCharE("L")) + .build()), + List.of(R.varChar(10))); + } + + @ParameterizedTest + @CsvSource( + delimiter = ';', + value = { + "varchar; varchar<11>", + "fixedchar; fixedchar<20>", + "fixedbinary; fixedbinary<5>", + "decimal; decimal<12,2>", + "precision_time; precision_time<6>", + "precision_timestamp; precision_timestamp<6>", + "precision_timestamp_tz; precision_timestamp_tz<6>", + "interval_day; interval_day<3>", + "interval_compound; interval_compound<3>" + }) + void arithmeticWorksInsideTypeParameters(String expression, String expected) { + assertEquals(TypeStringParser.parseSimple(expected, URN), evaluate(expression)); + } + + @Test + void assignmentsUseEarlierResultsAndRemainLocalToOneEvaluation() { + assertEquals(R.varChar(24), evaluate("a = L + 2\nb = a * 2\nvarchar")); + assertEquals(R.varChar(10), evaluate("wide = L > 5\nvarchar")); + assertEquals(R.varChar(22), evaluate("L = L + 1\nL = L * 2\nvarchar")); + assertEquals(R.varChar(10), evaluate("varchar")); + assertThrows(UnsupportedOperationException.class, () -> evaluate("varchar")); + } + + @Test + void conditionsSelectOnlyTheChosenBranch() { + assertEquals(R.varChar(10), evaluate("varchar 5 ? L : missing>")); + assertEquals(R.varChar(10), evaluate("varchar")); + assertEquals(R.I64, evaluate("(L = 10) ? i64 : string")); + assertEquals(R.varChar(10), evaluate("varchar 0 ? L : 1>")); + assertEquals(R.varChar(10), evaluate("varchar")); + assertEquals(N.I64, evaluate("if L >= 10 then i64? else string")); + assertEquals(R.STRING, evaluate("L != 10 ? i64 : string")); + assertEquals(R.varChar(2), evaluate("varchar<(L <= 10 AND L >= 10 AND L != 9) ? 2 : 1>")); + assertEquals(R.varChar(1), evaluate("varchar<(L <= 9 OR L >= 11 OR L != 10) ? 2 : 1>")); + } + + @Test + void booleanOperationsEvaluateBothOperands() { + assertArithmeticFailure("varchar<(L < 0 AND L / 0 > 0) ? 1 : 2>"); + assertArithmeticFailure("varchar<(L > 0 OR L / 0 > 0) ? 1 : 2>"); + } + + @Test + void signedDivisionTruncatesTowardsZero() { + assertEquals(R.varChar(13), evaluate("varchar")); + assertEquals(R.varChar(7), evaluate("varchar")); + } + + @Test + void integerExpressionsUse64BitsBeforeConvertingToATypeParameter() { + assertEquals(R.varChar(10), evaluate("wide = 2147483647 + L\nvarchar")); + assertArithmeticFailure("varchar<2147483647 + L>"); + assertArithmeticFailure("wide = 2147483647 * 2147483647 * L\nvarchar<10>"); + + String minimum = "low = (0 - 2147483647 - 1) * (2147483647 + 1) * 2\n"; + assertEquals(R.varChar(10), evaluate(minimum + "varchar")); + for (String expression : List.of("low - 1", "(0 - (low + 1)) + 1", "low / (0 - 1)")) { + // Overflow must fail even when the assignment's result is not used by the final type. + assertArithmeticFailure(minimum + "wide = " + expression + "\nvarchar<10>"); + } + } + + @Test + void invalidExpressionKindsAndUnboundParametersAreRejected() { + for (String expression : List.of("varchar 0>", "varchar")) { + assertThrows(UnsupportedOperationException.class, () -> evaluate(expression), expression); + } + assertArithmeticFailure("varchar"); + UnsupportedOperationException error = + assertThrows(UnsupportedOperationException.class, () -> evaluate("varchar")); + assertTrue(error.getMessage().contains("missing"), error.getMessage()); + } + + private static void assertArithmeticFailure(String expression) { + UnsupportedOperationException error = + assertThrows(UnsupportedOperationException.class, () -> evaluate(expression), expression); + assertInstanceOf(ArithmeticException.class, error.getCause(), expression); + } +}