This is an automated email from the ASF dual-hosted git repository.
JingsongLi 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 cf2f9914cd [spark] Support REST catalog Hive UDTFs in lateral views
(#10510)
cf2f9914cd is described below
commit cf2f9914cdebf5c0062c200f82e7a3b43b9540d8
Author: Zouxxyy <[email protected]>
AuthorDate: Wed Oct 14 10:02:17 2026 +0800
[spark] Support REST catalog Hive UDTFs in lateral views (#10510)
---
docs/docs/spark/sql-functions.md | 9 +-
.../catalyst/analysis/PaimonFunctionResolver.scala | 40 +++--
.../extensions/RewritePaimonFunctionCommands.scala | 115 ++++++++++---
.../spark/sql/PaimonV1FunctionTestBase.scala | 188 ++++++++++++++++++++-
4 files changed, 310 insertions(+), 42 deletions(-)
diff --git a/docs/docs/spark/sql-functions.md b/docs/docs/spark/sql-functions.md
index f2d638d7da..357ac219ce 100644
--- a/docs/docs/spark/sql-functions.md
+++ b/docs/docs/spark/sql-functions.md
@@ -145,7 +145,7 @@ Paimon-managed function definitions require a REST catalog.
Choose a definition
| Definition | Use it for | Requirement |
| --- | --- | --- |
| [Java lambda](#lambda-function) | A small Java expression stored in the
catalog | Register metadata and a Spark definition with procedures. |
-| [JAR implementation](#file-function) | Existing Spark or Hive UDF/UDAF
classes | Spark 3.4+ and the implementation JAR. |
+| [JAR implementation](#file-function) | Spark or Hive UDF/UDAF classes, or
Hive UDTF classes | Spark 3.4+ and the implementation JAR. |
| [SQL body](#sql-function) | Reusable scalar SQL logic | Spark 4.0+. |
The examples assume the REST catalog and its `default` database are selected.
@@ -177,9 +177,12 @@ CALL sys.drop_function(`function` => 'default.area_func');
### File Function
-Register a Spark or Hive UDF/UDAF implementation packaged in a JAR.
+Register a Spark or Hive UDF/UDAF, or a Hive UDTF, packaged in a JAR.
+See [Spark
UDFs](https://spark.apache.org/docs/latest/sql-ref-functions.html#udfs-user-defined-functions).
-Currently, supports Spark or Hive implementations of UDFs and UDAFs, see
[Spark
UDFs](https://spark.apache.org/docs/latest/sql-ref-functions.html#udfs-user-defined-functions)
+Hive UDTFs can be invoked in a `SELECT` list or a `LATERAL VIEW`. In `LATERAL
VIEW`,
+use an unqualified function name or `database.function` in the current REST
catalog;
+Spark's parser does not accept `catalog.database.function` in this clause.
This feature requires Spark 3.4 or higher.
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonFunctionResolver.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonFunctionResolver.scala
index 3d4ee94876..fb9161a777 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonFunctionResolver.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/analysis/PaimonFunctionResolver.scala
@@ -25,10 +25,10 @@ import
org.apache.paimon.spark.catalog.functions.PaimonFunctions
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.analysis.UnresolvedFunction
import org.apache.spark.sql.catalyst.expressions.{Cast, Expression, Literal}
-import
org.apache.spark.sql.catalyst.parser.extensions.UnResolvedPaimonV1Function
+import
org.apache.spark.sql.catalyst.parser.extensions.{UnResolvedPaimonV1Function,
UnResolvedPaimonV1Generator}
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
import org.apache.spark.sql.catalyst.rules.Rule
-import org.apache.spark.sql.catalyst.trees.TreePattern.UNRESOLVED_FUNCTION
+import org.apache.spark.sql.catalyst.trees.TreePattern.{GENERATOR,
UNRESOLVED_FUNCTION}
import org.apache.spark.sql.connector.catalog.CatalogPlugin
import org.apache.spark.sql.types.{BinaryType, DataType, DayTimeIntervalType,
LongType, NullType, StringType}
import org.apache.spark.unsafe.types.UTF8String
@@ -40,9 +40,9 @@ case class PaimonFunctionResolver(spark: SparkSession)
extends Rule[LogicalPlan]
protected lazy val catalogManager = spark.sessionState.catalogManager
override def apply(plan: LogicalPlan): LogicalPlan =
-
plan.resolveOperatorsUpWithPruning(_.containsAnyPattern(UNRESOLVED_FUNCTION)) {
+
plan.resolveOperatorsUpWithPruning(_.containsAnyPattern(UNRESOLVED_FUNCTION,
GENERATOR)) {
case l: LogicalPlan =>
-
l.transformExpressionsWithPruning(_.containsAnyPattern(UNRESOLVED_FUNCTION)) {
+
l.transformExpressionsWithPruning(_.containsAnyPattern(UNRESOLVED_FUNCTION,
GENERATOR)) {
case u: UnresolvedFunction
if isDescriptorToPresignedUrlFunction(u.nameParts) &&
u.arguments.forall(_.resolved) =>
@@ -51,20 +51,30 @@ case class PaimonFunctionResolver(spark: SparkSession)
extends Rule[LogicalPlan]
if isBlobViewFunction(u.nameParts) &&
u.arguments.forall(_.resolved) =>
resolveBlobView(u)
case u: UnResolvedPaimonV1Function if u.arguments.forall(_.resolved)
=>
- u.funcIdent.catalog match {
- case Some(catalog) =>
- catalogManager.catalog(catalog) match {
- case v1FunctionCatalog: SupportV1Function =>
- v1FunctionCatalog.registerAndResolveV1Function(u)
- case _ =>
- throw new IllegalArgumentException(
- s"Catalog $catalog is not a v1 function catalog")
- }
- case None => throw new IllegalArgumentException("Catalog name is
not defined")
- }
+ resolveV1Function(u)
+ case u: UnResolvedPaimonV1Generator if u.childrenResolved =>
+ val function = UnResolvedPaimonV1Function(
+ u.funcIdent,
+ u.arguments,
+ isDistinct = false,
+ func = u.func)
+ u.resolve(resolveV1Function(function))
}
}
+ private def resolveV1Function(u: UnResolvedPaimonV1Function): Expression = {
+ u.funcIdent.catalog match {
+ case Some(catalog) =>
+ catalogManager.catalog(catalog) match {
+ case v1FunctionCatalog: SupportV1Function =>
+ v1FunctionCatalog.registerAndResolveV1Function(u)
+ case _ =>
+ throw new IllegalArgumentException(s"Catalog $catalog is not a v1
function catalog")
+ }
+ case None => throw new IllegalArgumentException("Catalog name is not
defined")
+ }
+ }
+
private def resolveBlobView(u: UnresolvedFunction): Expression = {
if (u.arguments.length != 3) {
throw new UnsupportedOperationException(
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 6be680cd60..43f9a49d92 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
@@ -25,16 +25,18 @@ import
org.apache.paimon.spark.execution.{CreatePaimonV1FunctionCommand, Describ
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}
+import org.apache.spark.sql.catalyst.{FunctionIdentifier, InternalRow}
+import org.apache.spark.sql.catalyst.analysis.{UnresolvedException,
UnresolvedFunction, UnresolvedGenerator}
import org.apache.spark.sql.catalyst.catalog.CatalogFunction
-import org.apache.spark.sql.catalyst.expressions.{Expression, Unevaluable}
+import org.apache.spark.sql.catalyst.expressions.{Expression, Generator,
SubqueryExpression, Unevaluable}
+import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext,
ExprCode}
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.catalyst.trees.TreePattern.{GENERATOR,
PLAN_EXPRESSION, TreePattern, UNRESOLVED_FUNCTION}
import org.apache.spark.sql.connector.catalog.CatalogManager
+import org.apache.spark.sql.errors.{QueryCompilationErrors,
QueryExecutionErrors}
import org.apache.spark.sql.paimon.shims.SparkShimLoader
-import org.apache.spark.sql.types.DataType
+import org.apache.spark.sql.types.{DataType, StructType}
case class RewritePaimonFunctionCommands(spark: SparkSession) extends
Rule[LogicalPlan] {
@@ -123,28 +125,95 @@ case class RewritePaimonFunctionCommands(spark:
SparkSession) extends Rule[Logic
u,
alias =>
transformPaimonV1Function(alias).asInstanceOf[SubqueryAlias])
case l: LogicalPlan =>
-
l.transformExpressionsWithPruning(_.containsAnyPattern(UNRESOLVED_FUNCTION)) {
+ l.transformExpressionsWithPruning(
+ _.containsAnyPattern(UNRESOLVED_FUNCTION, GENERATOR,
PLAN_EXPRESSION)) {
+ case subquery: SubqueryExpression =>
+ // Spark's recursive subquery analyzer does not rerun this parser
rewrite.
+ subquery.withNewPlan(transformPaimonV1Function(subquery.plan))
case u: UnresolvedFunction =>
- lookup.CatalogAndFunctionIdentifier.unapply(u.nameParts) match {
- case Some((v1FunctionCatalog: SupportV1Function, funcIdent,
false))
- // For Paimon built-in functions, Spark will resolve them by
itself.
- 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 (PaimonFunctionLookup.isPaimonV1Function(function)) {
- UnResolvedPaimonV1Function(funcIdent, u, Some(function))
- } else {
- u
- }
- }
- case _ => u
- }
+ lookupFunction(u.nameParts)
+ .map {
+ case (funcIdent, function) =>
UnResolvedPaimonV1Function(funcIdent, u, function)
+ }
+ .getOrElse(u)
+ case u @ UnresolvedGenerator(name, arguments) =>
+ // Spark parses a two-part generator name as database.function.
Qualify it before
+ // catalog lookup so a database with the same name as a catalog is
not reinterpreted.
+ val nameParts = name.database
+ .map(
+ database =>
+ Seq(
+
name.catalog.getOrElse(catalogManager.currentCatalog.name()),
+ database,
+ name.funcName))
+ .getOrElse(name.nameParts)
+ lookupFunction(nameParts)
+ .map {
+ case (funcIdent, function) =>
+ UnResolvedPaimonV1Generator(funcIdent, arguments, function)
+ }
+ .getOrElse(u)
}
}
}
+
+ private def lookupFunction(
+ nameParts: Seq[String]): Option[(FunctionIdentifier,
Option[PaimonFunction])] = {
+ lookup.CatalogAndFunctionIdentifier.unapply(nameParts) match {
+ case Some((v1FunctionCatalog: SupportV1Function, funcIdent, false))
+ // Spark resolves built-in functions itself.
+ if !lookup.isPaimonBuiltInFunction(funcIdent) =>
+ // Avoid redundant catalog lookups for functions already loaded into
the registry.
+ if (v1FunctionCatalog.v1FunctionRegistered(funcIdent)) {
+ Some((funcIdent, None))
+ } else {
+ val function = v1FunctionCatalog.getFunction(funcIdent)
+ if (PaimonFunctionLookup.isPaimonV1Function(function)) {
+ Some((funcIdent, Some(function)))
+ } else {
+ None
+ }
+ }
+ case _ => None
+ }
+ }
+}
+
+/** Keeps a REST catalog generator unresolved until Spark resolves its
arguments. */
+case class UnResolvedPaimonV1Generator(
+ funcIdent: FunctionIdentifier,
+ arguments: Seq[Expression],
+ func: Option[PaimonFunction])
+ extends Generator {
+
+ override def children: Seq[Expression] = arguments
+
+ override def elementSchema: StructType = throw new
UnresolvedException("elementSchema")
+ override def dataType: DataType = throw new UnresolvedException("dataType")
+ override def foldable: Boolean = throw new UnresolvedException("foldable")
+ override def nullable: Boolean = throw new UnresolvedException("nullable")
+ override lazy val resolved = false
+
+ override def prettyName: String = funcIdent.unquotedString
+
+ def resolve(function: Expression): Generator = function match {
+ case generator: Generator => generator
+ case other =>
+ throw QueryCompilationErrors.generatorNotExpectedError(
+ funcIdent,
+ other.getClass.getCanonicalName)
+ }
+
+ // Nothing is compatible with both Scala 2.12's TraversableOnce and 2.13's
IterableOnce.
+ override def eval(input: InternalRow): Nothing =
+ throw QueryExecutionErrors.cannotEvaluateExpressionError(this)
+
+ override protected def doGenCode(ctx: CodegenContext, ev: ExprCode):
ExprCode =
+ throw QueryExecutionErrors.cannotGenerateCodeForExpressionError(this)
+
+ override protected def withNewChildrenInternal(
+ newChildren: IndexedSeq[Expression]): UnResolvedPaimonV1Generator =
+ copy(arguments = newChildren)
}
/** An unresolved Paimon V1 function to let Spark resolve the necessary
variables. */
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonV1FunctionTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonV1FunctionTestBase.scala
index 367327ed47..c0144b9e3f 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonV1FunctionTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonV1FunctionTestBase.scala
@@ -22,12 +22,198 @@ import
org.apache.paimon.spark.PaimonSparkTestWithRestCatalogBase
import org.apache.paimon.spark.function.FunctionResources._
import org.apache.spark.SparkConf
-import org.apache.spark.sql.Row
+import org.apache.spark.sql.{AnalysisException, Row}
+import org.apache.spark.sql.catalyst.expressions.Explode
import java.io.File
abstract class PaimonV1FunctionTestBase extends
PaimonSparkTestWithRestCatalogBase {
+ private val hivePosExplode =
"org.apache.hadoop.hive.ql.udf.generic.GenericUDTFPosExplode"
+
+ test("Paimon V1 Function: persistent Hive UDTF in lateral view") {
+ withUserDefinedFunction("udtf_pos_explode" -> false) {
+ sql(s"CREATE FUNCTION udtf_pos_explode AS '$hivePosExplode'")
+ withTable("udtf_input") {
+ sql("CREATE TABLE udtf_input (id INT, items ARRAY<INT>) USING paimon")
+ sql("INSERT INTO udtf_input VALUES (1, array(10, 20)), (2, array()),
(3, NULL)")
+
+ // Resolve a cold REST catalog function before any SELECT invocation
populates its registry.
+ Seq("udtf_pos_explode", "test.udtf_pos_explode").foreach {
+ name =>
+ checkAnswer(
+ sql(s"""SELECT id, position, item FROM udtf_input
+ |LATERAL VIEW $name(items) u AS position, item
+ |""".stripMargin),
+ Seq(Row(1, 0, 10), Row(1, 1, 20))
+ )
+ }
+
+ checkAnswer(
+ sql("""SELECT id, position, item FROM udtf_input
+ |LATERAL VIEW OUTER udtf_pos_explode(items) u AS position, item
+ |""".stripMargin),
+ Seq(Row(1, 0, 10), Row(1, 1, 20), Row(2, null, null), Row(3, null,
null))
+ )
+ checkAnswer(
+ sql("SELECT id, paimon.test.udtf_pos_explode(items) FROM
udtf_input"),
+ Seq(Row(1, 0, 10), Row(1, 1, 20)))
+
+ val session = spark.newSession()
+ session.sql("USE paimon.test")
+ checkAnswer(
+ session.sql("""SELECT id, position, item FROM udtf_input
+ |LATERAL VIEW udtf_pos_explode(items) u AS position,
item
+ |""".stripMargin),
+ Seq(Row(1, 0, 10), Row(1, 1, 20))
+ )
+ }
+ }
+ }
+
+ test("Paimon V1 Function: lateral view database name can match a catalog") {
+ withDatabase("paimon.paimon") {
+ sql("CREATE DATABASE paimon.paimon")
+ withUserDefinedFunction("paimon.paimon.udtf_pos_explode" -> false) {
+ sql(s"CREATE FUNCTION paimon.paimon.udtf_pos_explode AS
'$hivePosExplode'")
+ checkAnswer(
+ sql("""SELECT position, item FROM VALUES (array(10, 20)) AS t(items)
+ |LATERAL VIEW paimon.udtf_pos_explode(items) u AS position,
item
+ |""".stripMargin),
+ Seq(Row(0, 10), Row(1, 20))
+ )
+ }
+ }
+ }
+
+ test("Paimon V1 Function: Hive UDTF in CTE and dependent lateral views") {
+ withUserDefinedFunction("udtf_pos_explode" -> false) {
+ sql(s"CREATE FUNCTION udtf_pos_explode AS '$hivePosExplode'")
+ checkAnswer(
+ sql("""WITH expanded AS (
+ | SELECT id, item FROM VALUES (1, array(10, 20)) AS t(id, items)
+ | LATERAL VIEW udtf_pos_explode(items) u AS position, item
+ |)
+ |SELECT * FROM expanded
+ |""".stripMargin),
+ Seq(Row(1, 10), Row(1, 20))
+ )
+ checkAnswer(
+ sql("""SELECT id, a.item, b.item FROM VALUES (1, array(10, 20)) AS
t(id, items)
+ |LATERAL VIEW udtf_pos_explode(items) a AS position, item
+ |LATERAL VIEW udtf_pos_explode(array(a.item, a.item + 1)) b AS
position, item
+ |""".stripMargin),
+ Seq(Row(1, 10, 10), Row(1, 10, 11), Row(1, 20, 20), Row(1, 20, 21))
+ )
+ }
+ }
+
+ test("Paimon V1 Function: Hive UDTF in scalar subquery") {
+ withUserDefinedFunction("udtf_pos_explode" -> false) {
+ sql(s"CREATE FUNCTION udtf_pos_explode AS '$hivePosExplode'")
+ Seq("posexplode", "udtf_pos_explode").foreach {
+ name =>
+ checkAnswer(
+ sql(s"""SELECT (
+ | SELECT count(*) FROM VALUES (array(10, 20)) AS t(items)
+ | LATERAL VIEW $name(items) u AS position, item
+ |) AS n
+ |""".stripMargin),
+ Seq(Row(2L))
+ )
+ }
+ }
+ }
+
+ test("Paimon V1 Function: Hive UDTF in EXISTS subquery") {
+ withUserDefinedFunction("udtf_pos_explode" -> false) {
+ sql(s"CREATE FUNCTION udtf_pos_explode AS '$hivePosExplode'")
+ Seq(10 -> true, 30 -> false).foreach {
+ case (item, expected) =>
+ checkAnswer(
+ sql(s"""SELECT EXISTS (
+ | SELECT 1 FROM VALUES (array(10, 20)) AS t(items)
+ | LATERAL VIEW test.udtf_pos_explode(items) u AS position,
item
+ | WHERE u.item = $item
+ |)
+ |""".stripMargin),
+ Seq(Row(expected))
+ )
+ }
+ checkAnswer(
+ sql("""SELECT id FROM VALUES (10), (30) AS outer_t(id)
+ |WHERE EXISTS (
+ | SELECT 1 FROM VALUES (array(10, 20)) AS t(items)
+ | LATERAL VIEW udtf_pos_explode(items) u AS position, item
+ | WHERE u.item = outer_t.id
+ |)
+ |""".stripMargin),
+ Seq(Row(10))
+ )
+ }
+ }
+
+ test("Paimon V1 Function: Hive UDTF in nested expression subqueries and
CTEs") {
+ withUserDefinedFunction("udtf_pos_explode" -> false) {
+ sql(s"CREATE FUNCTION udtf_pos_explode AS '$hivePosExplode'")
+ checkAnswer(
+ sql("""SELECT (
+ | WITH expanded AS (
+ | SELECT (
+ | SELECT count(*) FROM VALUES (array(10, 20)) AS t(items)
+ | LATERAL VIEW udtf_pos_explode(items) u AS position, item
+ | ) AS n
+ | )
+ | SELECT n FROM expanded
+ |) AS n
+ |""".stripMargin),
+ Seq(Row(2L))
+ )
+ }
+ }
+
+ test("Paimon V1 Function: lateral view preserves built-in and temporary
function lookup") {
+ withUserDefinedFunction("udtf_pos_explode" -> false) {
+ sql(s"CREATE FUNCTION udtf_pos_explode AS '$hivePosExplode'")
+ withUserDefinedFunction("udtf_pos_explode" -> true) {
+ spark.sessionState.functionRegistry.createOrReplaceTempFunction(
+ "udtf_pos_explode",
+ children => Explode(children.head),
+ "scala_udf")
+ checkAnswer(
+ sql("""SELECT item FROM VALUES (array(10, 20)) AS t(items)
+ |LATERAL VIEW udtf_pos_explode(items) u AS item
+ |""".stripMargin),
+ Seq(Row(10), Row(20))
+ )
+ checkAnswer(
+ sql("""SELECT position, item FROM VALUES (array(10, 20)) AS t(items)
+ |LATERAL VIEW test.udtf_pos_explode(items) u AS position, item
+ |""".stripMargin),
+ Seq(Row(0, 10), Row(1, 20))
+ )
+ checkAnswer(
+ sql("""SELECT position, item FROM VALUES (array(10, 20)) AS t(items)
+ |LATERAL VIEW posexplode(items) u AS position, item
+ |""".stripMargin),
+ Seq(Row(0, 10), Row(1, 20))
+ )
+ }
+ }
+ }
+
+ test("Paimon V1 Function: reject a scalar function in lateral view") {
+ withUserDefinedFunction("udf_add2" -> false) {
+ sql(s"CREATE FUNCTION udf_add2 AS '$UDFExampleAdd2Class' USING JAR
'$testUDFJarPath'")
+ val error = intercept[AnalysisException] {
+ sql("""SELECT item FROM VALUES (1, 2) AS t(a, b)
+ |LATERAL VIEW udf_add2(a, b) u AS item
+ |""".stripMargin)
+ }
+ assert(error.getErrorClass == "UNSUPPORTED_GENERATOR.NOT_GENERATOR")
+ }
+ }
+
test("Paimon V1 Function: create or replace function") {
withUserDefinedFunction("udf_add2" -> false) {
sql(s"""