This is an automated email from the ASF dual-hosted git repository.
Zouxxyy pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new b686cd7d84 [spark] Support SQL-defined UDFs for Spark 4 (#8299)
b686cd7d84 is described below
commit b686cd7d84e9b5783f2c91779e2e72970cb000fe
Author: Zouxxyy <[email protected]>
AuthorDate: Mon Jun 22 10:58:24 2026 +0800
[spark] Support SQL-defined UDFs for Spark 4 (#8299)
---
docs/docs/spark/sql-functions.md | 33 ++++-
.../apache/spark/sql/paimon/shims/Spark4Shim.scala | 12 ++
.../paimon/spark/sql/PaimonSQLFunctionTest.scala | 21 +++
.../paimon/spark/sql/PaimonSQLFunctionTest.scala | 21 +++
.../java/org/apache/paimon/spark/SparkCatalog.java | 18 +--
.../paimon/spark/catalog/SupportV1Function.java | 14 +-
.../org/apache/paimon/spark/SparkTypeUtils.java | 7 +
...Converter.scala => FileFunctionConverter.scala} | 33 ++---
.../functions/FunctionIdentifierConverter.scala | 37 +++++
.../spark/execution/PaimonFunctionExec.scala | 35 ++++-
.../catalog/PaimonV1FunctionRegistry.scala | 90 ++++++------
.../AbstractPaimonSparkSqlExtensionsParser.scala | 2 +
.../parser/extensions/PaimonFunctionLookup.scala | 98 +++++++++++++
.../extensions/RewritePaimonFunctionCommands.scala | 124 ++++++----------
.../apache/spark/sql/paimon/shims/SparkShim.scala | 17 +++
.../spark/sql/PaimonSQLFunctionTestBase.scala | 145 ++++++++++++++++++
.../apache/spark/sql/paimon/shims/Spark3Shim.scala | 14 ++
.../catalog/functions/SQLFunctionConverter.scala | 162 +++++++++++++++++++++
.../RewritePaimonSQLFunctionCommands.scala | 79 ++++++++++
.../apache/spark/sql/paimon/shims/Spark4Shim.scala | 12 ++
20 files changed, 796 insertions(+), 178 deletions(-)
diff --git a/docs/docs/spark/sql-functions.md b/docs/docs/spark/sql-functions.md
index 410f1c99a7..7e94c46a3f 100644
--- a/docs/docs/spark/sql-functions.md
+++ b/docs/docs/spark/sql-functions.md
@@ -96,7 +96,7 @@ SELECT sys.descriptor_to_string(content) FROM t WHERE id =
'1';
## User-defined Function
-Paimon Spark supports two types of user-defined functions: lambda functions
and file-based functions.
+Paimon Spark supports three types of user-defined functions: lambda functions,
file-based functions, and SQL functions.
This feature currently only supports the REST catalog.
@@ -152,3 +152,34 @@ DESCRIBE FUNCTION [EXTENDED] <mydb>.simple_udf;
-- Drop Function
DROP [TEMPORARY] FUNCTION <mydb>.simple_udf;
```
+
+### SQL Function
+
+Define reusable scalar functions with a pure SQL body. The definition is
persisted in the Paimon catalog.
+
+This feature requires Spark 4.0 or higher. Table functions (`RETURNS
TABLE(...)`) are not supported yet.
+
+**Example**
+
+```sql
+-- Create Function (expression body)
+CREATE FUNCTION area(width DOUBLE, height DOUBLE)
+RETURNS DOUBLE
+RETURN width * height;
+
+-- Create Function (query body)
+CREATE FUNCTION dept_total(d INT) RETURNS INT
+RETURN SELECT SUM(salary) FROM emp WHERE dept_id = d;
+
+-- Parameter with DEFAULT value
+CREATE FUNCTION addd(x INT, y INT DEFAULT 10) RETURNS INT RETURN x + y;
+
+-- Create or Replace / If Not Exists
+CREATE OR REPLACE FUNCTION inc(x INT) RETURNS INT RETURN x + 100;
+CREATE FUNCTION IF NOT EXISTS inc(x INT) RETURNS INT RETURN x + 1;
+
+-- Describe / Show / Drop Function
+DESCRIBE FUNCTION [EXTENDED] area;
+SHOW USER FUNCTIONS;
+DROP FUNCTION [IF EXISTS] area;
+```
diff --git
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
index 0f7ea24e66..9e97adc96e 100644
---
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
+++
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
@@ -341,6 +341,18 @@ class Spark4Shim extends SparkShim {
dataType.isInstanceOf[VariantType]
override def SparkVariantType(): org.apache.spark.sql.types.DataType =
DataTypes.VariantType
+
+ // SQL UDFs (CREATE FUNCTION ... RETURN ...).
+ override def rewritePaimonSQLFunctionCommands(spark: SparkSession):
Rule[LogicalPlan] =
+
org.apache.spark.sql.catalyst.parser.extensions.RewritePaimonSQLFunctionCommands(spark)
+
+ override def resolvePaimonSQLFunction(
+ funcIdent: org.apache.spark.sql.catalyst.FunctionIdentifier,
+ function: org.apache.paimon.function.Function,
+ arguments: Seq[Expression],
+ parser: org.apache.spark.sql.catalyst.parser.ParserInterface):
Expression =
+ org.apache.paimon.spark.catalog.functions.SQLFunctionConverter
+ .toSQLFunctionExpression(funcIdent, function, arguments, parser)
}
object Spark4Shim {
diff --git
a/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTest.scala
b/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTest.scala
new file mode 100644
index 0000000000..967795d439
--- /dev/null
+++
b/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTest.scala
@@ -0,0 +1,21 @@
+/*
+ * 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.paimon.spark.sql
+
+class PaimonSQLFunctionTest extends PaimonSQLFunctionTestBase {}
diff --git
a/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTest.scala
b/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTest.scala
new file mode 100644
index 0000000000..967795d439
--- /dev/null
+++
b/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTest.scala
@@ -0,0 +1,21 @@
+/*
+ * 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.paimon.spark.sql
+
+class PaimonSQLFunctionTest extends PaimonSQLFunctionTestBase {}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkCatalog.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkCatalog.java
index 165be98980..7868afdd1f 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkCatalog.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkCatalog.java
@@ -34,8 +34,8 @@ import org.apache.paimon.spark.catalog.FormatTableCatalog;
import org.apache.paimon.spark.catalog.SparkBaseCatalog;
import org.apache.paimon.spark.catalog.SupportV1Function;
import org.apache.paimon.spark.catalog.SupportView;
+import org.apache.paimon.spark.catalog.functions.FunctionIdentifierConverter;
import org.apache.paimon.spark.catalog.functions.PaimonFunctions;
-import org.apache.paimon.spark.catalog.functions.V1FunctionConverter;
import org.apache.paimon.spark.utils.CatalogUtils;
import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.iceberg.IcebergTable;
@@ -56,7 +56,6 @@ import
org.apache.spark.sql.catalyst.analysis.NoSuchFunctionException;
import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException;
import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException;
-import org.apache.spark.sql.catalyst.catalog.CatalogFunction;
import org.apache.spark.sql.catalyst.catalog.PaimonV1FunctionRegistry;
import org.apache.spark.sql.catalyst.expressions.Expression;
import
org.apache.spark.sql.catalyst.parser.extensions.UnResolvedPaimonV1Function;
@@ -749,18 +748,13 @@ public class SparkCatalog extends SparkBaseCatalog
@Override
public Function getFunction(FunctionIdentifier funcIdent) throws Exception
{
- return
paimonCatalog().getFunction(V1FunctionConverter.fromFunctionIdentifier(funcIdent));
+ return paimonCatalog()
+
.getFunction(FunctionIdentifierConverter.toPaimonIdentifier(funcIdent));
}
@Override
- public void createV1Function(CatalogFunction v1Function, boolean
ignoreIfExists)
- throws Exception {
- Function paimonFunction =
V1FunctionConverter.fromV1Function(v1Function);
- paimonCatalog()
- .createFunction(
-
V1FunctionConverter.fromFunctionIdentifier(v1Function.identifier()),
- paimonFunction,
- ignoreIfExists);
+ public void createV1Function(Function function, boolean ignoreIfExists)
throws Exception {
+ paimonCatalog().createFunction(function.identifier(), function,
ignoreIfExists);
}
@Override
@@ -778,7 +772,7 @@ public class SparkCatalog extends SparkBaseCatalog
public void dropV1Function(FunctionIdentifier funcIdent, boolean ifExists)
throws Exception {
v1FunctionRegistry().unregisterFunction(funcIdent);
paimonCatalog()
-
.dropFunction(V1FunctionConverter.fromFunctionIdentifier(funcIdent), ifExists);
+
.dropFunction(FunctionIdentifierConverter.toPaimonIdentifier(funcIdent),
ifExists);
}
// ======================= Tools methods ===============================
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/catalog/SupportV1Function.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/catalog/SupportV1Function.java
index 4c070c5949..e1bce316b4 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/catalog/SupportV1Function.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/catalog/SupportV1Function.java
@@ -21,11 +21,13 @@ package org.apache.paimon.spark.catalog;
import org.apache.paimon.function.Function;
import org.apache.spark.sql.catalyst.FunctionIdentifier;
-import org.apache.spark.sql.catalyst.catalog.CatalogFunction;
import org.apache.spark.sql.catalyst.expressions.Expression;
import
org.apache.spark.sql.catalyst.parser.extensions.UnResolvedPaimonV1Function;
-/** Catalog supports v1 function. */
+/**
+ * Catalog supports v1 function, i.e. functions surfaced through Spark's v1
(session / persistent)
+ * function mechanism: file (Hive) functions and SQL functions.
+ */
public interface SupportV1Function extends WithPaimonCatalog {
boolean v1FunctionEnabled();
@@ -33,14 +35,12 @@ public interface SupportV1Function extends
WithPaimonCatalog {
/** Look up the function in the catalog. */
Function getFunction(FunctionIdentifier funcIdent) throws Exception;
- void createV1Function(CatalogFunction v1Function, boolean ignoreIfExists)
throws Exception;
+ /** Create a v1 function (file or SQL) from an already-built Paimon {@link
Function}. */
+ void createV1Function(Function function, boolean ignoreIfExists) throws
Exception;
boolean v1FunctionRegistered(FunctionIdentifier funcIdent);
- /**
- * Register the function and resolves it to an Expression if not
registered, otherwise returns
- * the registered Expression.
- */
+ /** Resolve a v1 function reference (file or SQL) to an Expression. */
Expression registerAndResolveV1Function(UnResolvedPaimonV1Function
unresolvedV1Function)
throws Exception;
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkTypeUtils.java
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkTypeUtils.java
index 80a27c35ac..cd7de8f535 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkTypeUtils.java
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkTypeUtils.java
@@ -70,6 +70,9 @@ public class SparkTypeUtils {
*/
public static final String CURRENT_DEFAULT_COLUMN_METADATA_KEY =
"CURRENT_DEFAULT";
+ // Spark SQL functions store DEFAULT in a different metadata key than
table columns.
+ public static final String SQL_FUNCTION_DEFAULT_METADATA_KEY = "default";
+
public static RowType toPartitionType(Table table) {
int[] projections =
table.rowType().getFieldIndices(table.partitionKeys());
List<DataField> partitionTypes = new ArrayList<>();
@@ -294,6 +297,7 @@ public class SparkTypeUtils {
MetadataBuilder metadataBuilder = new MetadataBuilder();
if (field.defaultValue() != null) {
metadataBuilder.putString(CURRENT_DEFAULT_COLUMN_METADATA_KEY,
field.defaultValue());
+
metadataBuilder.putString(SQL_FUNCTION_DEFAULT_METADATA_KEY,
field.defaultValue());
}
StructField structField =
DataTypes.createStructField(
@@ -380,6 +384,9 @@ public class SparkTypeUtils {
if
(field.metadata().contains(CURRENT_DEFAULT_COLUMN_METADATA_KEY)) {
defaultValue =
field.metadata().getString(CURRENT_DEFAULT_COLUMN_METADATA_KEY);
+ } else if
(field.metadata().contains(SQL_FUNCTION_DEFAULT_METADATA_KEY)) {
+ defaultValue =
+
field.metadata().getString(SQL_FUNCTION_DEFAULT_METADATA_KEY);
}
newFields.add(
new DataField(
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/V1FunctionConverter.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/FileFunctionConverter.scala
similarity index 71%
rename from
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/V1FunctionConverter.scala
rename to
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/FileFunctionConverter.scala
index be4daa86b9..705ec09137 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/V1FunctionConverter.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/FileFunctionConverter.scala
@@ -18,7 +18,6 @@
package org.apache.paimon.spark.catalog.functions
-import org.apache.paimon.catalog.Identifier
import org.apache.paimon.function.{Function, FunctionDefinition, FunctionImpl}
import org.apache.paimon.spark.SparkCatalog.FUNCTION_DEFINITION_NAME
@@ -27,38 +26,32 @@ import
org.apache.spark.sql.catalyst.catalog.{CatalogFunction, FunctionResource,
import scala.collection.JavaConverters._
-object V1FunctionConverter {
-
- /** Converts spark [[FunctionIdentifier]] to paimon [[Identifier]]. */
- def fromFunctionIdentifier(ident: FunctionIdentifier): Identifier = {
- new Identifier(ident.database.get, ident.funcName)
- }
-
- /** Converts paimon [[Identifier]] to spark [[FunctionIdentifier]]. */
- def toFunctionIdentifier(ident: Identifier): FunctionIdentifier = {
- new FunctionIdentifier(ident.getObjectName, Some(ident.getDatabaseName))
- }
+/**
+ * Converts between Spark's [[CatalogFunction]] (a file/Hive className UDF)
and a Paimon
+ * [[Function]] carrying a [[FunctionDefinition.FileFunctionDefinition]].
+ */
+object FileFunctionConverter {
- /** Converts spark [[CatalogFunction]] to paimon [[Function]]. */
- def fromV1Function(v1Function: CatalogFunction): Function = {
- val functionIdentifier = v1Function.identifier
- val identifier = fromFunctionIdentifier(functionIdentifier)
- val fileResources = v1Function.resources
+ /** Converts a Spark [[CatalogFunction]] to a Paimon [[Function]]. */
+ def fromCatalogFunction(catalogFunction: CatalogFunction): Function = {
+ val functionIdentifier = catalogFunction.identifier
+ val identifier =
FunctionIdentifierConverter.toPaimonIdentifier(functionIdentifier)
+ val fileResources = catalogFunction.resources
.map(r => new
FunctionDefinition.FunctionFileResource(r.resourceType.resourceType, r.uri))
.toList
val functionDefinition: FunctionDefinition = FunctionDefinition.file(
fileResources.asJava,
"JAVA", // Apache Spark only supports JAR persistent function now.
- v1Function.className,
+ catalogFunction.className,
functionIdentifier.funcName)
val definitions = Map(FUNCTION_DEFINITION_NAME ->
functionDefinition).asJava
new FunctionImpl(identifier, definitions)
}
- /** Converts paimon [[Function]] to spark [[CatalogFunction]]. */
- def toV1Function(paimonFunction: Function): CatalogFunction = {
+ /** Converts a Paimon [[Function]] to a Spark [[CatalogFunction]]. */
+ def toCatalogFunction(paimonFunction: Function): CatalogFunction = {
paimonFunction.definition(FUNCTION_DEFINITION_NAME) match {
case functionDefinition: FunctionDefinition.FileFunctionDefinition =>
val fileResources = functionDefinition
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/FunctionIdentifierConverter.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/FunctionIdentifierConverter.scala
new file mode 100644
index 0000000000..137ca02859
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalog/functions/FunctionIdentifierConverter.scala
@@ -0,0 +1,37 @@
+/*
+ * 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.paimon.spark.catalog.functions
+
+import org.apache.paimon.catalog.Identifier
+
+import org.apache.spark.sql.catalyst.FunctionIdentifier
+
+/**
+ * Converts a Spark [[FunctionIdentifier]] to a Paimon [[Identifier]] (shared
by file & SQL
+ * functions).
+ */
+object FunctionIdentifierConverter {
+
+ def toPaimonIdentifier(funcIdent: FunctionIdentifier): Identifier = {
+ require(
+ funcIdent.database.isDefined,
+ s"Function identifier $funcIdent must have a database/namespace.")
+ new Identifier(funcIdent.database.get, funcIdent.funcName)
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonFunctionExec.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonFunctionExec.scala
index fb0a2ec014..3ca46a43de 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonFunctionExec.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonFunctionExec.scala
@@ -18,37 +18,38 @@
package org.apache.paimon.spark.execution
-import org.apache.paimon.function.FunctionDefinition
+import org.apache.paimon.function.{Function => PaimonFunction,
FunctionDefinition}
import org.apache.paimon.spark.SparkCatalog.FUNCTION_DEFINITION_NAME
import org.apache.paimon.spark.catalog.SupportV1Function
import org.apache.paimon.spark.leafnode.PaimonLeafRunnableCommand
import org.apache.spark.sql.{Row, SparkSession}
import org.apache.spark.sql.catalyst.FunctionIdentifier
-import org.apache.spark.sql.catalyst.catalog.CatalogFunction
import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference}
import org.apache.spark.sql.types.StringType
import scala.collection.JavaConverters._
import scala.collection.mutable.ArrayBuffer
+/** Create a Paimon v1 function (file or SQL) from an already-built Paimon
[[PaimonFunction]]. */
case class CreatePaimonV1FunctionCommand(
catalog: SupportV1Function,
- v1Function: CatalogFunction,
+ funcIdent: FunctionIdentifier,
+ function: PaimonFunction,
ignoreIfExists: Boolean,
replace: Boolean)
extends PaimonLeafRunnableCommand {
override def run(sparkSession: SparkSession): Seq[Row] = {
- // Note: for replace just drop then create ,this operation is non-atomic.
+ // replace = drop then create (non-atomic).
if (replace) {
- catalog.dropV1Function(v1Function.identifier, true)
+ catalog.dropV1Function(funcIdent, true)
}
- catalog.createV1Function(v1Function, ignoreIfExists)
+ catalog.createV1Function(function, ignoreIfExists)
Nil
}
override def simpleString(maxFields: Int): String = {
- s"CreatePaimonV1FunctionCommand: ${v1Function.identifier}"
+ s"CreatePaimonV1FunctionCommand: $funcIdent"
}
}
@@ -86,6 +87,26 @@ case class DescribePaimonV1FunctionCommand(
rows += Row(
s"File Resources:
${functionDefinition.fileResources().asScala.map(_.uri()).mkString(", ")}")
}
+ case sqlFunctionDefinition: FunctionDefinition.SQLFunctionDefinition =>
+ rows += Row(s"Function: ${function.fullName()}")
+ rows += Row("Type: SCALAR")
+ val inputParams = function.inputParams()
+ if (inputParams.isPresent && !inputParams.get().isEmpty) {
+ val params = inputParams
+ .get()
+ .asScala
+ .map(field => s"${field.name()} ${field.`type`().asSQLString()}")
+ .mkString(", ")
+ rows += Row(s"Input: $params")
+ }
+ val returnParams = function.returnParams()
+ if (returnParams.isPresent && !returnParams.get().isEmpty) {
+ rows += Row(s"Returns:
${returnParams.get().get(0).`type`().asSQLString()}")
+ }
+ if (isExtended) {
+ Option(function.comment()).foreach(c => rows += Row(s"Comment: $c"))
+ rows += Row(s"Body: ${sqlFunctionDefinition.definition()}")
+ }
case other =>
throw new UnsupportedOperationException(s"Unsupported function
definition $other")
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/PaimonV1FunctionRegistry.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/PaimonV1FunctionRegistry.scala
index b5a70c329e..ed69abd8bb 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/PaimonV1FunctionRegistry.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/PaimonV1FunctionRegistry.scala
@@ -19,7 +19,7 @@
package org.apache.spark.sql.catalyst.catalog
import org.apache.paimon.function.{Function => PaimonFunction}
-import org.apache.paimon.spark.catalog.functions.V1FunctionConverter
+import org.apache.paimon.spark.catalog.functions.FileFunctionConverter
import org.apache.spark.sql.{PaimonUtils, SparkSession}
import org.apache.spark.sql.catalyst.{FunctionIdentifier, SQLConfHelper}
@@ -27,7 +27,7 @@ import
org.apache.spark.sql.catalyst.analysis.{FunctionAlreadyExistsException, F
import org.apache.spark.sql.catalyst.analysis.FunctionRegistry.FunctionBuilder
import org.apache.spark.sql.catalyst.expressions.{AggregateWindowFunction,
Expression, ExpressionInfo, FrameLessOffsetWindowFunction, Lag, Lead, NthValue,
WindowExpression}
import org.apache.spark.sql.catalyst.expressions.aggregate._
-import
org.apache.spark.sql.catalyst.parser.extensions.UnResolvedPaimonV1Function
+import org.apache.spark.sql.catalyst.parser.extensions.{PaimonFunctionLookup,
UnResolvedPaimonV1Function}
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.hive.HiveUDFExpressionBuilder
import org.apache.spark.sql.paimon.shims.SparkShimLoader
@@ -37,26 +37,42 @@ import java.util.Locale
case class PaimonV1FunctionRegistry(session: SparkSession) extends
SQLConfHelper {
- // ================== Start Public API ===================
-
- /**
- * Register the function and resolves it to an Expression if not registered,
otherwise returns the
- * registered Expression.
- */
+ /** Resolve a v1 function (SQL or file) to an Expression. */
def registerAndResolveFunction(u: UnResolvedPaimonV1Function): Expression = {
- val resolvedFun = resolvePersistentFunctionInternal(
- u.funcIdent,
- u.func,
- u.arguments,
- functionRegistry,
- makeFunctionBuilder)
- validateFunction(resolvedFun, u.arguments.length, u)
+ val qualifiedIdent = qualifyIdentifier(u.funcIdent)
+ val sqlFunc = u.func
+ .filter(PaimonFunctionLookup.isPaimonSQLFunction)
+ .orElse(Option(sqlFunctionCache.get(qualifiedIdent)))
+ if (sqlFunc.isDefined) {
+ // SQL UDFs resolve into a Spark `SQLFunctionExpression` (Spark 4.0+
only, via the shim), which
+ // Spark's own `ResolveSQLFunctions` rule then inlines.
+ if (u.isDistinct || u.filter.isDefined || u.ignoreNulls) {
+ throw new UnsupportedOperationException(
+ s"SQL function ${u.funcIdent} does not support DISTINCT, FILTER or
IGNORE NULLS.")
+ }
+ val resolvedFun = SparkShimLoader.shim.resolvePaimonSQLFunction(
+ u.funcIdent,
+ sqlFunc.get,
+ u.arguments,
+ session.sessionState.sqlParser)
+ sqlFunctionCache.putIfAbsent(qualifiedIdent, sqlFunc.get)
+ resolvedFun
+ } else {
+ // File (Hive) function: register into the function registry and resolve
via the Hive builder.
+ val resolvedFun = resolvePersistentFunctionInternal(
+ u.funcIdent,
+ u.func,
+ u.arguments,
+ functionRegistry,
+ makeFunctionBuilder)
+ validateFunction(resolvedFun, u.arguments.length, u)
+ }
}
- /** Check if the function is registered. */
+ /** Check if the function is registered (file function in Hive registry or
SQL function in cache). */
def isRegistered(funcIdent: FunctionIdentifier): Boolean = {
val qualifiedIdent = qualifyIdentifier(funcIdent)
- functionRegistry.functionExists(qualifiedIdent)
+ functionRegistry.functionExists(qualifiedIdent) ||
sqlFunctionCache.containsKey(qualifiedIdent)
}
/** Unregister the function. */
@@ -65,17 +81,18 @@ case class PaimonV1FunctionRegistry(session: SparkSession)
extends SQLConfHelper
if (functionRegistry.functionExists(qualifiedIdent)) {
functionRegistry.dropFunction(qualifiedIdent)
}
+ sqlFunctionCache.remove(qualifiedIdent)
}
- // ================== End Public API ===================
-
- // Most copy from spark
private val functionResourceLoader: FunctionResourceLoader =
SparkShimLoader.shim.classicApi.sessionResourceLoader(session)
private val functionRegistry: FunctionRegistry = new SimpleFunctionRegistry
private val functionExpressionBuilder: FunctionExpressionBuilder =
HiveUDFExpressionBuilder
- /** Look up a persistent scalar function by name and resolves it to an
Expression. */
+ // SQL functions bypass the Hive registry; cache them here to avoid repeated
catalog IO.
+ private val sqlFunctionCache =
+ new java.util.concurrent.ConcurrentHashMap[FunctionIdentifier,
PaimonFunction]()
+
private def resolvePersistentFunctionInternal[T](
funcIdent: FunctionIdentifier,
func: Option[PaimonFunction],
@@ -83,44 +100,27 @@ case class PaimonV1FunctionRegistry(session: SparkSession)
extends SQLConfHelper
registry: FunctionRegistryBase[T],
createFunctionBuilder: CatalogFunction =>
FunctionRegistryBase[T]#FunctionBuilder): T = {
- val name = funcIdent
- // `synchronized` is used to prevent multiple threads from concurrently
resolving the
- // same function that has not yet been loaded into the function registry.
This is needed
- // because calling `registerFunction` twice with `overrideIfExists =
false` can lead to
- // a FunctionAlreadyExistsException.
+ // Synchronized: registerFunction with overrideIfExists=false throws on
concurrent duplicate loads.
synchronized {
- val qualifiedIdent = qualifyIdentifier(name)
+ val qualifiedIdent = qualifyIdentifier(funcIdent)
if (registry.functionExists(qualifiedIdent)) {
- // This function has been already loaded into the function registry.
registry.lookupFunction(qualifiedIdent, arguments)
} else {
- // The function has not been loaded to the function registry, which
means
- // that the function is a persistent function (if it actually has been
registered
- // in the metastore). We need to first put the function in the
function registry.
require(func.isDefined, "Function must be defined")
- val catalogFunction = V1FunctionConverter.toV1Function(func.get)
+ val catalogFunction = FileFunctionConverter.toCatalogFunction(func.get)
loadFunctionResources(catalogFunction.resources)
- // Please note that qualifiedName is provided by the user. However,
- // catalogFunction.identifier.unquotedString is returned by the
underlying
- // catalog. So, it is possible that qualifiedName is not exactly the
same as
- // catalogFunction.identifier.unquotedString (difference is on
case-sensitivity).
- // At here, we preserve the input from the user.
+ // Preserve user-provided identifier (case-sensitivity may differ from
catalog).
val funcMetadata = catalogFunction.copy(identifier = qualifiedIdent)
registerFunction(
funcMetadata,
overrideIfExists = false,
registry = registry,
functionBuilder = createFunctionBuilder(funcMetadata))
- // Now, we need to create the Expression.
registry.lookupFunction(qualifiedIdent, arguments)
}
}
}
- /**
- * Loads resources such as JARs and Files for a function. Every resource is
represented by a tuple
- * (resource type, resource uri).
- */
private def loadFunctionResources(resources: Seq[FunctionResource]): Unit = {
resources.foreach(functionResourceLoader.loadResource)
}
@@ -153,7 +153,6 @@ case class PaimonV1FunctionRegistry(session: SparkSession)
extends SQLConfHelper
"hive")
}
- /** Constructs a [[FunctionBuilder]] based on the provided function
metadata. */
private def makeFunctionBuilder(func: CatalogFunction): FunctionBuilder = {
val className = func.className
if (!PaimonUtils.classIsLoadable(className)) {
@@ -164,15 +163,10 @@ case class PaimonV1FunctionRegistry(session:
SparkSession) extends SQLConfHelper
(input) => functionExpressionBuilder.makeExpression(name, clazz, input)
}
- /**
- * Qualifies the function identifier with the current database if not
specified, and normalize all
- * the names.
- */
private def qualifyIdentifier(ident: FunctionIdentifier): FunctionIdentifier
= {
FunctionIdentifier(funcName = format(ident.funcName), database =
ident.database)
}
- /** Formats object names, taking into account case sensitivity. */
protected def format(name: String): String = {
if (conf.caseSensitiveAnalysis) name else name.toLowerCase(Locale.ROOT)
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/AbstractPaimonSparkSqlExtensionsParser.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/AbstractPaimonSparkSqlExtensionsParser.scala
index 4fd533f401..7108f0715e 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/AbstractPaimonSparkSqlExtensionsParser.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/AbstractPaimonSparkSqlExtensionsParser.scala
@@ -33,6 +33,7 @@ import
org.apache.spark.sql.catalyst.parser.extensions.PaimonSqlExtensionsParser
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.internal.VariableSubstitution
+import org.apache.spark.sql.paimon.shims.SparkShimLoader
import org.apache.spark.sql.types.{DataType, StructType}
import java.util.Locale
@@ -112,6 +113,7 @@ abstract class AbstractPaimonSparkSqlExtensionsParser(val
delegate: ParserInterf
Seq(
RewritePaimonViewCommands(sparkSession),
RewritePaimonFunctionCommands(sparkSession),
+ SparkShimLoader.shim.rewritePaimonSQLFunctionCommands(sparkSession),
RewriteCreateTableLikeCommand(sparkSession),
RewriteSparkDDLCommands(sparkSession)
)
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/PaimonFunctionLookup.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/PaimonFunctionLookup.scala
new file mode 100644
index 0000000000..4a4b179ff6
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/PaimonFunctionLookup.scala
@@ -0,0 +1,98 @@
+/*
+ * 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.spark.sql.catalyst.parser.extensions
+
+import org.apache.paimon.catalog.Catalog.SYSTEM_DATABASE_NAME
+import org.apache.paimon.function.{Function => PaimonFunction}
+import org.apache.paimon.function.FunctionDefinition
+import org.apache.paimon.spark.SparkCatalog.FUNCTION_DEFINITION_NAME
+import org.apache.paimon.spark.catalog.SupportV1Function
+import org.apache.paimon.spark.catalog.functions.PaimonFunctions
+
+import org.apache.spark.sql.catalyst.FunctionIdentifier
+import org.apache.spark.sql.catalyst.analysis.{UnresolvedFunctionName,
UnresolvedIdentifier}
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.connector.catalog.{CatalogManager, CatalogPlugin,
LookupCatalog}
+
+/** Resolves Paimon function identifiers in parser-stage plans. */
+case class PaimonFunctionLookup(catalogManager: CatalogManager) extends
LookupCatalog {
+
+ object CatalogAndFunctionIdentifier {
+
+ def unapply(unresolved: LogicalPlan): Option[(CatalogPlugin,
FunctionIdentifier, Boolean)] =
+ unresolved match {
+ case ui: UnresolvedIdentifier =>
+ unapply(ui.nameParts)
+ case name: UnresolvedFunctionName =>
+ unapply(name.multipartIdentifier)
+ case _ =>
+ None
+ }
+
+ def unapply(nameParts: Seq[String]): Option[(CatalogPlugin,
FunctionIdentifier, Boolean)] = {
+ nameParts match {
+ // Spark's built-in or tmp functions is without database name or
catalog name.
+ case Seq(funName) if
isSparkBuiltInFunction(FunctionIdentifier(funName)) =>
+ None
+ case Seq(funName) if isSparkTmpFunc(FunctionIdentifier(funName)) =>
+ Some(null, FunctionIdentifier(funName), true)
+ case CatalogAndIdentifier(v1FunctionCatalog: SupportV1Function, ident)
+ if v1FunctionCatalog.v1FunctionEnabled() =>
+ Some(
+ v1FunctionCatalog,
+ FunctionIdentifier(
+ ident.name(),
+ Some(ident.namespace().last),
+ Some(v1FunctionCatalog.name)),
+ false)
+ case _ =>
+ None
+ }
+ }
+ }
+
+ def isPaimonBuiltInFunction(funcIdent: FunctionIdentifier): Boolean = {
+ funcIdent.database match {
+ case Some(db)
+ if db == SYSTEM_DATABASE_NAME &&
PaimonFunctions.names.contains(funcIdent.funcName) =>
+ true
+ case _ => false
+ }
+ }
+
+ def isSparkBuiltInFunction(funcIdent: FunctionIdentifier): Boolean = {
+ catalogManager.v1SessionCatalog.isBuiltinFunction(funcIdent)
+ }
+
+ def isSparkTmpFunc(funcIdent: FunctionIdentifier): Boolean = {
+ catalogManager.v1SessionCatalog.isTemporaryFunction(funcIdent)
+ }
+}
+
+object PaimonFunctionLookup {
+
+ def isPaimonFileFunction(fun: PaimonFunction): Boolean =
+
fun.definition(FUNCTION_DEFINITION_NAME).isInstanceOf[FunctionDefinition.FileFunctionDefinition]
+
+ def isPaimonSQLFunction(fun: PaimonFunction): Boolean =
+
fun.definition(FUNCTION_DEFINITION_NAME).isInstanceOf[FunctionDefinition.SQLFunctionDefinition]
+
+ def isPaimonV1Function(fun: PaimonFunction): Boolean =
+ isPaimonFileFunction(fun) || isPaimonSQLFunction(fun)
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/RewritePaimonFunctionCommands.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/RewritePaimonFunctionCommands.scala
index 0f9722a633..6be680cd60 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/RewritePaimonFunctionCommands.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/RewritePaimonFunctionCommands.scala
@@ -18,41 +18,42 @@
package org.apache.spark.sql.catalyst.parser.extensions
-import org.apache.paimon.catalog.Catalog.SYSTEM_DATABASE_NAME
import org.apache.paimon.function.{Function => PaimonFunction}
-import org.apache.paimon.function.FunctionDefinition
-import org.apache.paimon.spark.SparkCatalog.FUNCTION_DEFINITION_NAME
import org.apache.paimon.spark.catalog.SupportV1Function
-import org.apache.paimon.spark.catalog.functions.PaimonFunctions
+import org.apache.paimon.spark.catalog.functions.FileFunctionConverter
import org.apache.paimon.spark.execution.{CreatePaimonV1FunctionCommand,
DescribePaimonV1FunctionCommand, DropPaimonV1FunctionCommand}
import org.apache.paimon.spark.util.OptionUtils
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.FunctionIdentifier
-import org.apache.spark.sql.catalyst.analysis.{UnresolvedException,
UnresolvedFunction, UnresolvedFunctionName, UnresolvedIdentifier}
+import org.apache.spark.sql.catalyst.analysis.{UnresolvedException,
UnresolvedFunction}
import org.apache.spark.sql.catalyst.catalog.CatalogFunction
import org.apache.spark.sql.catalyst.expressions.{Expression, Unevaluable}
import org.apache.spark.sql.catalyst.plans.logical.{CreateFunction,
DescribeFunction, DropFunction, LogicalPlan, SubqueryAlias, UnresolvedWith}
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.TreePattern.{TreePattern,
UNRESOLVED_FUNCTION}
-import org.apache.spark.sql.connector.catalog.{CatalogManager, CatalogPlugin,
LookupCatalog}
+import org.apache.spark.sql.connector.catalog.CatalogManager
import org.apache.spark.sql.paimon.shims.SparkShimLoader
import org.apache.spark.sql.types.DataType
-case class RewritePaimonFunctionCommands(spark: SparkSession)
- extends Rule[LogicalPlan]
- with LookupCatalog {
+case class RewritePaimonFunctionCommands(spark: SparkSession) extends
Rule[LogicalPlan] {
protected lazy val catalogManager: CatalogManager =
spark.sessionState.catalogManager
+ private lazy val lookup = PaimonFunctionLookup(catalogManager)
+
override def apply(plan: LogicalPlan): LogicalPlan = {
- // Add a global switch to enable/disable v1 function.
if (!OptionUtils.v1FunctionEnabled()) {
return plan
}
val applied = plan.resolveOperatorsUp {
- case CreateFunction(CatalogAndFunctionIdentifier(_, funcIdent, true), _,
_, _, replace) =>
+ case CreateFunction(
+ lookup.CatalogAndFunctionIdentifier(_, funcIdent, true),
+ _,
+ _,
+ _,
+ replace) =>
if (replace) {
throw new UnsupportedOperationException(
s"$funcIdent is a temporary function, you should use `CREATE OR
REPLACE TEMPORARY FUNCTION $funcIdent` or DROP TEMPORARY FUNCTION $funcIdent`.")
@@ -62,41 +63,56 @@ case class RewritePaimonFunctionCommands(spark:
SparkSession)
}
case CreateFunction(
- CatalogAndFunctionIdentifier(v1FunctionCatalog: SupportV1Function,
funcIdent, false),
+ lookup.CatalogAndFunctionIdentifier(
+ v1FunctionCatalog: SupportV1Function,
+ funcIdent,
+ false),
className,
resources,
ifExists,
replace) =>
- if (isPaimonBuildInFunction(funcIdent)) {
+ if (lookup.isPaimonBuiltInFunction(funcIdent)) {
throw new UnsupportedOperationException(s"Can't create built-in
function: $funcIdent")
}
- val v1Function = CatalogFunction(funcIdent, className, resources)
- CreatePaimonV1FunctionCommand(v1FunctionCatalog, v1Function, ifExists,
replace)
+ val paimonFunction =
+ FileFunctionConverter.fromCatalogFunction(
+ CatalogFunction(funcIdent, className, resources))
+ CreatePaimonV1FunctionCommand(
+ v1FunctionCatalog,
+ funcIdent,
+ paimonFunction,
+ ifExists,
+ replace)
case DropFunction(
- CatalogAndFunctionIdentifier(v1FunctionCatalog: SupportV1Function,
funcIdent, false),
+ lookup.CatalogAndFunctionIdentifier(
+ v1FunctionCatalog: SupportV1Function,
+ funcIdent,
+ false),
ifExists) =>
- if (isPaimonBuildInFunction(funcIdent)) {
+ if (lookup.isPaimonBuiltInFunction(funcIdent)) {
throw new UnsupportedOperationException(s"Can't drop built-in
function: $funcIdent")
}
// The function may be v1 function or not, anyway it can be safely
deleted here.
DropPaimonV1FunctionCommand(v1FunctionCatalog, funcIdent, ifExists)
case d @ DescribeFunction(
- CatalogAndFunctionIdentifier(v1FunctionCatalog: SupportV1Function,
funcIdent, false),
+ lookup.CatalogAndFunctionIdentifier(
+ v1FunctionCatalog: SupportV1Function,
+ funcIdent,
+ false),
isExtended)
// For Paimon built-in functions, Spark will resolve them by itself.
- if !isPaimonBuildInFunction(funcIdent) =>
+ if !lookup.isPaimonBuiltInFunction(funcIdent) =>
val function = v1FunctionCatalog.getFunction(funcIdent)
- if (isPaimonV1Function(function)) {
+ if (PaimonFunctionLookup.isPaimonV1Function(function)) {
DescribePaimonV1FunctionCommand(function, isExtended)
} else {
d
}
}
- // Needs to be done here and transform to `UnResolvedPaimonV1Function`, so
that spark's Analyzer can resolve
- // the 'arguments' without throwing an exception, saying that function is
not supported.
+ // Transform function references to UnResolvedPaimonV1Function so Spark
can resolve arguments.
transformPaimonV1Function(applied)
}
@@ -109,16 +125,16 @@ case class RewritePaimonFunctionCommands(spark:
SparkSession)
case l: LogicalPlan =>
l.transformExpressionsWithPruning(_.containsAnyPattern(UNRESOLVED_FUNCTION)) {
case u: UnresolvedFunction =>
- CatalogAndFunctionIdentifier.unapply(u.nameParts) match {
+ lookup.CatalogAndFunctionIdentifier.unapply(u.nameParts) match {
case Some((v1FunctionCatalog: SupportV1Function, funcIdent,
false))
// For Paimon built-in functions, Spark will resolve them by
itself.
- if !isPaimonBuildInFunction(funcIdent) =>
+ if !lookup.isPaimonBuiltInFunction(funcIdent) =>
// If the function is already registered, avoid redundant
lookup in the catalog to reduce overhead.
if (v1FunctionCatalog.v1FunctionRegistered(funcIdent)) {
UnResolvedPaimonV1Function(funcIdent, u, None)
} else {
val function = v1FunctionCatalog.getFunction(funcIdent)
- if (isPaimonV1Function(function)) {
+ if (PaimonFunctionLookup.isPaimonV1Function(function)) {
UnResolvedPaimonV1Function(funcIdent, u, Some(function))
} else {
u
@@ -129,64 +145,6 @@ case class RewritePaimonFunctionCommands(spark:
SparkSession)
}
}
}
-
- private object CatalogAndFunctionIdentifier {
-
- def unapply(unresolved: LogicalPlan): Option[(CatalogPlugin,
FunctionIdentifier, Boolean)] =
- unresolved match {
- case ui: UnresolvedIdentifier =>
- unapply(ui.nameParts)
- case name: UnresolvedFunctionName =>
- unapply(name.multipartIdentifier)
- case _ =>
- None
- }
-
- def unapply(nameParts: Seq[String]): Option[(CatalogPlugin,
FunctionIdentifier, Boolean)] = {
- nameParts match {
- // Spark's built-in or tmp functions is without database name or
catalog name.
- case Seq(funName) if
isSparkBuiltInFunction(FunctionIdentifier(funName)) =>
- None
- case Seq(funName) if isSparkTmpFunc(FunctionIdentifier(funName)) =>
- Some(null, FunctionIdentifier(funName), true)
- case CatalogAndIdentifier(v1FunctionCatalog: SupportV1Function, ident)
- if v1FunctionCatalog.v1FunctionEnabled() =>
- Some(
- v1FunctionCatalog,
- FunctionIdentifier(
- ident.name(),
- Some(ident.namespace().last),
- Some(v1FunctionCatalog.name)),
- false)
- case _ =>
- None
- }
- }
- }
-
- private def isPaimonBuildInFunction(funcIdent: FunctionIdentifier): Boolean
= {
- funcIdent.database match {
- case Some(db)
- if db == SYSTEM_DATABASE_NAME &&
PaimonFunctions.names.contains(funcIdent.funcName) =>
- true
- case _ => false
- }
- }
-
- private def isSparkBuiltInFunction(funcIdent: FunctionIdentifier): Boolean =
{
- catalogManager.v1SessionCatalog.isBuiltinFunction(funcIdent)
- }
-
- private def isSparkTmpFunc(funcIdent: FunctionIdentifier): Boolean = {
- catalogManager.v1SessionCatalog.isTemporaryFunction(funcIdent)
- }
-
- private def isPaimonV1Function(fun: PaimonFunction): Boolean = {
- fun.definition(FUNCTION_DEFINITION_NAME) match {
- case _: FunctionDefinition.FileFunctionDefinition => true
- case _ => false
- }
- }
}
/** An unresolved Paimon V1 function to let Spark resolve the necessary
variables. */
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/paimon/shims/SparkShim.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/paimon/shims/SparkShim.scala
index 7883903b30..5c8094682f 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/paimon/shims/SparkShim.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/spark/sql/paimon/shims/SparkShim.scala
@@ -19,12 +19,14 @@
package org.apache.spark.sql.paimon.shims
import org.apache.paimon.data.variant.Variant
+import org.apache.paimon.function.{Function => PaimonFunction}
import org.apache.paimon.spark.data.{SparkArrayData, SparkInternalRow}
import org.apache.paimon.spark.rowops.PaimonCopyOnWriteScan
import org.apache.paimon.table.{FileStoreTable, FormatTable}
import org.apache.paimon.types.{DataType, RowType}
import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.catalyst.FunctionIdentifier
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression}
import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
@@ -238,4 +240,19 @@ trait SparkShim {
def isSparkVariantType(dataType: org.apache.spark.sql.types.DataType):
Boolean
def SparkVariantType(): org.apache.spark.sql.types.DataType
+
+ // SQL UDFs (`CREATE FUNCTION ... RETURN ...`) are Spark 4.0+; the spark3
shim no-ops these.
+
+ /** Parser-stage rule rewriting a Paimon-catalog `CreateUserDefinedFunction`
into a create command. */
+ def rewritePaimonSQLFunctionCommands(spark: SparkSession): Rule[LogicalPlan]
+
+ /**
+ * Resolve a Paimon SQL function reference into a Spark
`SQLFunctionExpression` (Spark inlines
+ * it).
+ */
+ def resolvePaimonSQLFunction(
+ funcIdent: FunctionIdentifier,
+ function: PaimonFunction,
+ arguments: Seq[Expression],
+ parser: ParserInterface): Expression
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTestBase.scala
new file mode 100644
index 0000000000..230ec6f3dc
--- /dev/null
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonSQLFunctionTestBase.scala
@@ -0,0 +1,145 @@
+/*
+ * 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.paimon.spark.sql
+
+import org.apache.paimon.spark.PaimonSparkTestWithRestCatalogBase
+
+import org.apache.spark.sql.Row
+
+abstract class PaimonSQLFunctionTestBase extends
PaimonSparkTestWithRestCatalogBase {
+
+ test("Paimon SQL Function: scalar function with expression body") {
+ withUserDefinedFunction("area" -> false) {
+ sql("""
+ |CREATE FUNCTION area(width DOUBLE, height DOUBLE)
+ |RETURNS DOUBLE
+ |RETURN width * height
+ |""".stripMargin)
+ checkAnswer(sql("SELECT area(2, 3)"), Row(6.0))
+
+ withTable("t") {
+ sql("CREATE TABLE t (a DOUBLE, b DOUBLE)")
+ sql("INSERT INTO t VALUES (1, 2), (3, 4)")
+ checkAnswer(sql("SELECT area(a, b) FROM t ORDER BY a"), Seq(Row(2.0),
Row(12.0)))
+ }
+ }
+ }
+
+ test("Paimon SQL Function: create with qualified name and call across
catalog") {
+ withUserDefinedFunction("area" -> false) {
+ sql("""
+ |CREATE FUNCTION test.area(width DOUBLE, height DOUBLE)
+ |RETURNS DOUBLE
+ |RETURN width * height
+ |""".stripMargin)
+ checkAnswer(sql("SELECT test.area(2, 3)"), Row(6.0))
+ checkAnswer(sql("SELECT paimon.test.area(2, 4)"), Row(8.0))
+ }
+ }
+
+ test("Paimon SQL Function: create or replace / if not exists") {
+ withUserDefinedFunction("inc" -> false) {
+ sql("CREATE FUNCTION inc(x INT) RETURNS INT RETURN x + 1")
+ checkAnswer(sql("SELECT inc(10)"), Row(11))
+
+ // create again should fail
+ intercept[Exception] {
+ sql("CREATE FUNCTION inc(x INT) RETURNS INT RETURN x + 1")
+ }
+
+ // if not exists: no-op, keeps the old definition
+ sql("CREATE FUNCTION IF NOT EXISTS inc(x INT) RETURNS INT RETURN x +
100")
+ checkAnswer(sql("SELECT inc(10)"), Row(11))
+
+ // or replace: new definition takes effect
+ sql("CREATE OR REPLACE FUNCTION inc(x INT) RETURNS INT RETURN x + 100")
+ checkAnswer(sql("SELECT inc(10)"), Row(110))
+ }
+ }
+
+ test("Paimon SQL Function: scalar function with query body referencing a
table") {
+ withUserDefinedFunction("dept_total" -> false) {
+ withTable("emp") {
+ sql("CREATE TABLE emp (id INT, dept_id INT, salary INT)")
+ sql("INSERT INTO emp VALUES (1, 10, 100), (2, 10, 200), (3, 20, 300)")
+ sql("""
+ |CREATE FUNCTION dept_total(d INT) RETURNS INT
+ |RETURN SELECT SUM(salary) FROM emp WHERE dept_id = d
+ |""".stripMargin)
+ checkAnswer(sql("SELECT dept_total(10)"), Row(300))
+ checkAnswer(sql("SELECT dept_total(20)"), Row(300))
+ }
+ }
+ }
+
+ test("Paimon SQL Function: parameter with DEFAULT value") {
+ withUserDefinedFunction("addd" -> false) {
+ sql("CREATE FUNCTION addd(x INT, y INT DEFAULT 10) RETURNS INT RETURN x
+ y")
+ checkAnswer(sql("SELECT addd(5)"), Row(15))
+ checkAnswer(sql("SELECT addd(5, 1)"), Row(6))
+ }
+ }
+
+ test("Paimon SQL Function: describe function") {
+ withUserDefinedFunction("area" -> false) {
+ sql("CREATE FUNCTION area(width DOUBLE, height DOUBLE) RETURNS DOUBLE
RETURN width * height")
+
+ val desc = sql("DESCRIBE FUNCTION area").collect().map(_.getString(0))
+ assert(desc.exists(_.contains("Type: SCALAR")), desc.mkString("\n"))
+ assert(desc.exists(_.contains("Input:")), desc.mkString("\n"))
+ assert(desc.exists(_.contains("width")), desc.mkString("\n"))
+ assert(desc.exists(_.contains("Returns: DOUBLE")), desc.mkString("\n"))
+
+ val descExt = sql("DESCRIBE FUNCTION EXTENDED
area").collect().map(_.getString(0))
+ assert(descExt.exists(_.contains("width * height")),
descExt.mkString("\n"))
+ }
+ }
+
+ test("Paimon SQL Function: show functions lists the created function") {
+ withUserDefinedFunction("area" -> false) {
+ sql("CREATE FUNCTION area(w DOUBLE, h DOUBLE) RETURNS DOUBLE RETURN w *
h")
+ val names = sql("SHOW USER
FUNCTIONS").collect().map(_.getString(0).toLowerCase)
+ assert(names.exists(_.contains("area")), names.mkString(", "))
+ }
+ }
+
+ test("Paimon SQL Function: drop function") {
+ withUserDefinedFunction("area" -> false) {
+ sql("CREATE FUNCTION area(w DOUBLE, h DOUBLE) RETURNS DOUBLE RETURN w *
h")
+ checkAnswer(sql("SELECT area(2, 3)"), Row(6.0))
+
+ sql("DROP FUNCTION area")
+ intercept[Exception] {
+ sql("SELECT area(2, 3)")
+ }
+ sql("DROP FUNCTION IF EXISTS area")
+ }
+ }
+
+ test("Paimon SQL Function: table function is not supported yet") {
+ val e = intercept[Exception] {
+ sql("""
+ |CREATE FUNCTION rows_of(x INT)
+ |RETURNS TABLE(a INT)
+ |RETURN SELECT x + 1 AS a
+ |""".stripMargin)
+ }
+ assert(e.getMessage.contains("does not support creating SQL table
functions"))
+ }
+}
diff --git
a/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark3Shim.scala
b/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark3Shim.scala
index cbefee5d04..362e2bebf0 100644
---
a/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark3Shim.scala
+++
b/paimon-spark/paimon-spark3-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark3Shim.scala
@@ -324,6 +324,20 @@ class Spark3Shim extends SparkShim {
override def toPaimonVariant(array: ArrayData, pos: Int): Variant =
throw new UnsupportedOperationException()
+
+ // SQL UDFs (CREATE FUNCTION ... RETURN ...) are a Spark 4.0+ feature; no-op
rule on Spark 3.
+ override def rewritePaimonSQLFunctionCommands(spark: SparkSession):
Rule[LogicalPlan] =
+ new Rule[LogicalPlan] {
+ override def apply(plan: LogicalPlan): LogicalPlan = plan
+ }
+
+ override def resolvePaimonSQLFunction(
+ funcIdent: org.apache.spark.sql.catalyst.FunctionIdentifier,
+ function: org.apache.paimon.function.Function,
+ arguments: Seq[Expression],
+ parser: org.apache.spark.sql.catalyst.parser.ParserInterface):
Expression =
+ throw new UnsupportedOperationException(
+ "SQL user-defined functions (CREATE FUNCTION ... RETURN) require Spark
4.0 or later.")
}
object Spark3Shim {
diff --git
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/catalog/functions/SQLFunctionConverter.scala
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/catalog/functions/SQLFunctionConverter.scala
new file mode 100644
index 0000000000..a81baff8f3
--- /dev/null
+++
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/catalog/functions/SQLFunctionConverter.scala
@@ -0,0 +1,162 @@
+/*
+ * 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.paimon.spark.catalog.functions
+
+import org.apache.paimon.function.{Function => PaimonFunction,
FunctionDefinition, FunctionImpl}
+import org.apache.paimon.spark.SparkCatalog.FUNCTION_DEFINITION_NAME
+import org.apache.paimon.spark.SparkTypeUtils
+import org.apache.paimon.types.{DataField, RowType}
+
+import org.apache.spark.sql.catalyst.FunctionIdentifier
+import org.apache.spark.sql.catalyst.analysis.SQLFunctionExpression
+import org.apache.spark.sql.catalyst.catalog.{SQLFunction, UserDefinedFunction}
+import org.apache.spark.sql.catalyst.expressions.Expression
+import org.apache.spark.sql.catalyst.parser.ParserInterface
+import org.apache.spark.sql.types.{DataType => SparkDataType, StructType}
+
+import java.util.{Collections, HashMap => JHashMap, List => JList}
+
+/** Converts between Spark SQLFunction and Paimon Function with a
SQLFunctionDefinition body. */
+object SQLFunctionConverter {
+
+ // Spark-specific metadata stored in Paimon Function.options().
+ private val IS_QUERY = "spark.sql-function.is-query"
+ private val DETERMINISTIC = "spark.sql-function.deterministic"
+ private val CONTAINS_SQL = "spark.sql-function.contains-sql"
+
+ /** Build a Paimon function from a parsed CREATE FUNCTION ... RETURN
statement. */
+ def toPaimonFunction(
+ funcIdent: FunctionIdentifier,
+ inputParamText: Option[String],
+ returnTypeText: String,
+ exprText: Option[String],
+ queryText: Option[String],
+ comment: Option[String],
+ isDeterministic: Option[Boolean],
+ containsSQL: Option[Boolean],
+ parser: ParserInterface): PaimonFunction = {
+ require(
+ returnTypeText != null && returnTypeText.trim.nonEmpty,
+ s"SQL function $funcIdent must declare an explicit RETURNS type.")
+ val identifier = FunctionIdentifierConverter.toPaimonIdentifier(funcIdent)
+
+ val inputParams: JList[DataField] = inputParamText.filter(_.trim.nonEmpty)
match {
+ case Some(text) =>
+ SparkTypeUtils
+ .toPaimonRowType(UserDefinedFunction.parseRoutineParam(text, parser))
+ .getFields
+ case None => Collections.emptyList[DataField]()
+ }
+ val returnSparkType = parseScalarReturnType(funcIdent, returnTypeText,
parser)
+ val returnParams: JList[DataField] =
+ Collections.singletonList(
+ new DataField(0, funcIdent.funcName,
SparkTypeUtils.toPaimonType(returnSparkType)))
+
+ // Exactly one of exprText / queryText is set by the parser.
+ val isQuery = exprText.isEmpty && queryText.isDefined
+ val body = exprText
+ .orElse(queryText)
+ .getOrElse(throw new IllegalArgumentException(s"SQL function $funcIdent
has an empty body."))
+
+ val options = new JHashMap[String, String]()
+ options.put(IS_QUERY, isQuery.toString)
+ isDeterministic.foreach(d => options.put(DETERMINISTIC, d.toString))
+ containsSQL.foreach(c => options.put(CONTAINS_SQL, c.toString))
+
+ new FunctionImpl(
+ identifier,
+ inputParams,
+ returnParams,
+ isDeterministic.getOrElse(true),
+ Collections.singletonMap(FUNCTION_DEFINITION_NAME,
FunctionDefinition.sql(body)),
+ comment.orNull,
+ options
+ )
+ }
+
+ /** Resolve a Paimon-stored SQL function into a Spark SQLFunctionExpression.
*/
+ def toSQLFunctionExpression(
+ funcIdent: FunctionIdentifier,
+ function: PaimonFunction,
+ arguments: Seq[Expression],
+ parser: ParserInterface): Expression = {
+ val options = function.options()
+
+ val body = function.definition(FUNCTION_DEFINITION_NAME) match {
+ case sql: FunctionDefinition.SQLFunctionDefinition => sql.definition()
+ case other =>
+ throw new IllegalStateException(
+ s"Function $funcIdent is not a SQL function, found definition:
$other")
+ }
+
+ val inputParam: Option[StructType] = {
+ val ip = function.inputParams()
+ if (ip.isPresent && !ip.get().isEmpty) {
+ Some(SparkTypeUtils.fromPaimonType(new
RowType(ip.get())).asInstanceOf[StructType])
+ } else None
+ }
+
+ val rp = function.returnParams()
+ require(
+ rp.isPresent && !rp.get().isEmpty,
+ s"SQL function $funcIdent has no return type in returnParams.")
+ val returnType: SparkDataType =
SparkTypeUtils.fromPaimonType(rp.get().get(0).`type`())
+
+ val isQuery = Option(options.get(IS_QUERY))
+ .map(java.lang.Boolean.parseBoolean)
+ .getOrElse {
+ try { parser.parseExpression(body); false }
+ catch { case _: Exception => true }
+ }
+
+ val deterministic = Option(options.get(DETERMINISTIC))
+ .map(_.toBoolean)
+ .orElse(Some(function.isDeterministic))
+
+ val sqlFunction = SQLFunction(
+ name = funcIdent,
+ inputParam = inputParam,
+ returnType = Left(returnType),
+ exprText = if (isQuery) None else Some(body),
+ queryText = if (isQuery) Some(body) else None,
+ comment = Option(function.comment()),
+ deterministic = deterministic,
+ containsSQL = Option(options.get(CONTAINS_SQL)).map(_.toBoolean),
+ isTableFunc = false,
+ properties = Map.empty
+ )
+
+ SQLFunctionExpression(
+ sqlFunction.name.unquotedString,
+ sqlFunction,
+ arguments,
+ Some(sqlFunction.getScalarFuncReturnType))
+ }
+
+ private def parseScalarReturnType(
+ funcIdent: FunctionIdentifier,
+ returnTypeText: String,
+ parser: ParserInterface): SparkDataType =
+ SQLFunction.parseReturnTypeText(returnTypeText, isTableFunc = false,
parser) match {
+ case Some(Left(dataType)) => dataType
+ case _ =>
+ throw new UnsupportedOperationException(
+ s"Unsupported return type '$returnTypeText' for scalar SQL function
$funcIdent.")
+ }
+}
diff --git
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/RewritePaimonSQLFunctionCommands.scala
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/RewritePaimonSQLFunctionCommands.scala
new file mode 100644
index 0000000000..cc2c4df6a6
--- /dev/null
+++
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/RewritePaimonSQLFunctionCommands.scala
@@ -0,0 +1,79 @@
+/*
+ * 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.spark.sql.catalyst.parser.extensions
+
+import org.apache.paimon.spark.catalog.SupportV1Function
+import org.apache.paimon.spark.catalog.functions.SQLFunctionConverter
+import org.apache.paimon.spark.execution.CreatePaimonV1FunctionCommand
+import org.apache.paimon.spark.util.OptionUtils
+
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.catalyst.plans.logical.{CreateUserDefinedFunction,
LogicalPlan}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.connector.catalog.CatalogManager
+
+/**
+ * Parser-stage rule that rewrites a Paimon-catalog `CREATE FUNCTION ...
RETURN ...`
+ * (`CreateUserDefinedFunction`) into [[CreatePaimonV1FunctionCommand]],
before Spark's
+ * `ResolveSessionCatalog` throws `MISSING_CATALOG_ABILITY.CREATE_FUNCTION`.
Fields are read by name
+ * (not positional unapply) since `CreateUserDefinedFunction`'s arity differs
across Spark 4.0/4.1.
+ */
+case class RewritePaimonSQLFunctionCommands(spark: SparkSession) extends
Rule[LogicalPlan] {
+
+ private lazy val catalogManager: CatalogManager =
spark.sessionState.catalogManager
+
+ private lazy val lookup = PaimonFunctionLookup(catalogManager)
+
+ override def apply(plan: LogicalPlan): LogicalPlan = {
+ if (!OptionUtils.v1FunctionEnabled()) {
+ return plan
+ }
+
+ plan.resolveOperatorsUp {
+ case c: CreateUserDefinedFunction =>
+ lookup.CatalogAndFunctionIdentifier.unapply(c.child) match {
+ case Some((catalog: SupportV1Function, funcIdent, false)) =>
+ if (lookup.isPaimonBuiltInFunction(funcIdent)) {
+ throw new UnsupportedOperationException(s"Can't create built-in
function: $funcIdent")
+ }
+ if (c.isTableFunc) {
+ throw new UnsupportedOperationException(
+ s"Paimon does not support creating SQL table functions yet:
$funcIdent")
+ }
+ val paimonFunction = SQLFunctionConverter.toPaimonFunction(
+ funcIdent,
+ c.inputParamText,
+ c.returnTypeText,
+ c.exprText,
+ c.queryText,
+ c.comment,
+ c.isDeterministic,
+ c.containsSQL,
+ spark.sessionState.sqlParser)
+ CreatePaimonV1FunctionCommand(
+ catalog,
+ funcIdent,
+ paimonFunction,
+ c.ignoreIfExists,
+ c.replace)
+ case _ => c
+ }
+ }
+ }
+}
diff --git
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
index 9c4a4daa6a..3a49c223b6 100644
---
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
+++
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/paimon/shims/Spark4Shim.scala
@@ -319,6 +319,18 @@ class Spark4Shim extends SparkShim {
dataType.isInstanceOf[VariantType]
override def SparkVariantType(): org.apache.spark.sql.types.DataType =
DataTypes.VariantType
+
+ // SQL UDFs (CREATE FUNCTION ... RETURN ...).
+ override def rewritePaimonSQLFunctionCommands(spark: SparkSession):
Rule[LogicalPlan] =
+
org.apache.spark.sql.catalyst.parser.extensions.RewritePaimonSQLFunctionCommands(spark)
+
+ override def resolvePaimonSQLFunction(
+ funcIdent: org.apache.spark.sql.catalyst.FunctionIdentifier,
+ function: org.apache.paimon.function.Function,
+ arguments: Seq[Expression],
+ parser: org.apache.spark.sql.catalyst.parser.ParserInterface):
Expression =
+ org.apache.paimon.spark.catalog.functions.SQLFunctionConverter
+ .toSQLFunctionExpression(funcIdent, function, arguments, parser)
}
object Spark4Shim {