This is an automated email from the ASF dual-hosted git repository.
mihaibudiu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git
The following commit(s) were added to refs/heads/main by this push:
new d3b1487be7 [CALCITE-7845] UDF/UDTF overload resolution fails when
concrete parameter types coexist with ANY
d3b1487be7 is described below
commit d3b1487be77a377c61a9976a4427afbb3ec7b0ff
Author: Kirill Tkalenko <[email protected]>
AuthorDate: Fri Nov 6 20:56:20 2026 +0300
[CALCITE-7845] UDF/UDTF overload resolution fails when concrete parameter
types coexist with ANY
---
.../main/java/org/apache/calcite/sql/SqlUtil.java | 89 +++++++++++++-
.../java/org/apache/calcite/sql/SqlUtilTest.java | 134 +++++++++++++++++++++
.../org/apache/calcite/test/TableFunctionTest.java | 39 ++++++
.../test/java/org/apache/calcite/test/UdfTest.java | 50 ++++++++
.../main/java/org/apache/calcite/util/Smalls.java | 37 ++++++
5 files changed, 348 insertions(+), 1 deletion(-)
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlUtil.java
b/core/src/main/java/org/apache/calcite/sql/SqlUtil.java
index fc16a169fd..62e2301b14 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlUtil.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlUtil.java
@@ -536,6 +536,11 @@ public static SqlLiteral
concatenateLiterals(List<SqlLiteral> lits) {
* Looks up a (possibly overloaded) routine based on name and argument
* types.
*
+ * <p>Uses the overload resolution rules described in
+ * {@link #lookupSubjectRoutines}. If several routines remain, returns the
+ * first in the order supplied by the operator table; this method does not
+ * report an ambiguity error.
+ *
* @param opTab operator table to search
* @param typeFactory Type factory
* @param funcName name of function being invoked
@@ -588,6 +593,81 @@ private static Iterator<SqlOperator>
filterOperatorRoutinesByKind(
/**
* Looks up all subject routines matching the given name and argument types.
*
+ * <p>For user-defined scalar and table functions with
+ * {@link SqlSyntax#FUNCTION} syntax and fixed parameter metadata supplied by
+ * {@link SqlOperandMetadata}, Calcite resolves overloads using the following
+ * rules:
+ *
+ * <ol>
+ * <li>Collect candidates by function name, category and syntax using the
+ * operator table and {@code nameMatcher}. Keep only candidates whose operand
+ * count range accepts the number of supplied arguments.</li>
+ * <li>Match arguments to formal parameters by position, or by parameter name
+ * when {@code argNames} is supplied. Parameter names are matched
+ * case-insensitively. Reject candidates with an unknown parameter name or an
+ * incompatible argument type. Compatibility is determined by
+ * {@link SqlTypeUtil#canCastFrom(RelDataType, RelDataType, boolean)}, with
the
+ * formal parameter as the target type and {@code coerce} controlling the
+ * permitted conversions.</li>
+ * <li>If {@code coerce} is true, return the compatible candidates without
+ * ranking them by type precedence. If fewer than two candidates remain,
+ * return them immediately as well.</li>
+ * <li>Otherwise, consider the supplied arguments from left to right, in call
+ * order. For each argument, obtain its
+ * {@link RelDataType#getPrecedenceList() type precedence list} and examine
the
+ * corresponding parameter types of the remaining candidates. If any of those
+ * types belong to the list, keep only candidates with a parameter type of
the
+ * highest precedence according to
+ * {@link RelDataTypePrecedenceList#compareTypePrecedence}. Discard parameter
+ * types outside the list in this case. If none belong to the list, that
+ * argument does not eliminate any candidates. Candidates eliminated for an
+ * earlier argument are not reconsidered for a later argument.</li>
+ * <li>After precedence filtering, retain candidates whose
+ * {@link SqlKind#getFunctionKind() function kind} matches the requested
+ * kind.</li>
+ * </ol>
+ *
+ * <p>Type compatibility and type precedence are separate checks. A parameter
+ * of type {@link SqlTypeName#ANY ANY} can accept a concrete argument even
+ * though it is outside that argument's precedence list. With Calcite's
+ * built-in SQL types, a compatible concrete parameter in that list therefore
+ * takes precedence over an {@code ANY} parameter. If no compatible concrete
+ * overload exists, an {@code ANY} overload can still be selected.
Conversely,
+ * an argument of type {@code ANY} prefers an {@code ANY} parameter when one
+ * remains among the candidates. If no remaining parameter belongs to the
+ * argument's precedence list, the candidates remain tied at that position.
+ *
+ * <p>An untyped {@link SqlTypeName#NULL NULL} argument supplies no
precedence
+ * preference between non-{@code NULL} parameter types. Other arguments may
+ * still distinguish the candidates. A typed null, such as
+ * {@code CAST(NULL AS TIMESTAMP)}, uses the precedence list of its declared
+ * type.
+ *
+ * <p>For example, given overloads {@code F(VARCHAR)}, {@code F(TIMESTAMP)}
and
+ * {@code F(ANY)}, lookup with {@code coerce = false} resolves as follows,
+ * independently of candidate order:
+ *
+ * <pre>{@code
+ * F('text') -> F(VARCHAR)
+ * F(TIMESTAMP '2024-01-02 03:04:05') -> F(TIMESTAMP)
+ * F(TRUE) -> F(ANY)
+ * F(CAST(NULL AS TIMESTAMP)) -> F(TIMESTAMP)
+ * }</pre>
+ *
+ * <p>For {@code F(NULL)}, all three overloads remain candidates. Filtering
+ * preserves the order supplied by the operator table, and
+ * {@link #lookupRoutine} returns the first surviving candidate. Thus
candidate
+ * order can affect calls that remain tied, but not calls with a unique best
+ * match.
+ *
+ * <p>Overload resolution is static: SQL validation selects an overload
+ * based on the derived SQL types of the arguments, including nested calls
+ * and other expressions. Runtime argument values and the function's return
+ * type do not participate in overload selection.
+ *
+ * <p>Procedures are an exception to the rules above: they are filtered only
+ * by name and argument count, not by argument type.
+ *
* @param opTab operator table to search
* @param typeFactory Type factory
* @param funcName name of function being invoked
@@ -840,7 +920,8 @@ private static Iterator<SqlOperator>
filterRoutinesByTypePrecedence(
? lookupParamNameCaseInsensitive(paramNames,
argNames.get(argType.i))
: argType.i;
final RelDataType paramType = paramTypes.get(index);
- return precList.compareTypePrecedence(paramType, bestMatch) >= 0;
+ return precList.containsType(paramType)
+ && precList.compareTypePrecedence(paramType, bestMatch) >= 0;
})
.collect(Collectors.toList());
}
@@ -865,6 +946,12 @@ private static Iterator<SqlOperator>
filterRoutinesByTypePrecedence(
final RelDataType paramType = argNames != null
? paramTypes.get(lookupParamNameCaseInsensitive(paramNames,
argNames.get(i)))
: paramTypes.get(i);
+ // A compatible parameter (for example ANY) need not belong to the
+ // argument's precedence list. Prefer a type in that list if one exists;
+ // otherwise leave the candidates unchanged.
+ if (!precList.containsType(paramType)) {
+ continue;
+ }
if (bestMatch == null) {
bestMatch = paramType;
} else {
diff --git a/core/src/test/java/org/apache/calcite/sql/SqlUtilTest.java
b/core/src/test/java/org/apache/calcite/sql/SqlUtilTest.java
new file mode 100644
index 0000000000..d820024ae2
--- /dev/null
+++ b/core/src/test/java/org/apache/calcite/sql/SqlUtilTest.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.sql;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.type.OperandTypes;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlTypeFactoryImpl;
+import org.apache.calcite.sql.type.SqlTypeFamily;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.sql.util.SqlOperatorTables;
+import org.apache.calcite.sql.validate.SqlNameMatchers;
+
+import com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.apache.calcite.rel.type.RelDataTypeSystem.DEFAULT;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+
+import static java.util.Objects.requireNonNull;
+
+/** Tests for {@link SqlUtil}. */
+class SqlUtilTest {
+ private final RelDataTypeFactory typeFactory = new
SqlTypeFactoryImpl(DEFAULT);
+
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7845">[CALCITE-7845]
+ * Fix UDF/UDTF overload resolution with generic parameter types</a>.*/
+ @Test void testConcreteAndAnyOverloads() {
+ final SqlFunction string =
createFunctionWithSignature(SqlTypeName.VARCHAR);
+ final SqlFunction timestamp =
createFunctionWithSignature(SqlTypeName.TIMESTAMP);
+ final SqlFunction any = createFunctionWithSignature(SqlTypeName.ANY);
+ final ImmutableList<SqlFunction> functions = ImmutableList.of(any, string,
timestamp);
+ for (List<SqlFunction> order : ImmutableList.of(functions,
functions.reverse())) {
+ assertThat(lookup(order, ImmutableList.of(SqlTypeName.CHAR), null),
contains(string));
+ assertThat(lookup(order, ImmutableList.of(SqlTypeName.VARCHAR), null),
contains(string));
+ assertThat(lookup(order, ImmutableList.of(SqlTypeName.TIMESTAMP), null),
contains(timestamp));
+ assertThat(lookup(order, ImmutableList.of(SqlTypeName.BOOLEAN), null),
contains(any));
+ assertThat(lookup(order, ImmutableList.of(SqlTypeName.ANY), null),
contains(any));
+ }
+ }
+
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7845">[CALCITE-7845]
+ * Fix UDF/UDTF overload resolution with generic parameter types</a>. */
+ @Test void testNumericPrecedenceWithAny() {
+ final SqlFunction bigint = createFunctionWithSignature(SqlTypeName.BIGINT);
+ final SqlFunction decimal =
createFunctionWithSignature(SqlTypeName.DECIMAL);
+ final SqlFunction any = createFunctionWithSignature(SqlTypeName.ANY);
+ final ImmutableList<SqlFunction> functions = ImmutableList.of(any,
decimal, bigint);
+ for (List<SqlFunction> order : ImmutableList.of(functions,
functions.reverse())) {
+ assertThat(lookup(order, ImmutableList.of(SqlTypeName.INTEGER), null),
contains(bigint));
+ }
+ }
+
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7845">[CALCITE-7845]
+ * Fix UDF/UDTF overload resolution with generic parameter types</a>. */
+ @Test void testNamedArgumentsWithAny() {
+ final SqlFunction timestamp =
+ createFunctionWithSignature(SqlTypeName.VARCHAR,
SqlTypeName.TIMESTAMP);
+ final SqlFunction any = createFunctionWithSignature(SqlTypeName.VARCHAR,
SqlTypeName.ANY);
+ final ImmutableList<SqlFunction> functions = ImmutableList.of(any,
timestamp);
+ for (List<SqlFunction> order : ImmutableList.of(functions,
functions.reverse())) {
+ assertThat(
+ lookup(order, ImmutableList.of(SqlTypeName.TIMESTAMP,
SqlTypeName.CHAR),
+ ImmutableList.of("p1", "p0")), contains(timestamp));
+ assertThat(lookup(order, ImmutableList.of(SqlTypeName.NULL,
SqlTypeName.TIMESTAMP), null),
+ contains(timestamp));
+ }
+ }
+
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7845">[CALCITE-7845]
+ * Fix UDF/UDTF overload resolution with generic parameter types</a>. */
+ @Test void testNullArgumentHasNoTypePrecedence() {
+ final SqlFunction string =
createFunctionWithSignature(SqlTypeName.VARCHAR);
+ final SqlFunction timestamp =
createFunctionWithSignature(SqlTypeName.TIMESTAMP);
+ final SqlFunction any = createFunctionWithSignature(SqlTypeName.ANY);
+ final ImmutableList<SqlFunction> functions = ImmutableList.of(any, string,
timestamp);
+ for (List<SqlFunction> order : ImmutableList.of(functions,
functions.reverse())) {
+ // An untyped NULL does not distinguish otherwise compatible overloads.
+ assertThat(lookup(order, ImmutableList.of(SqlTypeName.NULL), null),
+ contains(order.toArray()));
+ }
+ }
+
+ private static SqlFunction createFunctionWithSignature(SqlTypeName... types)
{
+ final List<SqlTypeName> paramTypes = ImmutableList.copyOf(types);
+ final List<SqlTypeFamily> families = paramTypes.stream()
+ .map(type ->
requireNonNull(type.getFamily())).collect(Collectors.toList());
+ final SqlOperandMetadata metadata =
+ OperandTypes.operandMetadata(families,
+ factory ->
paramTypes.stream().map(factory::createSqlType).collect(Collectors.toList()),
+ i -> "p" + i, i -> false);
+ return new SqlFunction("F", SqlKind.OTHER_FUNCTION,
ReturnTypes.VARCHAR_2000,
+ null, metadata, SqlFunctionCategory.USER_DEFINED_FUNCTION);
+ }
+
+ private List<SqlOperator> lookup(List<SqlFunction> functions,
List<SqlTypeName> types,
+ @Nullable List<String> names) {
+ final List<RelDataType> argTypes =
types.stream().map(typeFactory::createSqlType)
+ .collect(Collectors.toList());
+ return ImmutableList.copyOf(
+ SqlUtil.lookupSubjectRoutines(SqlOperatorTables.of(functions),
typeFactory,
+ new SqlIdentifier("F", SqlParserPos.ZERO), argTypes, names,
+ SqlSyntax.FUNCTION, SqlKind.OTHER_FUNCTION,
SqlFunctionCategory.USER_DEFINED_FUNCTION,
+ SqlNameMatchers.withCaseSensitive(true), false));
+ }
+}
diff --git a/core/src/test/java/org/apache/calcite/test/TableFunctionTest.java
b/core/src/test/java/org/apache/calcite/test/TableFunctionTest.java
index 57a96477b9..4e9c9b7f84 100644
--- a/core/src/test/java/org/apache/calcite/test/TableFunctionTest.java
+++ b/core/src/test/java/org/apache/calcite/test/TableFunctionTest.java
@@ -22,6 +22,7 @@
import org.apache.calcite.linq4j.Enumerable;
import org.apache.calcite.linq4j.Linq4j;
import org.apache.calcite.linq4j.tree.Primitive;
+import org.apache.calcite.linq4j.tree.Types;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeFactory;
import org.apache.calcite.schema.ScannableTable;
@@ -36,6 +37,8 @@
import org.apache.calcite.util.Smalls;
import org.apache.calcite.util.TestUtil;
+import com.google.common.collect.ImmutableList;
+
import org.checkerframework.checker.nullness.qual.Nullable;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@@ -46,6 +49,7 @@
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
+import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
@@ -125,6 +129,41 @@ private CalciteAssert.AssertThat with() {
}
}
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7845">[CALCITE-7845]
+ * Fix UDF/UDTF overload resolution with generic parameter types</a>. */
+ @Test void testTableFunctionOverloadedWithObject() {
+ final ImmutableList<Class<?>> types =
+ ImmutableList.of(Object.class, String.class, Timestamp.class);
+ for (List<Class<?>> order : ImmutableList.of(types, types.reverse())) {
+ final CalciteAssert.AssertThat with =
CalciteAssert.that().with(connection -> {
+ final SchemaPlus schema =
connection.unwrap(CalciteConnection.class).getRootSchema()
+ .add("s", new AbstractSchema());
+ for (Class<?> type : order) {
+ schema.add(
+ "F", TableFunctionImpl.create(
+ Types.lookupMethod(Smalls.GenericOverloadFunction.class,
+ "table", String.class, type)));
+ }
+ return connection;
+ });
+ with.query("select count(*) from table(\"s\".f('x',
'text'))").returnsValue("1");
+ with.query("select count(*) from table(\"s\".f('x', localtimestamp))")
+ .returnsValue("2");
+ with.query("select count(*) from table(\"s\".f('x',
true))").returnsValue("3");
+ with.query("select count(*) from table(\"s\".f('x', cast(null as
timestamp)))")
+ .returnsValue("2");
+ with.query("select count(*) from table(\"s\".f(\n"
+ + "\"value\" => localtimestamp, \"label\" => 'x'))")
+ .returnsValue("2");
+ with.query("select count(*) from table(\"s\".f('x', cast(? as
timestamp)))")
+ .consumesPreparedStatement(
+ p -> p.setTimestamp(1,
+ Timestamp.valueOf("2024-01-02 03:04:05")))
+ .returnsValue("2");
+ }
+ }
+
/**
* Tests correlated subquery with 2 identical params is being processed
correctly.
*/
diff --git a/core/src/test/java/org/apache/calcite/test/UdfTest.java
b/core/src/test/java/org/apache/calcite/test/UdfTest.java
index ec641a6e9a..c2f29717be 100644
--- a/core/src/test/java/org/apache/calcite/test/UdfTest.java
+++ b/core/src/test/java/org/apache/calcite/test/UdfTest.java
@@ -48,6 +48,7 @@
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
+import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
@@ -561,6 +562,55 @@ private CalciteAssert.AssertThat withUdf() {
.returns("P0=0; P1=1; P2=2\n");
}
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7845">[CALCITE-7845]
+ * Fix UDF/UDTF overload resolution with generic parameter types</a>. */
+ @Test void testUdfOverloadedWithObject() {
+ final ImmutableList<Class<?>> types =
+ ImmutableList.of(Object.class, String.class, Timestamp.class);
+ for (List<Class<?>> order : ImmutableList.of(types, types.reverse())) {
+ final CalciteAssert.AssertThat with =
CalciteAssert.that().with(connection -> {
+ final SchemaPlus schema =
connection.unwrap(CalciteConnection.class).getRootSchema()
+ .add("s", new AbstractSchema());
+ for (Class<?> type : order) {
+ schema.add(
+ "F", ScalarFunctionImpl.create(
+ Types.lookupMethod(Smalls.GenericOverloadFunction.class,
+ "eval", String.class, type)));
+ }
+ schema.add(
+ "TS", ScalarFunctionImpl.create(
+ Types.lookupMethod(Smalls.AllTypesFunction.class,
"toTimestampFun", Long.class)));
+ schema.add(
+ "O", ScalarFunctionImpl.create(
+ Types.lookupMethod(Smalls.GenericOverloadFunction.class,
"objectValue")));
+ return connection;
+ });
+ with.query("values \"s\".f('x', 'text')").returnsValue("x:String");
+ with.query("values \"s\".f('x', timestamp '2024-01-02 03:04:05')")
+ .returnsValue("x:Timestamp");
+ with.query("values \"s\".f('x', true)").returnsValue("x:Object");
+ with.query("values \"s\".f('x', 42)").returnsValue("x:Object");
+ with.query("values \"s\".f('x', cast(null as timestamp))")
+ .returnsValue("x:Timestamp");
+ with.query("values \"s\".f('x',
\"s\".ts(0))").returnsValue("x:Timestamp");
+ with.query("values \"s\".f('x', \"s\".o())").returnsValue("x:Object");
+ with.query("select \"s\".f('x', coalesce(v, \"s\".ts(0)))\n"
+ + "from (values (cast(null as timestamp))) as t(v)")
+ .returnsValue("x:Timestamp");
+ with.query("values \"s\".f(\"value\" => timestamp '2024-01-02 03:04:05',
\"label\" => 'x')")
+ .returnsValue("x:Timestamp");
+ with.query("values \"s\".f('x', cast(? as timestamp))")
+ .consumesPreparedStatement(
+ p -> p.setTimestamp(1,
+ Timestamp.valueOf("2024-01-02 03:04:05")))
+ .returnsValue("x:Timestamp");
+ with.query("values \"s\".f('x', cast(? as integer))")
+ .consumesPreparedStatement(p -> p.setInt(1, 42))
+ .returnsValue("x:Object");
+ }
+ }
+
@Test void testUdfOverloadedNullable() {
final CalciteAssert.AssertThat with = withUdf();
// MYSQL_5 conformance support niladic function with parentheses
diff --git a/testkit/src/main/java/org/apache/calcite/util/Smalls.java
b/testkit/src/main/java/org/apache/calcite/util/Smalls.java
index 0184a0f168..7c65fb4d2a 100644
--- a/testkit/src/main/java/org/apache/calcite/util/Smalls.java
+++ b/testkit/src/main/java/org/apache/calcite/util/Smalls.java
@@ -1468,6 +1468,43 @@ public static class SimpleTableMacro implements
TableMacro {
}
}
+ /** Scalar and table functions with concrete and generic parameter types. */
+ public static class GenericOverloadFunction {
+ public static Object objectValue() {
+ return "text";
+ }
+
+ public static String eval(@Parameter(name = "label") String label,
+ @Parameter(name = "value") String value) {
+ return label + ":String";
+ }
+
+ public static String eval(@Parameter(name = "label") String label,
+ @Parameter(name = "value") Timestamp value) {
+ return label + ":Timestamp";
+ }
+
+ public static String eval(@Parameter(name = "label") String label,
+ @Parameter(name = "value") Object value) {
+ return label + ":Object";
+ }
+
+ public static QueryableTable table(@Parameter(name = "label") String label,
+ @Parameter(name = "value") String value) {
+ return generateStrings(1);
+ }
+
+ public static QueryableTable table(@Parameter(name = "label") String label,
+ @Parameter(name = "value") Timestamp value) {
+ return generateStrings(2);
+ }
+
+ public static QueryableTable table(@Parameter(name = "label") String label,
+ @Parameter(name = "value") Object value) {
+ return generateStrings(3);
+ }
+ }
+
/** Table with columns (A, B). */
public static class SimpleTable extends AbstractQueryableTable
implements TranslatableTable {