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 {

Reply via email to