This is an automated email from the ASF dual-hosted git repository.
YannByron 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 1d2cf84780 [spark] Improve SQL UDF with body analysis and validation
(#8315)
1d2cf84780 is described below
commit 1d2cf84780af3827c94530a7c90cbc80fd313cd8
Author: Zouxxyy <[email protected]>
AuthorDate: Tue Jun 23 14:46:25 2026 +0800
[spark] Improve SQL UDF with body analysis and validation (#8315)
---
.../CreatePaimonSQLFunctionCommand.scala | 75 +++
.../spark/execution/PaimonFunctionExec.scala | 64 ++-
.../spark/sql/PaimonSQLFunctionTestBase.scala | 93 +++-
.../catalog/functions/SQLFunctionConverter.scala | 21 +-
.../CreatePaimonSQLFunctionCommand.scala | 514 +++++++++++++++++++++
.../RewritePaimonSQLFunctionCommands.scala | 22 +-
6 files changed, 754 insertions(+), 35 deletions(-)
diff --git
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/CreatePaimonSQLFunctionCommand.scala
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/CreatePaimonSQLFunctionCommand.scala
new file mode 100644
index 0000000000..f06ae2ca6f
--- /dev/null
+++
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/CreatePaimonSQLFunctionCommand.scala
@@ -0,0 +1,75 @@
+/*
+ * 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.leafnode.PaimonLeafRunnableCommand
+
+import org.apache.spark.sql.{Row, SparkSession}
+import org.apache.spark.sql.catalyst.FunctionIdentifier
+
+/**
+ * Simplified version of CreatePaimonSQLFunctionCommand for Spark 4.0.
Persists the function without
+ * full body analysis (no type inference, no validation). The full version in
spark4-common is used
+ * by Spark 4.1+.
+ */
+case class CreatePaimonSQLFunctionCommand(
+ catalog: SupportV1Function,
+ name: FunctionIdentifier,
+ inputParamText: Option[String],
+ returnTypeText: String,
+ exprText: Option[String],
+ queryText: Option[String],
+ comment: Option[String],
+ isDeterministic: Option[Boolean],
+ containsSQL: Option[Boolean],
+ isTableFunc: Boolean,
+ ignoreIfExists: Boolean,
+ replace: Boolean)
+ extends PaimonLeafRunnableCommand {
+
+ override def run(sparkSession: SparkSession): Seq[Row] = {
+ require(
+ returnTypeText != null && returnTypeText.trim.nonEmpty,
+ s"SQL function $name requires an explicit RETURNS clause on Spark 4.0.")
+
+ val parser = sparkSession.sessionState.sqlParser
+ val paimonFunction = SQLFunctionConverter.toPaimonFunction(
+ name,
+ inputParamText,
+ returnTypeText,
+ exprText,
+ queryText,
+ comment,
+ isDeterministic,
+ containsSQL,
+ parser)
+
+ if (replace) {
+ catalog.dropV1Function(name, true)
+ }
+ catalog.createV1Function(paimonFunction, ignoreIfExists)
+ Nil
+ }
+
+ override def simpleString(maxFields: Int): String = {
+ s"CreatePaimonSQLFunctionCommand: $name"
+ }
+}
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 3ca46a43de..0cb9e1cfb5 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
@@ -88,25 +88,46 @@ case class DescribePaimonV1FunctionCommand(
s"File Resources:
${functionDefinition.fileResources().asScala.map(_.uri()).mkString(", ")}")
}
case sqlFunctionDefinition: FunctionDefinition.SQLFunctionDefinition =>
- rows += Row(s"Function: ${function.fullName()}")
- rows += Row("Type: SCALAR")
+ val buffer = new ArrayBuffer[(String, String)]
+ buffer += ("Function:" -> function.fullName())
+ buffer += ("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 params = formatInputParams(inputParams.get().asScala)
+ buffer += ("Input:" -> params.head)
+ params.tail.foreach(s => buffer += ("" -> s))
+ } else {
+ buffer += ("Input:" -> "()")
}
val returnParams = function.returnParams()
if (returnParams.isPresent && !returnParams.get().isEmpty) {
- rows += Row(s"Returns:
${returnParams.get().get(0).`type`().asSQLString()}")
+ buffer += ("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()}")
+ Option(function.comment()).foreach(c => buffer += ("Comment:" -> c))
+ buffer += ("Deterministic:" -> function.isDeterministic.toString)
+ val options = function.options()
+ Option(options.get("spark.sql-function.contains-sql"))
+ .map(_.toBoolean)
+ .foreach {
+ c =>
+ val dataAccess = if (c) "CONTAINS SQL" else "READS SQL DATA"
+ buffer += ("Data Access:" -> dataAccess)
+ }
+ val configs = options.asScala
+ .filter(_._1.startsWith("sqlConfig."))
+ .toSeq
+ .sortBy(_._1)
+ .map { case (k, v) => s"${k.stripPrefix("sqlConfig.")}=$v" }
+ if (configs.nonEmpty) {
+ buffer += ("Configs:" -> configs.head)
+ configs.tail.foreach(s => buffer += ("" -> s))
+ }
+ buffer += ("Body:" -> sqlFunctionDefinition.definition())
}
+ val keys = tabulate(buffer.map(_._1).toSeq)
+ val values = buffer.map(_._2)
+ keys.zip(values).foreach { case (key, value) => rows += Row(s"$key
$value") }
case other =>
throw new UnsupportedOperationException(s"Unsupported function
definition $other")
}
@@ -114,6 +135,27 @@ case class DescribePaimonV1FunctionCommand(
rows.toSeq
}
+ private def tabulate(inputs: Seq[String]): Seq[String] = {
+ val maxLen = inputs.map(_.length).max
+ inputs.map(_.padTo(maxLen, ' '))
+ }
+
+ private def formatInputParams(
+ params: Iterable[org.apache.paimon.types.DataField]): Seq[String] = {
+ val fields = params.toSeq
+ val names = tabulate(fields.map(_.name()))
+ val types = tabulate(fields.map(_.`type`().asSQLString()))
+ val defaults = fields.map {
+ f => if (isExtended) Option(f.defaultValue()).map(d => s" DEFAULT
$d").getOrElse("") else ""
+ }
+ val comments = fields.map {
+ f => if (isExtended) Option(f.description()).map(c => s"
'$c'").getOrElse("") else ""
+ }
+ names.zip(types).zip(defaults).zip(comments).map {
+ case (((name, dataType), default), comment) => s"$name
$dataType$default$comment"
+ }
+ }
+
override def simpleString(maxFields: Int): String = {
s"DescribePaimonV1FunctionCommand: ${function.fullName()}"
}
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
index 230ec6f3dc..8d13da7d60 100644
---
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
@@ -101,16 +101,30 @@ abstract class PaimonSQLFunctionTestBase extends
PaimonSparkTestWithRestCatalogB
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("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"))
+ assert(desc.exists(_.contains("DOUBLE")), desc.mkString("\n"))
val descExt = sql("DESCRIBE FUNCTION EXTENDED
area").collect().map(_.getString(0))
+ assert(descExt.exists(_.contains("Deterministic")),
descExt.mkString("\n"))
assert(descExt.exists(_.contains("width * height")),
descExt.mkString("\n"))
}
}
+ test("Paimon SQL Function: describe function with comment") {
+ withUserDefinedFunction("inc" -> false) {
+ sql("CREATE FUNCTION inc(x INT) RETURNS INT COMMENT 'increment by one'
RETURN x + 1")
+
+ val desc = sql("DESCRIBE FUNCTION inc").collect().map(_.getString(0))
+ assert(desc.exists(_.contains("SCALAR")), desc.mkString("\n"))
+
+ val descExt = sql("DESCRIBE FUNCTION EXTENDED
inc").collect().map(_.getString(0))
+ assert(descExt.exists(_.contains("increment by one")),
descExt.mkString("\n"))
+ assert(descExt.exists(_.contains("x + 1")), 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")
@@ -132,6 +146,79 @@ abstract class PaimonSQLFunctionTestBase extends
PaimonSparkTestWithRestCatalogB
}
}
+ test("Paimon SQL Function: SQL configs captured at creation time") {
+ assume(gteqSpark4_1)
+ withUserDefinedFunction("div_func" -> false) {
+ // Create with ANSI enabled — division by zero should throw at query
time.
+ sql("SET spark.sql.ansi.enabled=true")
+ sql("CREATE FUNCTION div_func(x INT) RETURNS DOUBLE RETURN 1 / x")
+ sql("SET spark.sql.ansi.enabled=false")
+
+ // Even though ANSI is now disabled in the session, the function was
created with ANSI=true,
+ // so division by zero should still throw ArithmeticException.
+ val e = intercept[Exception] {
+ sql("SELECT div_func(0)").collect()
+ }
+ assert(
+ e.getMessage.contains("Division by zero") ||
+ e.getMessage.contains("ArithmeticException") ||
+ e.getMessage.contains("DIVIDE_BY_ZERO"))
+
+ sql("RESET spark.sql.ansi.enabled")
+ }
+ }
+
+ test("Paimon SQL Function: non-deterministic function body") {
+ assume(gteqSpark4_1)
+ withUserDefinedFunction("rnd" -> false) {
+ sql("CREATE FUNCTION rnd() RETURNS DOUBLE RETURN rand()")
+ val r1 = sql("SELECT rnd()").collect()(0).getDouble(0)
+ val r2 = sql("SELECT rnd()").collect()(0).getDouble(0)
+ assert(r1 >= 0.0 && r1 < 1.0)
+ assert(r2 >= 0.0 && r2 < 1.0)
+ }
+ }
+
+ test("Paimon SQL Function: reject aggregate in scalar function body") {
+ assume(gteqSpark4_1)
+ val e = intercept[Exception] {
+ sql("CREATE FUNCTION bad_agg(x INT) RETURNS INT RETURN SUM(x)")
+ }
+ assert(e.getMessage.contains("CANNOT_CONTAIN_COMPLEX_FUNCTIONS"))
+ }
+
+ test("Paimon SQL Function: reject window function in scalar function body") {
+ assume(gteqSpark4_1)
+ val e = intercept[Exception] {
+ sql("CREATE FUNCTION bad_win(x INT) RETURNS INT RETURN ROW_NUMBER() OVER
(ORDER BY x)")
+ }
+ assert(e.getMessage.contains("CANNOT_CONTAIN_COMPLEX_FUNCTIONS"))
+ }
+
+ test("Paimon SQL Function: reject duplicate parameter names") {
+ assume(gteqSpark4_1)
+ val e = intercept[Exception] {
+ sql("CREATE FUNCTION bad_dup(x INT, x INT) RETURNS INT RETURN x + x")
+ }
+ assert(e.getMessage.toLowerCase.contains("duplicate"))
+ }
+
+ test("Paimon SQL Function: reject non-trailing defaults") {
+ assume(gteqSpark4_1)
+ val e = intercept[Exception] {
+ sql("CREATE FUNCTION bad_def(x INT DEFAULT 1, y INT) RETURNS INT RETURN
x + y")
+ }
+ assert(e.getMessage.toLowerCase.contains("default"))
+ }
+
+ test("Paimon SQL Function: omitting RETURNS clause") {
+ assume(gteqSpark4_1)
+ withUserDefinedFunction("inc" -> false) {
+ sql("CREATE FUNCTION inc(x INT) RETURN x + 1")
+ checkAnswer(sql("SELECT inc(10)"), Row(11))
+ }
+ }
+
test("Paimon SQL Function: table function is not supported yet") {
val e = intercept[Exception] {
sql("""
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
index a81baff8f3..4aee17aa2e 100644
---
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
@@ -32,13 +32,16 @@ import org.apache.spark.sql.types.{DataType =>
SparkDataType, StructType}
import java.util.{Collections, HashMap => JHashMap, List => JList}
+import scala.collection.JavaConverters._
+
/** 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"
+ // Paimon-specific option keys (prefixed to avoid collision with Spark
properties).
+ private val PAIMON_OPTION_PREFIX = "spark.sql-function."
+ private val IS_QUERY = PAIMON_OPTION_PREFIX + "is-query"
+ private val DETERMINISTIC = PAIMON_OPTION_PREFIX + "deterministic"
+ private val CONTAINS_SQL = PAIMON_OPTION_PREFIX + "contains-sql"
/** Build a Paimon function from a parsed CREATE FUNCTION ... RETURN
statement. */
def toPaimonFunction(
@@ -50,10 +53,11 @@ object SQLFunctionConverter {
comment: Option[String],
isDeterministic: Option[Boolean],
containsSQL: Option[Boolean],
- parser: ParserInterface): PaimonFunction = {
+ parser: ParserInterface,
+ properties: Map[String, String] = Map.empty): PaimonFunction = {
require(
returnTypeText != null && returnTypeText.trim.nonEmpty,
- s"SQL function $funcIdent must declare an explicit RETURNS type.")
+ s"SQL function $funcIdent must have a return type (explicit or
inferred).")
val identifier = FunctionIdentifierConverter.toPaimonIdentifier(funcIdent)
val inputParams: JList[DataField] = inputParamText.filter(_.trim.nonEmpty)
match {
@@ -78,12 +82,13 @@ object SQLFunctionConverter {
options.put(IS_QUERY, isQuery.toString)
isDeterministic.foreach(d => options.put(DETERMINISTIC, d.toString))
containsSQL.foreach(c => options.put(CONTAINS_SQL, c.toString))
+ properties.foreach { case (k, v) => options.put(k, v) }
new FunctionImpl(
identifier,
inputParams,
returnParams,
- isDeterministic.getOrElse(true),
+ isDeterministic.getOrElse(true), // caller should always pass Some after
analysis
Collections.singletonMap(FUNCTION_DEFINITION_NAME,
FunctionDefinition.sql(body)),
comment.orNull,
options
@@ -139,7 +144,7 @@ object SQLFunctionConverter {
deterministic = deterministic,
containsSQL = Option(options.get(CONTAINS_SQL)).map(_.toBoolean),
isTableFunc = false,
- properties = Map.empty
+ properties =
options.asScala.filterNot(_._1.startsWith(PAIMON_OPTION_PREFIX)).toMap
)
SQLFunctionExpression(
diff --git
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/CreatePaimonSQLFunctionCommand.scala
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/CreatePaimonSQLFunctionCommand.scala
new file mode 100644
index 0000000000..de4195ac3f
--- /dev/null
+++
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/CreatePaimonSQLFunctionCommand.scala
@@ -0,0 +1,514 @@
+/*
+ * 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.leafnode.PaimonLeafRunnableCommand
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.{AnalysisException, Row, SparkSession}
+import org.apache.spark.sql.catalyst.CapturesConfig
+import org.apache.spark.sql.catalyst.FunctionIdentifier
+import org.apache.spark.sql.catalyst.analysis.{withPosition, Analyzer,
SQLFunctionExpression, SQLFunctionNode, SQLScalarFunction, SQLTableFunction,
UnresolvedAlias, UnresolvedAttribute, UnresolvedFunction, UnresolvedRelation,
UnresolvedTableValuedFunction}
+import org.apache.spark.sql.catalyst.catalog.{SessionCatalog, SQLFunction,
UserDefinedFunction, UserDefinedFunctionErrors}
+import org.apache.spark.sql.catalyst.catalog.UserDefinedFunction._
+import org.apache.spark.sql.catalyst.expressions.{Alias, Cast, Expression,
Generator, LateralSubquery, Literal, ScalarSubquery, SubqueryExpression,
WindowExpression}
+import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
+import org.apache.spark.sql.catalyst.plans.Inner
+import org.apache.spark.sql.catalyst.plans.logical.{LateralJoin,
LocalRelation, LogicalPlan, OneRowRelation, Project, Range, UnresolvedWith,
View}
+import org.apache.spark.sql.catalyst.trees.TreePattern.UNRESOLVED_ATTRIBUTE
+import
org.apache.spark.sql.connector.catalog.CatalogV2Implicits.MultipartIdentifierHelper
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import
org.apache.spark.sql.execution.command.CreateUserDefinedFunctionCommand._
+import org.apache.spark.sql.execution.command.ViewHelper
+import org.apache.spark.sql.types.{DataType, StructField, StructType}
+
+/**
+ * Adapted from Spark's CreateSQLFunctionCommand. Analyzes the function body,
validates, derives
+ * deterministic/containsSQL, then persists to the Paimon catalog instead of
the session catalog.
+ */
+case class CreatePaimonSQLFunctionCommand(
+ catalog: SupportV1Function,
+ name: FunctionIdentifier,
+ inputParamText: Option[String],
+ returnTypeText: String,
+ exprText: Option[String],
+ queryText: Option[String],
+ comment: Option[String],
+ isDeterministic: Option[Boolean],
+ containsSQL: Option[Boolean],
+ isTableFunc: Boolean,
+ ignoreIfExists: Boolean,
+ replace: Boolean)
+ extends PaimonLeafRunnableCommand
+ with CapturesConfig {
+
+ import SQLFunction._
+
+ override def run(sparkSession: SparkSession): Seq[Row] = {
+ val parser = sparkSession.sessionState.sqlParser
+ val analyzer = sparkSession.sessionState.analyzer
+ val sessionCatalog = sparkSession.sessionState.catalog
+ val conf = sparkSession.sessionState.conf
+
+ val inputParam =
inputParamText.map(UserDefinedFunction.parseRoutineParam(_, parser))
+ val returnType = parseReturnTypeText(returnTypeText, isTableFunc, parser)
+
+ val function = SQLFunction(
+ name,
+ inputParam,
+ returnType.getOrElse(if (isTableFunc) Right(null) else Left(null)),
+ exprText,
+ queryText,
+ comment,
+ isDeterministic,
+ containsSQL,
+ isTableFunc,
+ Map.empty
+ )
+
+ val newFunction = {
+ val (expression, query) = function.getExpressionAndQuery(parser,
isTableFunc)
+ assert(query.nonEmpty || expression.nonEmpty)
+
+ // Build function input.
+ val inputPlan = if (inputParam.isDefined) {
+ val param = inputParam.get
+ checkParameterNotNull(param, inputParamText.get)
+ checkParameterNameDuplication(param, conf, name)
+ checkDefaultsTrailing(param, name)
+
+ // Qualify the input parameters with the function name so that
attributes referencing
+ // the function input parameters can be resolved correctly.
+ val qualifier = Seq(name.funcName)
+ val input = param.map(
+ p =>
+ Alias(
+ {
+ val defaultExpr = p.getDefault()
+ if (defaultExpr.isEmpty) {
+ Literal.create(null, p.dataType)
+ } else {
+ val defaultPlan = parseDefault(defaultExpr.get, parser)
+ if (SubqueryExpression.hasSubquery(defaultPlan)) {
+ throw new AnalysisException(
+ errorClass =
"USER_DEFINED_FUNCTIONS.NOT_A_VALID_DEFAULT_EXPRESSION",
+ messageParameters =
+ Map("functionName" -> name.funcName, "parameterName"
-> p.name))
+ } else if
(defaultPlan.containsPattern(UNRESOLVED_ATTRIBUTE)) {
+ // TODO(SPARK-50698): use parsed expression instead of
expression string.
+ defaultPlan.collect {
+ case a: UnresolvedAttribute =>
+ throw QueryCompilationErrors.unresolvedAttributeError(
+ "UNRESOLVED_COLUMN",
+ a.sql,
+ Seq.empty,
+ a.origin)
+ }
+ }
+ Cast(defaultPlan, p.dataType)
+ }
+ },
+ p.name
+ )(qualifier = qualifier))
+ Project(input, OneRowRelation())
+ } else {
+ OneRowRelation()
+ }
+
+ // Build the function body and check if the function body can be
analyzed successfully.
+ val (unresolvedPlan, analyzedPlan, inferredReturnType) = if
(!isTableFunc) {
+ // Build SQL scalar function plan.
+ val outputExpr = if (query.isDefined) ScalarSubquery(query.get) else
expression.get
+ val plan: LogicalPlan = returnType
+ .map {
+ t =>
+ val retType: DataType = t match {
+ case Left(t) => t
+ case _ =>
+ throw SparkException.internalError("Unexpected return type
for a scalar SQL UDF.")
+ }
+ val outputCast = Seq(Alias(Cast(outputExpr, retType),
name.funcName)())
+ Project(outputCast, inputPlan)
+ }
+ .getOrElse {
+ // If no explicit RETURNS clause is present, infer the result type
from the function body.
+ val outputAlias = Seq(Alias(outputExpr, name.funcName)())
+ Project(outputAlias, inputPlan)
+ }
+
+ // Check cyclic function reference before running the analyzer.
+ checkCyclicFunctionReference(sessionCatalog, name, plan)
+
+ // Check the function body can be analyzed correctly.
+ val analyzed = analyzer.execute(plan)
+ val (resolved, resolvedReturnType) = analyzed match {
+ case p @ Project(expr :: Nil, _) if expr.resolved =>
+ (p, Left(expr.dataType))
+ case other =>
+ (other, function.returnType)
+ }
+
+ // Check if the SQL function body contains aggregate/window functions.
+ // This check needs to be performed before checkAnalysis to provide
better error messages.
+ checkAggOrWindowOrGeneratorExpr(resolved)
+
+ // Check if the SQL function body can be analyzed.
+ checkFunctionBodyAnalysis(analyzer, function, resolved)
+
+ (plan, resolved, resolvedReturnType)
+ } else {
+ // Build SQL table function plan.
+ if (query.isEmpty) {
+ throw
UserDefinedFunctionErrors.bodyIsNotAQueryForSqlTableUdf(name.funcName)
+ }
+ // Check cyclic function reference before running the analyzer.
+ checkCyclicFunctionReference(sessionCatalog, name, query.get)
+
+ // Construct a lateral join to analyze the function body.
+ val plan = LateralJoin(inputPlan, LateralSubquery(query.get), Inner,
None)
+ val analyzed = analyzer.execute(plan)
+ val newPlan = analyzed match {
+ case Project(_, j: LateralJoin) => j
+ case j: LateralJoin => j
+ case _ =>
+ throw SparkException.internalError(
+ "Unexpected plan returned when " +
+ s"creating a SQL TVF: ${analyzed.getClass.getSimpleName}.")
+ }
+ val maybeResolved = newPlan.asInstanceOf[LateralJoin].right.plan
+
+ // Check if the function body can be analyzed.
+ checkFunctionBodyAnalysis(analyzer, function, maybeResolved)
+
+ // Get the function's return schema.
+ val returnParam: StructType = returnType
+ .map {
+ case Right(t) => t
+ case Left(_) =>
+ throw SparkException.internalError(
+ "Unexpected return schema for a SQL table function.")
+ }
+ .getOrElse {
+ query.get match {
+ case Project(projectList, _) if
projectList.exists(_.isInstanceOf[UnresolvedAlias]) =>
+ throw
UserDefinedFunctionErrors.missingColumnNamesForSqlTableUdf(name.funcName)
+ case _ =>
+
StructType(analyzed.asInstanceOf[LateralJoin].right.plan.output.map {
+ col => StructField(col.name, col.dataType)
+ })
+ }
+ }
+
+ // Check the return columns cannot have NOT NULL specified.
+ checkParameterNotNull(returnParam, returnTypeText)
+
+ // Check duplicated return column names.
+ checkReturnsColumnDuplication(returnParam, conf, name)
+
+ // Check if the actual output size equals to the number of return
parameters.
+ val outputSize = maybeResolved.output.size
+ if (outputSize != returnParam.size) {
+ throw new AnalysisException(
+ errorClass = "USER_DEFINED_FUNCTIONS.RETURN_COLUMN_COUNT_MISMATCH",
+ messageParameters = Map(
+ "outputSize" -> s"$outputSize",
+ "returnParamSize" -> s"${returnParam.size}",
+ "name" -> s"$name"
+ )
+ )
+ }
+
+ (plan, analyzed, Right(returnParam))
+ }
+
+ // A permanent function is not allowed to reference temporary objects.
+ verifyTemporaryObjectsNotExists(sessionCatalog, name, unresolvedPlan,
analyzedPlan)
+
+ // Generate function properties.
+ val properties = generateFunctionProperties(sparkSession,
unresolvedPlan, analyzedPlan)
+
+ // Derive determinism of the SQL function.
+ val deterministic = analyzedPlan.deterministic
+
+ // Derive and check a SQL function with CONTAINS SQL data access should
not reads SQL data.
+ val readsSQLData = deriveSQLDataAccess(analyzedPlan)
+
+ function.copy(
+ // Assign the return type, inferring from the function body if needed.
+ returnType = inferredReturnType,
+ deterministic = Some(function.deterministic.getOrElse(deterministic)),
+ containsSQL = Some(function.containsSQL.getOrElse(!readsSQLData)),
+ properties = properties
+ )
+ }
+
+ // ---- Paimon-specific: persist to Paimon catalog ----
+ val resolvedReturnTypeText = newFunction.returnType match {
+ case Left(dt) if dt != null => dt.sql
+ case _ =>
+ throw new UnsupportedOperationException(
+ s"Cannot infer return type for SQL function ${name.funcName}. " +
+ "Please add an explicit RETURNS clause.")
+ }
+
+ val paimonFunction = SQLFunctionConverter.toPaimonFunction(
+ name,
+ inputParamText,
+ if (returnTypeText != null && returnTypeText.trim.nonEmpty)
returnTypeText
+ else resolvedReturnTypeText,
+ exprText,
+ queryText,
+ comment,
+ newFunction.deterministic,
+ newFunction.containsSQL,
+ parser,
+ newFunction.properties
+ )
+
+ if (replace) {
+ catalog.dropV1Function(name, true)
+ }
+ catalog.createV1Function(paimonFunction, ignoreIfExists)
+ Nil
+ }
+
+ /** Check if the function body can be analyzed. */
+ private def checkFunctionBodyAnalysis(
+ analyzer: Analyzer,
+ function: SQLFunction,
+ body: LogicalPlan): Unit = {
+ analyzer.checkAnalysis(SQLFunctionNode(function, body))
+ }
+
+ /** Collect all temporary views and functions and return the identifiers
separately */
+ private def collectTemporaryObjectsInUnresolvedPlan(
+ catalog: SessionCatalog,
+ child: LogicalPlan): (Seq[Seq[String]], Seq[String]) = {
+ import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
+ def collectTempViews(child: LogicalPlan): Seq[Seq[String]] = {
+ child.flatMap {
+ case UnresolvedRelation(nameParts, _, _) if
catalog.isTempView(nameParts) =>
+ Seq(nameParts)
+ case w: UnresolvedWith if !w.resolved =>
w.innerChildren.flatMap(collectTempViews)
+ case plan if !plan.resolved =>
+ plan.expressions.flatMap(_.flatMap {
+ case e: SubqueryExpression => collectTempViews(e.plan)
+ case _ => Seq.empty
+ })
+ case _ => Seq.empty
+ }.distinct
+ }
+
+ def collectTempFunctions(child: LogicalPlan): Seq[String] = {
+ child.flatMap {
+ case w: UnresolvedWith if !w.resolved =>
w.innerChildren.flatMap(collectTempFunctions)
+ case plan if !plan.resolved =>
+ plan.expressions.flatMap(_.flatMap {
+ case e: SubqueryExpression => collectTempFunctions(e.plan)
+ case e: UnresolvedFunction
+ if
catalog.isTemporaryFunction(e.nameParts.asFunctionIdentifier) =>
+ Seq(e.nameParts.asFunctionIdentifier.funcName)
+ case _ => Seq.empty
+ })
+ case _ => Seq.empty
+ }.distinct
+ }
+ (collectTempViews(child), collectTempFunctions(child))
+ }
+
+ /**
+ * Permanent functions are not allowed to reference temp objects, including
temp functions and
+ * temp views.
+ */
+ private def verifyTemporaryObjectsNotExists(
+ catalog: SessionCatalog,
+ name: FunctionIdentifier,
+ child: LogicalPlan,
+ analyzed: LogicalPlan): Unit = {
+ import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
+ val (tempViews, tempFunctions) =
collectTemporaryObjectsInUnresolvedPlan(catalog, child)
+ tempViews.foreach {
+ nameParts =>
+ throw UserDefinedFunctionErrors.invalidTempViewReference(
+ routineName = name.asMultipart,
+ tempViewName = nameParts)
+ }
+ tempFunctions.foreach {
+ funcName =>
+ throw UserDefinedFunctionErrors.invalidTempFuncReference(
+ routineName = name.asMultipart,
+ tempFuncName = funcName)
+ }
+ val tempVars = ViewHelper.collectTemporaryVariables(analyzed)
+ tempVars.foreach {
+ varName =>
+ throw UserDefinedFunctionErrors.invalidTempVarReference(
+ routineName = name.asMultipart,
+ varName = varName)
+ }
+ }
+
+ /** Check if the given plan contains cyclic function references. */
+ private def checkCyclicFunctionReference(
+ catalog: SessionCatalog,
+ identifier: FunctionIdentifier,
+ plan: LogicalPlan): Unit = {
+ import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
+
+ def checkPlan(plan: LogicalPlan, path: Seq[FunctionIdentifier]): Unit = {
+ plan.foreach {
+ case u @ UnresolvedTableValuedFunction(nameParts, arguments, _) =>
+ try {
+ val funcId = nameParts.asFunctionIdentifier
+ val info = catalog.lookupFunctionInfo(funcId)
+ if (isSQLFunction(info.getClassName)) {
+ val f = withPosition(u) {
+ catalog.lookupTableFunction(funcId,
arguments).asInstanceOf[SQLTableFunction]
+ }
+ val newPath = path :+ f.function.name
+ if (f.function.name == name) {
+ throw
UserDefinedFunctionErrors.cyclicFunctionReference(newPath.mkString(" -> "))
+ }
+ val plan = catalog.makeSQLTableFunctionPlan(f.name, f.function,
f.inputs, f.output)
+ checkPlan(plan, newPath)
+ }
+ } catch {
+ case _: AnalysisException =>
+ }
+ case p: LogicalPlan =>
+ p.expressions.foreach(checkExpression(_, path))
+ }
+ }
+
+ def checkExpression(expression: Expression, path:
Seq[FunctionIdentifier]): Unit = {
+ expression.foreach {
+ case s: SubqueryExpression => checkPlan(s.plan, path)
+ case u @ UnresolvedFunction(nameParts, arguments, _, _, _, _, _) =>
+ try {
+ val funcId = nameParts.asFunctionIdentifier
+ val info = catalog.lookupFunctionInfo(funcId)
+ if (isSQLFunction(info.getClassName)) {
+ val f = withPosition(u) {
+ catalog.lookupFunction(funcId,
arguments).asInstanceOf[SQLFunctionExpression]
+ }
+ val newPath = path :+ f.function.name
+ if (f.function.name == name) {
+ throw
UserDefinedFunctionErrors.cyclicFunctionReference(newPath.mkString(" -> "))
+ }
+ val plan = catalog.makeSQLFunctionPlan(f.name, f.function,
f.inputs)
+ checkPlan(plan, newPath)
+ }
+ } catch {
+ case _: AnalysisException =>
+ }
+ case _ =>
+ }
+ }
+
+ checkPlan(plan, Seq(identifier))
+ }
+
+ /**
+ * Check if the SQL function body contains aggregate/window/generate
functions. Note subqueries
+ * inside the SQL function body can contain aggregate/window/generate
functions.
+ */
+ private def checkAggOrWindowOrGeneratorExpr(plan: LogicalPlan): Unit = {
+ if (plan.resolved) {
+ plan.transformAllExpressions {
+ case e
+ if e.isInstanceOf[WindowExpression] || e.isInstanceOf[Generator] ||
+ e.isInstanceOf[AggregateExpression] =>
+ throw new AnalysisException(
+ errorClass =
"USER_DEFINED_FUNCTIONS.CANNOT_CONTAIN_COMPLEX_FUNCTIONS",
+ messageParameters = Map("queryText" ->
s"${exprText.orElse(queryText).get}")
+ )
+ }
+ }
+ }
+
+ /**
+ * Derive the SQL data access routine of the function and check if the SQL
function matches its
+ * data access routine. If the data access is CONTAINS SQL, the expression
should not access
+ * operators and expressions that read SQL data.
+ *
+ * Returns true is SQL data access routine is READS SQL DATA, otherwise
returns false.
+ */
+ private def deriveSQLDataAccess(plan: LogicalPlan): Boolean = {
+ // Find logical plan nodes that read SQL data.
+ val readsSQLData = plan.find {
+ case _: View => true
+ case p if p.children.isEmpty =>
+ p match {
+ case _: OneRowRelation | _: LocalRelation | _: Range => false
+ case _ => true
+ }
+ case f: SQLFunctionNode => f.function.containsSQL.contains(false)
+ case p: LogicalPlan =>
+ lazy val sub = p.subqueries.exists(deriveSQLDataAccess)
+ // If the SQL function contains another SQL function that has SQL data
access routine
+ // to be READS SQL DATA, then this SQL function will also be READS SQL
DATA.
+ p.expressions.exists(
+ expr =>
+ expr.find {
+ case f: SQLScalarFunction =>
f.function.containsSQL.contains(false)
+ case sub: SubqueryExpression => deriveSQLDataAccess(sub.plan)
+ case _ => false
+ }.isDefined)
+ }.isDefined
+
+ if (containsSQL.contains(true) && readsSQLData) {
+ throw new AnalysisException(
+ errorClass = "INVALID_SQL_FUNCTION_DATA_ACCESS",
+ messageParameters = Map.empty
+ )
+ }
+
+ readsSQLData
+ }
+
+ /**
+ * Generate the function properties, including:
+ * 1. the SQL configs when creating the function.
+ * 2. the catalog and database name when creating the function. This will
be used to provide
+ * context during nested function resolution.
+ * 3. referred temporary object names if the function is a temp function.
+ */
+ private def generateFunctionProperties(
+ session: SparkSession,
+ plan: LogicalPlan,
+ analyzed: LogicalPlan): Map[String, String] = {
+ val catalog = session.sessionState.catalog
+ val conf = session.sessionState.conf
+ val manager = session.sessionState.catalogManager
+
+ val tempVars = ViewHelper.collectTemporaryVariables(analyzed)
+
+ sqlConfigsToProps(conf, SQL_CONFIG_PREFIX) ++
+ catalogAndNamespaceToProps(
+ manager.currentCatalog.name,
+ manager.currentNamespace.toIndexedSeq) ++
+ referredTempNamesToProps(Nil, Nil, tempVars)
+ }
+
+ override def simpleString(maxFields: Int): String = {
+ s"CreatePaimonSQLFunctionCommand: $name"
+ }
+}
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
index cc2c4df6a6..099f6d84e9 100644
---
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
@@ -19,8 +19,6 @@
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
@@ -29,10 +27,10 @@ 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.
+ * Parser-stage rule that rewrites a Paimon-catalog CREATE FUNCTION ... RETURN
+ * (CreateUserDefinedFunction) into CreatePaimonSQLFunctionCommand before
Spark's
+ * ResolveSessionCatalog rejects it. Only does plan rewriting; analysis,
validation, and derivation
+ * happen in CreatePaimonSQLFunctionCommand.run().
*/
case class RewritePaimonSQLFunctionCommands(spark: SparkSession) extends
Rule[LogicalPlan] {
@@ -56,7 +54,8 @@ case class RewritePaimonSQLFunctionCommands(spark:
SparkSession) extends Rule[Lo
throw new UnsupportedOperationException(
s"Paimon does not support creating SQL table functions yet:
$funcIdent")
}
- val paimonFunction = SQLFunctionConverter.toPaimonFunction(
+ CreatePaimonSQLFunctionCommand(
+ catalog,
funcIdent,
c.inputParamText,
c.returnTypeText,
@@ -65,13 +64,10 @@ case class RewritePaimonSQLFunctionCommands(spark:
SparkSession) extends Rule[Lo
c.comment,
c.isDeterministic,
c.containsSQL,
- spark.sessionState.sqlParser)
- CreatePaimonV1FunctionCommand(
- catalog,
- funcIdent,
- paimonFunction,
+ isTableFunc = false,
c.ignoreIfExists,
- c.replace)
+ c.replace
+ )
case _ => c
}
}