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); + } +}