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 b4997de97..a1c2703e5 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java @@ -592,7 +592,7 @@ public RexNode visit(Expression.ScalarFunctionInvocation expr, Context context) throws RuntimeException { SqlOperator operator = scalarFunctionConverter - .getSqlOperatorFromSubstraitFunc(expr.declaration().key(), expr.outputType()) + .getSqlOperatorFromSubstraitFunc(expr) .orElseThrow( () -> new IllegalArgumentException( diff --git a/isthmus/src/main/java/io/substrait/isthmus/expression/ScalarFunctionConverter.java b/isthmus/src/main/java/io/substrait/isthmus/expression/ScalarFunctionConverter.java index 854bf2094..0781c4ac3 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/expression/ScalarFunctionConverter.java +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/ScalarFunctionConverter.java @@ -5,6 +5,7 @@ import io.substrait.expression.Expression; import io.substrait.expression.ExpressionCreator; import io.substrait.expression.FunctionArg; +import io.substrait.expression.FunctionOption; import io.substrait.extension.DefaultExtensionCatalog; import io.substrait.extension.SimpleExtension; import io.substrait.isthmus.CallConverter; @@ -48,6 +49,9 @@ public class ScalarFunctionConverter */ private final List mappers; + private final List optionPolicies = + List.of(new StringFunctionOptions()); + /** * Creates a converter with the given functions and type factory. * @@ -144,6 +148,14 @@ public Stream getOperands() { private Optional defaultConvert( RexCall call, Function topLevelConverter) { FunctionFinder finder = signatures.get(call.op); + if (finder == null) { + for (ScalarFunctionOptionPolicy policy : optionPolicies) { + finder = signatures.get(policy.signatureOperator(call)); + if (finder != null) { + break; + } + } + } WrappedScalarCall wrapped = new WrappedScalarCall(call); return attemptMatch(finder, wrapped, topLevelConverter); @@ -185,7 +197,12 @@ protected Expression generateBinding( List arguments, Type outputType) { if (!DefaultExtensionCatalog.FUNCTIONS_DATETIME.equals(function.getAnchor().urn())) { - return ExpressionCreator.scalarFunction(function, outputType, arguments); + return Expression.ScalarFunctionInvocation.builder() + .declaration(function) + .outputType(outputType) + .addAllArguments(arguments) + .options(options(call.delegate, function)) + .build(); } // The datetime extension declares its results by parameter, where Calcite keeps an operand's // own type: add(date, interval_day

) is a precision_timestamp

there and a DATE here. The @@ -378,6 +395,49 @@ public List getExpressionArguments(Expression.ScalarFunctionInvocat return getMappedExpressionArguments(expression).orElseGet(expression::arguments); } + /** + * Resolves an invocation through the existing operator mapping and its option policy. + * + * @param expression the Substrait scalar invocation + * @return the selected operator, or empty when no mapping exists + */ + public Optional getSqlOperatorFromSubstraitFunc( + Expression.ScalarFunctionInvocation expression) { + return getSqlOperatorFromSubstraitFunc(expression.declaration().key(), expression.outputType()) + .map(operator -> resolveOptions(expression, operator)); + } + + /** + * Selects an operator that honors the requested options. Custom converters can override this + * together with {@link #options} to supply their own semantics in both directions. + * + * @param expression the Substrait invocation + * @param operator the selected Calcite operator + * @return the operator implementing a supported preference + * @throws UnsupportedOperationException if no requested preference is supported + */ + protected SqlOperator resolveOptions( + Expression.ScalarFunctionInvocation expression, SqlOperator operator) { + for (ScalarFunctionOptionPolicy policy : optionPolicies) { + operator = policy.resolve(expression, operator); + } + return operator; + } + + /** + * Returns the option preferences implemented by the matched Calcite call. + * + * @param call the Calcite call + * @param function the bound Substrait variant + * @return the preferences to export + */ + protected List options( + RexCall call, SimpleExtension.ScalarFunctionVariant function) { + return optionPolicies.stream() + .flatMap(policy -> policy.forCall(call, function).stream()) + .collect(Collectors.toList()); + } + /** * Builds a Calcite call, applying a reverse mapping when the native operator is not executable. * diff --git a/isthmus/src/main/java/io/substrait/isthmus/expression/ScalarFunctionOptionPolicy.java b/isthmus/src/main/java/io/substrait/isthmus/expression/ScalarFunctionOptionPolicy.java new file mode 100644 index 000000000..ec1ebcbd4 --- /dev/null +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/ScalarFunctionOptionPolicy.java @@ -0,0 +1,20 @@ +package io.substrait.isthmus.expression; + +import io.substrait.expression.Expression; +import io.substrait.expression.FunctionOption; +import io.substrait.extension.SimpleExtension.ScalarFunctionVariant; +import java.util.List; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.sql.SqlOperator; + +/** Option semantics for one family of scalar functions, in both conversion directions. */ +interface ScalarFunctionOptionPolicy { + SqlOperator resolve(Expression.ScalarFunctionInvocation expression, SqlOperator operator); + + List forCall(RexCall call, ScalarFunctionVariant function); + + /** Returns the operator whose signature also binds this call, such as an unchecked equivalent. */ + default SqlOperator signatureOperator(RexCall call) { + return call.getOperator(); + } +} diff --git a/isthmus/src/main/java/io/substrait/isthmus/expression/StringFunctionOptions.java b/isthmus/src/main/java/io/substrait/isthmus/expression/StringFunctionOptions.java new file mode 100644 index 000000000..0d0ac3c0a --- /dev/null +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/StringFunctionOptions.java @@ -0,0 +1,140 @@ +package io.substrait.isthmus.expression; + +import io.substrait.expression.Expression; +import io.substrait.expression.FunctionOption; +import io.substrait.extension.DefaultExtensionCatalog; +import io.substrait.extension.SimpleExtension.ScalarFunctionVariant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.fun.SqlLibraryOperators; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; + +/** The spec v0.103.0 string options supported by the corresponding Calcite operators. */ +final class StringFunctionOptions implements ScalarFunctionOptionPolicy { + private static final Map BINDINGS = + Map.of( + "concat", + new Binding( + "null_handling", + "ACCEPT_NULLS", + Set.of(SqlStdOperatorTable.CONCAT, SqlLibraryOperators.CONCAT_FUNCTION)), + "like", + new Binding("case_sensitivity", "CASE_SENSITIVE", Set.of(SqlStdOperatorTable.LIKE)), + "replace", + new Binding( + "case_sensitivity", "CASE_SENSITIVE", Set.of(SqlStdOperatorTable.REPLACE)), + "starts_with", + new Binding( + "case_sensitivity", "CASE_SENSITIVE", Set.of(SqlLibraryOperators.STARTS_WITH)), + "ends_with", + new Binding( + "case_sensitivity", "CASE_SENSITIVE", Set.of(SqlLibraryOperators.ENDS_WITH)), + "strpos", + new Binding( + "case_sensitivity", "CASE_SENSITIVE", Set.of(SqlStdOperatorTable.POSITION)), + "substring", + new Binding( + "negative_start", "LEFT_OF_BEGINNING", Set.of(SqlStdOperatorTable.SUBSTRING)), + "lower", new Binding("char_set", "UTF8", Set.of(SqlStdOperatorTable.LOWER)), + "upper", new Binding("char_set", "UTF8", Set.of(SqlStdOperatorTable.UPPER)), + "initcap", new Binding("char_set", null, Set.of(SqlStdOperatorTable.INITCAP))); + + @Override + public List forCall(RexCall call, ScalarFunctionVariant function) { + Binding binding = binding(function); + if (binding == null + || binding.value() == null + || !binding.operators().contains(call.getOperator())) { + return List.of(); + } + // An omitted option lets a consumer choose any supported behavior. Emit the one + // the producer's Calcite operator actually implements instead of a YAML default. + return List.of( + FunctionOption.builder().name(binding.name()).addValues(binding.value()).build()); + } + + @Override + public SqlOperator resolve(Expression.ScalarFunctionInvocation expression, SqlOperator operator) { + Binding binding = binding(expression.declaration()); + if (binding == null) { + return operator; + } + if (!expression.options().isEmpty() && !binding.operators().contains(operator)) { + throw new UnsupportedOperationException( + "No string option policy for Calcite operator " + operator.getName()); + } + for (FunctionOption option : expression.options()) { + if (!binding.name().equalsIgnoreCase(option.getName())) { + throw new UnsupportedOperationException( + "Unsupported " + expression.declaration().name() + " option: " + option.getName()); + } + for (String value : option.values()) { + boolean declared = + expression.declaration().options().entrySet().stream() + .filter(entry -> entry.getKey().equalsIgnoreCase(option.getName())) + .flatMap(entry -> entry.getValue().getValues().stream()) + .anyMatch(value::equalsIgnoreCase); + if (!declared) { + throw new UnsupportedOperationException( + expression.declaration().name() + + " option " + + option.getName() + + " does not declare value " + + value); + } + } + if (binding.value() == null) { + // The spec lists initcap's charsets without defining ASCII word boundaries. + throw new UnsupportedOperationException( + "No established Calcite initcap charset option policy"); + } + // Preferences name acceptable behaviors, in order. These operators implement one + // behavior each, so skip other preferences and reject when none is supported. + if (option.values().stream().noneMatch(binding.value()::equalsIgnoreCase)) { + throw new UnsupportedOperationException( + "Calcite " + + expression.declaration().name() + + " requires " + + binding.name() + + " " + + binding.value() + + "; preferences: " + + option.values()); + } + } + return operator; + } + + private static Binding binding(ScalarFunctionVariant function) { + return DefaultExtensionCatalog.FUNCTIONS_STRING.equals(function.urn()) + ? BINDINGS.get(function.name()) + : null; + } + + private static final class Binding { + private final String name; + private final String value; + private final Set operators; + + private Binding(String name, String value, Set operators) { + this.name = name; + this.value = value; + this.operators = operators; + } + + private String name() { + return name; + } + + private String value() { + return value; + } + + private Set operators() { + return operators; + } + } +} diff --git a/isthmus/src/test/java/io/substrait/isthmus/ConcatFunctionOptionsTest.java b/isthmus/src/test/java/io/substrait/isthmus/ConcatFunctionOptionsTest.java new file mode 100644 index 000000000..05f4a3c59 --- /dev/null +++ b/isthmus/src/test/java/io/substrait/isthmus/ConcatFunctionOptionsTest.java @@ -0,0 +1,174 @@ +package io.substrait.isthmus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.substrait.expression.Expression; +import io.substrait.expression.ExpressionCreator; +import io.substrait.expression.FunctionOption; +import io.substrait.extension.DefaultExtensionCatalog; +import io.substrait.extension.ImmutableSimpleExtension; +import io.substrait.extension.SimpleExtension; +import io.substrait.isthmus.SubstraitRelNodeConverter.Context; +import io.substrait.isthmus.expression.CallConverters; +import io.substrait.isthmus.expression.ExpressionRexConverter; +import io.substrait.isthmus.expression.RexExpressionConverter; +import io.substrait.isthmus.expression.ScalarFunctionConverter; +import io.substrait.isthmus.expression.WindowFunctionConverter; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.apache.calcite.DataContexts; +import org.apache.calcite.rex.RexExecutorImpl; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ConcatFunctionOptionsTest extends PlanTestBase { + private final ScalarFunctionConverter scalar = + new ScalarFunctionConverter(extensions.scalarFunctions(), typeFactory); + private final WindowFunctionConverter window = + new WindowFunctionConverter(extensions.windowFunctions(), typeFactory); + private final ExpressionRexConverter toRex = + new ExpressionRexConverter(typeFactory, scalar, window, TypeConverter.DEFAULT); + private final RexExpressionConverter fromRex = + new RexExpressionConverter( + null, + Stream.concat(CallConverters.defaults(TypeConverter.DEFAULT).stream(), Stream.of(scalar)) + .toList(), + window, + TypeConverter.DEFAULT); + + private Expression.ScalarFunctionInvocation concat(List options) { + Expression.ScalarFunctionInvocation call = + sb.scalarFn( + DefaultExtensionCatalog.FUNCTIONS_STRING, + "concat:str", + N.STRING, + ExpressionCreator.string(false, "a"), + ExpressionCreator.typedNull(N.STRING)); + return Expression.ScalarFunctionInvocation.builder().from(call).options(options).build(); + } + + private FunctionOption nullHandling(String... preferences) { + return FunctionOption.builder().name("null_handling").addValues(preferences).build(); + } + + @ParameterizedTest + @ValueSource(strings = {"concat(a, b)", "a || b", "concat(a, b, a)"}) + void sqlExportNamesItsNullHandling(String expression) throws Exception { + Expression.ScalarFunctionInvocation call = + (Expression.ScalarFunctionInvocation) + firstExpression( + "SELECT " + expression + " FROM strings", + "CREATE TABLE strings (a VARCHAR, b VARCHAR)"); + assertEquals(List.of(nullHandling("ACCEPT_NULLS")), call.options()); + } + + @Test + void roundTripKeepsAnExplicitOption() { + Expression.ScalarFunctionInvocation original = concat(List.of(nullHandling("ACCEPT_NULLS"))); + RexNode rex = original.accept(toRex, Context.newContext()); + Expression.ScalarFunctionInvocation back = + (Expression.ScalarFunctionInvocation) rex.accept(fromRex); + assertEquals(original.options(), back.options()); + } + + @Test + void acceptNullsReturnsNull() { + RexNode accept = + concat(List.of(nullHandling("ACCEPT_NULLS"))).accept(toRex, Context.newContext()); + List reduced = new ArrayList<>(); + new RexExecutorImpl(DataContexts.EMPTY).reduce(creator.rex(), List.of(accept), reduced); + assertTrue(RexLiteral.isNullLiteral(reduced.get(0))); + } + + @ParameterizedTest + @ValueSource(strings = {"IGNORE_NULLS", "IGNORE_NULLS,ACCEPT_NULLS", "ACCEPT_NULLS,IGNORE_NULLS"}) + void selectsTheFirstSupportedPreference(String preferences) { + Expression.ScalarFunctionInvocation original = + concat(List.of(nullHandling(preferences.split(",")))); + if (preferences.equals("IGNORE_NULLS")) { + UnsupportedOperationException failure = + assertThrows( + UnsupportedOperationException.class, + () -> original.accept(toRex, Context.newContext())); + assertTrue(failure.getMessage().contains("ACCEPT_NULLS")); + } else { + RexNode rex = original.accept(toRex, Context.newContext()); + Expression.ScalarFunctionInvocation back = + (Expression.ScalarFunctionInvocation) rex.accept(fromRex); + assertEquals(List.of(nullHandling("ACCEPT_NULLS")), back.options()); + } + } + + @Test + void matchesOptionNamesAndValuesCaseInsensitively() { + FunctionOption option = + FunctionOption.builder().name("NULL_HANDLING").addValues("accept_nulls").build(); + RexNode rex = concat(List.of(option)).accept(toRex, Context.newContext()); + Expression.ScalarFunctionInvocation back = + (Expression.ScalarFunctionInvocation) rex.accept(fromRex); + assertEquals(List.of(nullHandling("ACCEPT_NULLS")), back.options()); + } + + @Test + void omittedOptionsAllowCalciteToChooseItsSupportedBehavior() { + RexNode rex = concat(List.of()).accept(toRex, Context.newContext()); + Expression.ScalarFunctionInvocation back = + (Expression.ScalarFunctionInvocation) rex.accept(fromRex); + assertEquals(List.of(nullHandling("ACCEPT_NULLS")), back.options()); + } + + @Test + void rejectsAnEmptyPreferenceList() { + assertThrows( + UnsupportedOperationException.class, + () -> concat(List.of(nullHandling())).accept(toRex, Context.newContext())); + } + + @Test + void rejectsAnUnknownOption() { + FunctionOption option = FunctionOption.builder().name("unknown").addValues("value").build(); + UnsupportedOperationException failure = + assertThrows( + UnsupportedOperationException.class, + () -> concat(List.of(option)).accept(toRex, Context.newContext())); + assertTrue(failure.getMessage().contains("unknown")); + } + + @Test + void rejectsUndeclaredValuesEvenWithASupportedFallback() { + for (List values : + List.of(List.of("BOGUS", "ACCEPT_NULLS"), List.of("ACCEPT_NULLS", "BOGUS"))) { + UnsupportedOperationException failure = + assertThrows( + UnsupportedOperationException.class, + () -> + concat( + List.of( + FunctionOption.builder() + .name("null_handling") + .values(values) + .build())) + .accept(toRex, Context.newContext())); + assertTrue(failure.getMessage().contains("does not declare value BOGUS")); + } + } + + @Test + void leavesAnotherExtensionsConcatOptionsToItsMapper() { + Expression.ScalarFunctionInvocation original = concat(List.of(nullHandling("IGNORE_NULLS"))); + SimpleExtension.ScalarFunctionVariant custom = + ImmutableSimpleExtension.ScalarFunctionVariant.builder() + .from(original.declaration()) + .urn("extension:org.example:strings") + .build(); + Expression.ScalarFunctionInvocation call = + Expression.ScalarFunctionInvocation.builder().from(original).declaration(custom).build(); + call.accept(toRex, Context.newContext()); + } +} diff --git a/isthmus/src/test/java/io/substrait/isthmus/StringFunctionOptionsTest.java b/isthmus/src/test/java/io/substrait/isthmus/StringFunctionOptionsTest.java new file mode 100644 index 000000000..625723964 --- /dev/null +++ b/isthmus/src/test/java/io/substrait/isthmus/StringFunctionOptionsTest.java @@ -0,0 +1,333 @@ +package io.substrait.isthmus; + +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 io.substrait.expression.Expression; +import io.substrait.expression.ExpressionCreator; +import io.substrait.expression.FunctionArg; +import io.substrait.expression.FunctionOption; +import io.substrait.extension.DefaultExtensionCatalog; +import io.substrait.extension.SimpleExtension.ScalarFunctionVariant; +import io.substrait.isthmus.SubstraitRelNodeConverter.Context; +import io.substrait.isthmus.expression.CallConverters; +import io.substrait.isthmus.expression.ExpressionRexConverter; +import io.substrait.isthmus.expression.FunctionMappings; +import io.substrait.isthmus.expression.RexExpressionConverter; +import io.substrait.isthmus.expression.ScalarFunctionConverter; +import io.substrait.isthmus.expression.WindowFunctionConverter; +import io.substrait.type.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.apache.calcite.DataContexts; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexExecutorImpl; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.fun.SqlLibraryOperators; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class StringFunctionOptionsTest extends PlanTestBase { + private final ScalarFunctionConverter scalar = + new ScalarFunctionConverter(extensions.scalarFunctions(), typeFactory); + private final WindowFunctionConverter window = + new WindowFunctionConverter(extensions.windowFunctions(), typeFactory); + private final ExpressionRexConverter toRex = + new ExpressionRexConverter(typeFactory, scalar, window, TypeConverter.DEFAULT); + private final RexExpressionConverter fromRex = + new RexExpressionConverter( + null, + Stream.concat(CallConverters.defaults(TypeConverter.DEFAULT).stream(), Stream.of(scalar)) + .toList(), + window, + TypeConverter.DEFAULT); + + private static final class Case { + private final String name; + private final String query; + private final String option; + private final String supported; + private final String unsupported; + private final String expected; + + private Case( + String name, + String query, + String option, + String supported, + String unsupported, + String expected) { + this.name = name; + this.query = query; + this.option = option; + this.supported = supported; + this.unsupported = unsupported; + this.expected = expected; + } + } + + static Stream cases() { + return Stream.of( + new Case( + "like", "a LIKE b", "case_sensitivity", "CASE_SENSITIVE", "CASE_INSENSITIVE", "false"), + new Case( + "replace", + "replace(a,b,a)", + "case_sensitivity", + "CASE_SENSITIVE", + "CASE_INSENSITIVE", + "Abc"), + new Case( + "starts_with", + "starts_with(a,b)", + "case_sensitivity", + "CASE_SENSITIVE", + "CASE_INSENSITIVE_ASCII", + "false"), + new Case( + "ends_with", + "ends_with(a,b)", + "case_sensitivity", + "CASE_SENSITIVE", + "CASE_INSENSITIVE", + "false"), + new Case( + "strpos", + "position(b IN a)", + "case_sensitivity", + "CASE_SENSITIVE", + "CASE_INSENSITIVE", + "0"), + new Case( + "substring", + "substring(a,-1,4)", + "negative_start", + "LEFT_OF_BEGINNING", + "WRAP_FROM_END", + "ab"), + new Case("lower", "lower(a)", "char_set", "UTF8", "ASCII_ONLY", "école"), + new Case("upper", "upper(a)", "char_set", "UTF8", "ASCII_ONLY", "ÉCOLE")); + } + + private FunctionOption option(Case c, String... values) { + return FunctionOption.builder().name(c.option).addValues(values).build(); + } + + private Expression.ScalarFunctionInvocation call(Case c, List options) { + List args; + Type output; + String key; + switch (c.name) { + case "lower", "upper", "initcap" -> { + args = + List.of( + ExpressionCreator.string( + false, + c.name.equals("initcap") + ? "éCOLE" + : c.name.equals("upper") ? "école" : "ÉCOLE")); + output = R.STRING; + key = c.name + ":str"; + } + case "substring" -> { + args = + List.of( + ExpressionCreator.string(false, "abcdef"), + ExpressionCreator.i32(false, -1), + ExpressionCreator.i32(false, 4)); + output = R.STRING; + key = "substring:str_i32_i32"; + } + default -> { + args = + new ArrayList<>( + List.of( + ExpressionCreator.string(false, c.name.equals("ends_with") ? "AbC" : "Abc"), + ExpressionCreator.string( + false, + c.name.equals("like") ? "a%" : c.name.equals("ends_with") ? "c" : "a"))); + output = c.name.equals("strpos") ? R.I64 : R.BOOLEAN; + key = c.name + ":str_str"; + if (c.name.equals("replace")) { + args.add(ExpressionCreator.string(false, "x")); + output = R.STRING; + key += "_str"; + } + } + } + return Expression.ScalarFunctionInvocation.builder() + .from(sb.scalarFn(DefaultExtensionCatalog.FUNCTIONS_STRING, key, output, args, List.of())) + .options(options) + .build(); + } + + @ParameterizedTest + @MethodSource("cases") + void exportPinsTheOperatorsBehavior(Case c) throws Exception { + Expression.ScalarFunctionInvocation expression = + assertInstanceOf( + Expression.ScalarFunctionInvocation.class, + firstExpression( + "SELECT " + c.query + " FROM strings", + "CREATE TABLE strings (a VARCHAR, b VARCHAR)")); + assertEquals(List.of(option(c, c.supported)), expression.options()); + } + + @ParameterizedTest + @MethodSource("cases") + void importUsesTheSupportedPreferenceAndPreservesItOnExport(Case c) { + RexNode rex = + call(c, List.of(option(c, c.unsupported, c.supported))).accept(toRex, Context.newContext()); + Expression.ScalarFunctionInvocation back = + assertInstanceOf(Expression.ScalarFunctionInvocation.class, rex.accept(fromRex)); + assertEquals(List.of(option(c, c.supported)), back.options()); + List reduced = new ArrayList<>(); + new RexExecutorImpl(DataContexts.EMPTY).reduce(creator.rex(), List.of(rex), reduced); + RexLiteral literal = assertInstanceOf(RexLiteral.class, reduced.get(0)); + String result = + c.name.equals("strpos") + ? literal.getValueAs(Long.class).toString() + : c.expected.equals("false") + ? literal.getValueAs(Boolean.class).toString() + : literal.getValueAs(String.class); + assertEquals(c.expected, result); + } + + @ParameterizedTest + @MethodSource("cases") + void rejectsAnIncompatibleOption(Case c) { + assertThrows( + UnsupportedOperationException.class, + () -> call(c, List.of(option(c, c.unsupported))).accept(toRex, Context.newContext())); + } + + @Test + void initcapDoesNotPromiseAnUndefinedCharsetPolicy() throws Exception { + Expression.ScalarFunctionInvocation exported = + assertInstanceOf( + Expression.ScalarFunctionInvocation.class, + firstExpression("SELECT initcap(a) FROM strings", "CREATE TABLE strings (a VARCHAR)")); + assertEquals(List.of(), exported.options()); + RexNode imported = exported.accept(toRex, Context.newContext()); + assertEquals( + org.apache.calcite.sql.fun.SqlStdOperatorTable.INITCAP, + assertInstanceOf(RexCall.class, imported).getOperator()); + for (String charset : List.of("ASCII_ONLY", "UTF8")) { + Expression withOption = + Expression.ScalarFunctionInvocation.builder() + .from(exported) + .addOptions(FunctionOption.builder().name("char_set").addValues(charset).build()) + .build(); + assertThrows( + UnsupportedOperationException.class, + () -> withOption.accept(toRex, Context.newContext())); + } + } + + @ParameterizedTest + @MethodSource("cases") + void rejectsUndeclaredPreferenceValuesEvenWithASupportedFallback(Case c) { + for (List values : + List.of(List.of("BOGUS", c.supported), List.of(c.supported, "BOGUS"))) { + assertThrows( + UnsupportedOperationException.class, + () -> + call(c, List.of(FunctionOption.builder().name(c.option).values(values).build())) + .accept(toRex, Context.newContext())); + } + } + + @Test + void validatesOptionsAgainstTheOperatorChosenByACustomConverter() { + ScalarFunctionConverter custom = + new ScalarFunctionConverter(extensions.scalarFunctions(), typeFactory) { + @Override + public Optional getSqlOperatorFromSubstraitFunc( + String key, Type outputType) { + return Optional.of(SqlLibraryOperators.ILIKE); + } + }; + ExpressionRexConverter converter = + new ExpressionRexConverter(typeFactory, custom, window, TypeConverter.DEFAULT); + Case c = cases().filter(sample -> sample.name.equals("like")).findFirst().orElseThrow(); + assertThrows( + UnsupportedOperationException.class, + () -> + call(c, List.of(option(c, "CASE_SENSITIVE"))).accept(converter, Context.newContext())); + } + + @Test + void customOptionPolicyPreservesCaseInsensitiveLikeInBothDirections() { + Case c = cases().filter(sample -> sample.name.equals("like")).findFirst().orElseThrow(); + FunctionOption insensitive = option(c, "CASE_INSENSITIVE"); + ScalarFunctionConverter custom = + new ScalarFunctionConverter( + extensions.scalarFunctions(), + List.of(new FunctionMappings.Sig(SqlLibraryOperators.ILIKE, "like")), + typeFactory, + TypeConverter.DEFAULT) { + @Override + public Optional getSqlOperatorFromSubstraitFunc( + String key, Type outputType) { + if (key.equals("like:str_str")) { + return Optional.of(SqlLibraryOperators.ILIKE); + } + return super.getSqlOperatorFromSubstraitFunc(key, outputType); + } + + @Override + protected SqlOperator resolveOptions( + Expression.ScalarFunctionInvocation expression, SqlOperator operator) { + if (operator == SqlLibraryOperators.ILIKE + && expression.options().stream() + .allMatch( + o -> + o.getName().equalsIgnoreCase("case_sensitivity") + && o.values().stream() + .anyMatch("CASE_INSENSITIVE"::equalsIgnoreCase))) { + return operator; + } + return super.resolveOptions(expression, operator); + } + + @Override + protected List options(RexCall call, ScalarFunctionVariant function) { + if (DefaultExtensionCatalog.FUNCTIONS_STRING.equals(function.urn()) + && function.key().equals("like:str_str") + && call.getOperator() == SqlLibraryOperators.ILIKE) { + return List.of(insensitive); + } + return super.options(call, function); + } + }; + ExpressionRexConverter importer = + new ExpressionRexConverter(typeFactory, custom, window, TypeConverter.DEFAULT); + RexExpressionConverter exporter = + new RexExpressionConverter( + null, + Stream.concat( + CallConverters.defaults(TypeConverter.DEFAULT).stream(), Stream.of(custom)) + .toList(), + window, + TypeConverter.DEFAULT); + RexNode imported = call(c, List.of(insensitive)).accept(importer, Context.newContext()); + assertEquals( + SqlLibraryOperators.ILIKE, assertInstanceOf(RexCall.class, imported).getOperator()); + Expression.ScalarFunctionInvocation exported = + assertInstanceOf(Expression.ScalarFunctionInvocation.class, imported.accept(exporter)); + assertEquals(List.of(insensitive), exported.options()); + List reduced = new ArrayList<>(); + new RexExecutorImpl(DataContexts.EMPTY).reduce(creator.rex(), List.of(imported), reduced); + assertEquals( + true, assertInstanceOf(RexLiteral.class, reduced.get(0)).getValueAs(Boolean.class)); + assertThrows( + UnsupportedOperationException.class, + () -> call(c, List.of(option(c, "CASE_SENSITIVE"))).accept(importer, Context.newContext())); + } +}