andygrove commented on code in PR #7253:
URL: https://github.com/apache/datafusion-comet/pull/7253#discussion_r4456345830


##########
native/operators/src/explode.rs:
##########
@@ -455,13 +493,59 @@ impl ExplodeStream {
                     if other.is_none() {
                         let input_schema = self.input.schema();
                         self.input = 
Box::pin(EmptyRecordBatchStream::new(input_schema));
+                        if let Some(terminate) = self.terminate.take() {
+                            let elapsed_compute = 
self.baseline_metrics.elapsed_compute().clone();
+                            let timer = elapsed_compute.timer();
+                            let result = self.terminate_batch(&terminate);
+                            timer.done();
+                            if let Some(batch) = result.transpose() {
+                                if let Ok(batch) = &batch {
+                                    
batch.record_output(&self.baseline_metrics);
+                                }
+                                return Poll::Ready(Some(batch));
+                            }
+                        }
                     }
                     return Poll::Ready(other);
                 }
             }
         }
     }
 
+    /// Unnest the rows `terminate` yields in place of the last input row's 
list. With no input
+    /// rows, the other columns are null. Spark's `outer` handling does not 
apply to these rows, so
+    /// an empty result adds nothing.
+    fn terminate_batch(
+        &mut self,
+        terminate: &Arc<dyn PhysicalExpr>,
+    ) -> Result<Option<RecordBatch>> {
+        let options = RecordBatchOptions::new().with_row_count(Some(1));
+        let no_columns =
+            RecordBatch::try_new_with_options(Arc::new(Schema::empty()), 
vec![], &options)?;
+        let rows = terminate.evaluate(&no_columns)?.into_array(1)?;
+
+        let input_schema = self.input.schema();
+        let mut columns = match self.last_row.take() {
+            Some(row) => row.columns().to_vec(),
+            None => input_schema

Review Comment:
   Fixed in 4c335a8a5. `terminate_batch` now returns early when `terminate` 
yields a NULL or empty list, before it builds the placeholder row. When it does 
yield rows over an empty input, the planner marks the carried columns nullable 
(only when `terminate` is set) and the placeholder batch uses a nullable copy 
of the input schema, so the null row is valid. Tests: two Rust cases over a 
non-nullable `id` (empty `terminate` adds nothing, non-empty emits a null 
`id`), and `terminate with no rows over a non-nullable carried column adds 
nothing` in `CometGenerateExecSuite`. I confirmed that Scala test fails without 
the native change with `Column 'col_0' is declared as non-nullable but contains 
null values`.



##########
spark/src/main/scala/org/apache/spark/sql/comet/CometGeneratorUDF.scala:
##########
@@ -0,0 +1,255 @@
+/*
+ * 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.comet
+
+import java.nio.ByteBuffer
+import java.util.concurrent.atomic.AtomicLong
+
+import scala.collection.mutable
+import scala.collection.mutable.ArrayBuffer
+import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
+
+import org.apache.arrow.vector.{BitVector, ValueVector, VarBinaryVector}
+import org.apache.arrow.vector.types.pojo.Field
+import org.apache.spark.SparkEnv
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference, AttributeSeq, BindReferences, Expression, Generator, 
GenericInternalRow, Literal, Nondeterministic, Unevaluable}
+import org.apache.spark.sql.catalyst.util.GenericArrayData
+import org.apache.spark.sql.comet.execution.arrow.ArrowWriter
+import org.apache.spark.sql.execution.ExecSubqueryExpression
+import org.apache.spark.sql.types.ArrayType
+import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
+
+import org.apache.comet.CometConf
+import org.apache.comet.codegen.CometBatchKernelCodegen
+import org.apache.comet.serde.{CometInvokeTargets, ExprOuterClass}
+import org.apache.comet.serde.CometScalaUDF.closureSerialize
+import org.apache.comet.serde.QueryPlanSerde.{exprToProto, serializeDataType}
+import org.apache.comet.udf.{CometUDF, NativeUdfCall}
+import org.apache.comet.udf.codegen.CometScalaUDFCodegen
+import org.apache.comet.vector.CometVector
+
+/**
+ * Runs a Spark [[Generator]] for Comet's native generate operator. For each 
input row it returns
+ * the rows the generator emits as one `array<struct>` element, which the 
native operator unnests
+ * and flattens the way it does for `inline`. An input row that emits nothing 
gets an empty array,
+ * so `outer` keeps it as one row of nulls.
+ *
+ * Arg 0 is the digest of the closure-serialized bound generator, arg 1 the 
serialized bytes, and
+ * arg 2 a boolean scalar that is true for the call the native operator makes 
once its input is
+ * exhausted. That call returns one element holding the rows of 
`Generator.terminate`. Args 3..N
+ * are the columns the bound generator reads, in ordinal order.
+ *
+ * A generator can keep state across rows and emit rows from `terminate`, as a 
Hive UDTF does, so
+ * every native plan gets its own instance: the key is the digest and the 
plan, and `releasePlan`
+ * drops the plan's instances. The serde ships the bound generator rather than 
evaluating it on
+ * the driver, so this works in cluster mode without driver-side state. 
`evaluate` is synchronized
+ * for the reason `CometScalaUDFCodegen.evaluate` is.
+ */
+class CometGeneratorUDF extends CometUDF {
+  import CometGeneratorUDF._
+
+  private val generators = mutable.HashMap.empty[(ByteBuffer, Long), 
BoundGenerator]
+
+  override def evaluate(inputs: Array[ValueVector], numRows: Int): ValueVector 
=
+    evaluate(inputs, numRows, partitionIndex = 0, planId = NoPlan)
+
+  override def evaluate(
+      inputs: Array[ValueVector],
+      numRows: Int,
+      partitionIndex: Int,
+      planId: Long): ValueVector = this.synchronized {
+    require(
+      inputs.length >= 3,
+      "CometGeneratorUDF requires the generator digest, its serialized bytes 
and the terminate " +
+        s"flag, got ${inputs.length} inputs")
+    val digest = ByteBuffer.wrap(binaryScalar(inputs(0)))
+    val bound = generators.getOrElseUpdate(
+      (digest, planId),
+      BoundGenerator(deserialize(binaryScalar(inputs(1))), partitionIndex))
+
+    val output = CometBatchKernelCodegen.allocateOutput(bound.outputField, 
numRows, 0)
+    try {
+      val writer = ArrowWriter.createFieldWriter(output)
+      val outputRow = new GenericInternalRow(1)
+      def writeRows(rows: Any): Unit = {
+        val copies = new ArrayBuffer[Any]
+        iteratorOf(rows).foreach(row => copies += row.copy())

Review Comment:
   Fixed in 4c335a8a5. Each row is now snapshotted with an `UnsafeProjection` 
over `elementSchema` and `.copy()`, so the `UnsafeRow` owns its bytes. Added 
`ReusedBufferGenerator` (`CometTestGenerators`), which returns one 
`GenericInternalRow` over one byte array it overwrites between rows. `generator 
rows that share a reused buffer are kept apart` compares against Spark, for 
both the plain and `LATERAL VIEW OUTER` forms.



##########
spark/src/main/scala/org/apache/spark/sql/comet/CometGeneratorUDF.scala:
##########
@@ -0,0 +1,255 @@
+/*
+ * 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.comet
+
+import java.nio.ByteBuffer
+import java.util.concurrent.atomic.AtomicLong
+
+import scala.collection.mutable
+import scala.collection.mutable.ArrayBuffer
+import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
+
+import org.apache.arrow.vector.{BitVector, ValueVector, VarBinaryVector}
+import org.apache.arrow.vector.types.pojo.Field
+import org.apache.spark.SparkEnv
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference, AttributeSeq, BindReferences, Expression, Generator, 
GenericInternalRow, Literal, Nondeterministic, Unevaluable}
+import org.apache.spark.sql.catalyst.util.GenericArrayData
+import org.apache.spark.sql.comet.execution.arrow.ArrowWriter
+import org.apache.spark.sql.execution.ExecSubqueryExpression
+import org.apache.spark.sql.types.ArrayType
+import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
+
+import org.apache.comet.CometConf
+import org.apache.comet.codegen.CometBatchKernelCodegen
+import org.apache.comet.serde.{CometInvokeTargets, ExprOuterClass}
+import org.apache.comet.serde.CometScalaUDF.closureSerialize
+import org.apache.comet.serde.QueryPlanSerde.{exprToProto, serializeDataType}
+import org.apache.comet.udf.{CometUDF, NativeUdfCall}
+import org.apache.comet.udf.codegen.CometScalaUDFCodegen
+import org.apache.comet.vector.CometVector
+
+/**
+ * Runs a Spark [[Generator]] for Comet's native generate operator. For each 
input row it returns
+ * the rows the generator emits as one `array<struct>` element, which the 
native operator unnests
+ * and flattens the way it does for `inline`. An input row that emits nothing 
gets an empty array,
+ * so `outer` keeps it as one row of nulls.
+ *
+ * Arg 0 is the digest of the closure-serialized bound generator, arg 1 the 
serialized bytes, and
+ * arg 2 a boolean scalar that is true for the call the native operator makes 
once its input is
+ * exhausted. That call returns one element holding the rows of 
`Generator.terminate`. Args 3..N
+ * are the columns the bound generator reads, in ordinal order.
+ *
+ * A generator can keep state across rows and emit rows from `terminate`, as a 
Hive UDTF does, so
+ * every native plan gets its own instance: the key is the digest and the 
plan, and `releasePlan`
+ * drops the plan's instances. The serde ships the bound generator rather than 
evaluating it on
+ * the driver, so this works in cluster mode without driver-side state. 
`evaluate` is synchronized
+ * for the reason `CometScalaUDFCodegen.evaluate` is.
+ */
+class CometGeneratorUDF extends CometUDF {
+  import CometGeneratorUDF._
+
+  private val generators = mutable.HashMap.empty[(ByteBuffer, Long), 
BoundGenerator]
+
+  override def evaluate(inputs: Array[ValueVector], numRows: Int): ValueVector 
=
+    evaluate(inputs, numRows, partitionIndex = 0, planId = NoPlan)
+
+  override def evaluate(
+      inputs: Array[ValueVector],
+      numRows: Int,
+      partitionIndex: Int,
+      planId: Long): ValueVector = this.synchronized {
+    require(
+      inputs.length >= 3,
+      "CometGeneratorUDF requires the generator digest, its serialized bytes 
and the terminate " +
+        s"flag, got ${inputs.length} inputs")
+    val digest = ByteBuffer.wrap(binaryScalar(inputs(0)))
+    val bound = generators.getOrElseUpdate(
+      (digest, planId),
+      BoundGenerator(deserialize(binaryScalar(inputs(1))), partitionIndex))
+
+    val output = CometBatchKernelCodegen.allocateOutput(bound.outputField, 
numRows, 0)
+    try {
+      val writer = ArrowWriter.createFieldWriter(output)
+      val outputRow = new GenericInternalRow(1)
+      def writeRows(rows: Any): Unit = {
+        val copies = new ArrayBuffer[Any]
+        iteratorOf(rows).foreach(row => copies += row.copy())
+        outputRow.update(0, new GenericArrayData(copies.toArray))
+        writer.write(outputRow, 0)

Review Comment:
   Not changed in 4c335a8a5. The bridge cannot be lazy per row: the native 
operator hands the UDF a whole batch, so every row of the batch is evaluated, 
and the rows one input row generates are held together, before anything is 
returned. I documented this in the Generators section of `scala_java_udfs.md`, 
and `spark.comet.exec.jvmGenerator.enabled=false` keeps such generators on 
Spark. A regression test is not possible while the behavior stays. Whether the 
default should be off, or custom generators and Hive UDTFs should stay on 
Spark, is still open. I would like your view, since it decides whether this PR 
keeps its main benefit. `ReplicateRows` no longer goes through this path (see 
the thread on `operators.scala`).



##########
spark/src/test/scala/org/apache/comet/exec/CometGenerateExecSuite.scala:
##########
@@ -722,4 +737,98 @@ class CometGenerateExecSuite extends CometTestBase {
     }
   }
 
+  /** Run `f` over a single-file Parquet table `t` of `(id, s, js)` rows. */
+  private def withGeneratorTable(f: => Unit): Unit = {
+    val rows = Seq(
+      (1, "a", """{"a": 1, "b": "x"}"""),
+      (2, null, """{"a": null}"""),
+      (3, "c", "not json"),
+      (4, "d", null))
+    withTempPath { path =>
+      rows.toDF("id", "s", 
"js").repartition(1).write.parquet(path.getAbsolutePath)
+      withParquetTable(path.getAbsolutePath, "t")(f)
+    }
+  }
+
+  /** Check `df` against Spark, and that its generator ran in Comet's native 
operator. */
+  private def checkSparkAnswerWithNativeGenerate(df: DataFrame): Unit = {
+    val (_, cometPlan) = checkSparkAnswer(df)
+    assert(
+      collectFirst(cometPlan) { case generate: CometExplodeExec => generate 
}.isDefined,
+      s"Expected a native generator in\n$cometPlan")
+  }
+
+  test("Hive UDTF runs natively and emits its terminate rows") {

Review Comment:
   Done in 4c335a8a5. `Hive UDTF runs natively and emits its terminate rows` 
now runs with `COMET_BATCH_SIZE=2` over its four rows, so the running count and 
the `close` total cross a batch boundary. The per-partition test uses a batch 
size of 1.



##########
spark/src/main/scala/org/apache/spark/sql/comet/CometGeneratorUDF.scala:
##########
@@ -0,0 +1,255 @@
+/*
+ * 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.comet
+
+import java.nio.ByteBuffer
+import java.util.concurrent.atomic.AtomicLong
+
+import scala.collection.mutable
+import scala.collection.mutable.ArrayBuffer
+import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
+
+import org.apache.arrow.vector.{BitVector, ValueVector, VarBinaryVector}
+import org.apache.arrow.vector.types.pojo.Field
+import org.apache.spark.SparkEnv
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference, AttributeSeq, BindReferences, Expression, Generator, 
GenericInternalRow, Literal, Nondeterministic, Unevaluable}
+import org.apache.spark.sql.catalyst.util.GenericArrayData
+import org.apache.spark.sql.comet.execution.arrow.ArrowWriter
+import org.apache.spark.sql.execution.ExecSubqueryExpression
+import org.apache.spark.sql.types.ArrayType
+import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
+
+import org.apache.comet.CometConf
+import org.apache.comet.codegen.CometBatchKernelCodegen
+import org.apache.comet.serde.{CometInvokeTargets, ExprOuterClass}
+import org.apache.comet.serde.CometScalaUDF.closureSerialize
+import org.apache.comet.serde.QueryPlanSerde.{exprToProto, serializeDataType}
+import org.apache.comet.udf.{CometUDF, NativeUdfCall}
+import org.apache.comet.udf.codegen.CometScalaUDFCodegen
+import org.apache.comet.vector.CometVector
+
+/**
+ * Runs a Spark [[Generator]] for Comet's native generate operator. For each 
input row it returns
+ * the rows the generator emits as one `array<struct>` element, which the 
native operator unnests
+ * and flattens the way it does for `inline`. An input row that emits nothing 
gets an empty array,
+ * so `outer` keeps it as one row of nulls.
+ *
+ * Arg 0 is the digest of the closure-serialized bound generator, arg 1 the 
serialized bytes, and
+ * arg 2 a boolean scalar that is true for the call the native operator makes 
once its input is
+ * exhausted. That call returns one element holding the rows of 
`Generator.terminate`. Args 3..N
+ * are the columns the bound generator reads, in ordinal order.
+ *
+ * A generator can keep state across rows and emit rows from `terminate`, as a 
Hive UDTF does, so
+ * every native plan gets its own instance: the key is the digest and the 
plan, and `releasePlan`
+ * drops the plan's instances. The serde ships the bound generator rather than 
evaluating it on
+ * the driver, so this works in cluster mode without driver-side state. 
`evaluate` is synchronized
+ * for the reason `CometScalaUDFCodegen.evaluate` is.
+ */
+class CometGeneratorUDF extends CometUDF {
+  import CometGeneratorUDF._
+
+  private val generators = mutable.HashMap.empty[(ByteBuffer, Long), 
BoundGenerator]
+
+  override def evaluate(inputs: Array[ValueVector], numRows: Int): ValueVector 
=
+    evaluate(inputs, numRows, partitionIndex = 0, planId = NoPlan)
+
+  override def evaluate(
+      inputs: Array[ValueVector],
+      numRows: Int,
+      partitionIndex: Int,
+      planId: Long): ValueVector = this.synchronized {
+    require(
+      inputs.length >= 3,
+      "CometGeneratorUDF requires the generator digest, its serialized bytes 
and the terminate " +
+        s"flag, got ${inputs.length} inputs")
+    val digest = ByteBuffer.wrap(binaryScalar(inputs(0)))
+    val bound = generators.getOrElseUpdate(
+      (digest, planId),
+      BoundGenerator(deserialize(binaryScalar(inputs(1))), partitionIndex))
+
+    val output = CometBatchKernelCodegen.allocateOutput(bound.outputField, 
numRows, 0)
+    try {
+      val writer = ArrowWriter.createFieldWriter(output)
+      val outputRow = new GenericInternalRow(1)
+      def writeRows(rows: Any): Unit = {
+        val copies = new ArrayBuffer[Any]
+        iteratorOf(rows).foreach(row => copies += row.copy())
+        outputRow.update(0, new GenericArrayData(copies.toArray))
+        writer.write(outputRow, 0)
+      }
+
+      if (inputs(2).asInstanceOf[BitVector].get(0) == 1) {
+        writeRows(bound.generator.terminate())
+      } else {
+        val columns: Array[ColumnVector] =
+          inputs.drop(3).map(v => CometVector.getVector(v, null): ColumnVector)
+        val batch = new ColumnarBatch(columns, numRows)
+        var i = 0
+        while (i < numRows) {
+          writeRows(bound.generator.eval(batch.getRow(i)))
+          i += 1
+        }
+      }
+      writer.finish()
+      output
+    } catch {
+      case t: Throwable =>
+        output.close()
+        throw t
+    }
+  }
+
+  override def releasePlan(planId: Long): Unit = this.synchronized {
+    generators.keys.filter(_._2 == planId).toList.foreach(generators.remove)
+  }
+
+  private def deserialize(bytes: Array[Byte]): Generator = {
+    val loader = Option(Thread.currentThread().getContextClassLoader)
+      .getOrElse(classOf[Generator].getClassLoader)
+    SparkEnv.get.closureSerializer
+      .newInstance()
+      .deserialize[Generator](ByteBuffer.wrap(bytes), loader)
+  }
+}
+
+object CometGeneratorUDF {
+
+  /** The plan id of calls from outside a native plan, such as unit tests. */
+  private val NoPlan = -1L
+
+  /** Numbers each generator the serde ships; see [[calls]]. */
+  private val nextOccurrence = new AtomicLong()
+
+  /** Why `generator` cannot run through [[CometGeneratorUDF]], or `None` when 
it can. */
+  def declineReason(generator: Generator): Option[String] = {
+    val name = generator.nodeName
+    if (!CometConf.COMET_EXEC_JVM_GENERATOR_ENABLED.get()) {
+      return Some(
+        s"Unsupported generator: $name. Set 
${CometConf.COMET_EXEC_JVM_GENERATOR_ENABLED.key}" +
+          "=true to run it on the JVM inside Comet")
+    }
+    // The generator is serialized at plan time, before any subquery in it has 
run, and an
+    // unevaluable or native-only node has nothing to run on the JVM. 
Attributes are unevaluable
+    // only until they are bound, which `calls` does.
+    generator
+      .find {
+        case _: Attribute => false
+        case _: ExecSubqueryExpression | _: Unevaluable | _: NativeUdfCall => 
true
+        case _ => false
+      }
+      .map(node => s"$name: ${node.nodeName} cannot run inside a JVM 
generator")
+      .orElse(CometInvokeTargets.declineReason(generator))
+      .orElse(Some(s"$name: unsupported output type 
${outputType(generator).catalogString}")
+        .filter(_ => serializeDataType(outputType(generator)).isEmpty))
+  }
+
+  /**
+   * The `JvmScalarUdf` calls that run `generator` through 
[[CometGeneratorUDF]]: one evaluated
+   * for each input batch, and the one the native operator makes once its 
input is exhausted.
+   * Fails with the reason when `generator` cannot be shipped.
+   */
+  def calls(
+      generator: Generator,
+      inputs: Seq[Attribute]): Either[String, (ExprOuterClass.Expr, 
ExprOuterClass.Expr)] = {
+    val name = generator.nodeName
+    // Bind against only the attributes the generator reads, which become the 
data args.
+    val attrs = generator.collect { case a: AttributeReference => a }.distinct
+    val bound = BindReferences.bindReference(generator, AttributeSeq(attrs))
+    val bytes =
+      try {
+        closureSerialize(bound)
+      } catch {
+        case NonFatal(e) =>
+          return Left(
+            s"$name could not be closure-serialized 
(${e.getClass.getSimpleName}: " +
+              s"${e.getMessage})")
+      }
+    // The digest also covers a number unique to this generator, so two 
identical generators in
+    // one plan get their own instances and state.
+    val occurrence = 
ByteBuffer.allocate(8).putLong(nextOccurrence.getAndIncrement()).array()
+    val digest = CometScalaUDFCodegen.digest(bytes ++ occurrence)
+    val returnType = serializeDataType(outputType(generator)).getOrElse {
+      return Left(s"$name: unsupported output type 
${outputType(generator).catalogString}")
+    }
+
+    def call(
+        terminate: Boolean,
+        dataArgs: Seq[Expression]): Either[String, ExprOuterClass.Expr] = {
+      val args = (Seq(Literal(digest), Literal(bytes), Literal(terminate)) ++ 
dataArgs)
+        .map(exprToProto(_, inputs))
+      if (args.exists(_.isEmpty)) {
+        Left(s"$name: an argument could not be passed to the JVM generator")
+      } else {
+        val udf = ExprOuterClass.JvmScalarUdf
+          .newBuilder()
+          .setClassName(classOf[CometGeneratorUDF].getName)
+          .addAllArgs(args.flatten.asJava)
+          .setReturnType(returnType)
+          .setReturnNullable(false)
+        Right(ExprOuterClass.Expr.newBuilder().setJvmScalarUdf(udf).build())
+      }
+    }
+    for {
+      process <- call(terminate = false, attrs)
+      terminate <- call(terminate = true, Nil)
+    } yield (process, terminate)
+  }
+
+  /**
+   * A generator ready to run in one native plan, and the Arrow field its 
output is exported as.
+   */
+  private case class BoundGenerator(generator: Generator, outputField: Field)
+
+  private object BoundGenerator {
+    def apply(generator: Generator, partitionIndex: Int): BoundGenerator = {
+      generator.foreach {
+        case n: Nondeterministic => n.initialize(partitionIndex)

Review Comment:
   Done in 4c335a8a5. `BoundGenerator` no longer has the companion `apply` and 
`partitionIndex` is no longer threaded into it. A nondeterministic generator is 
declined by `getSupportLevel`, so `initialize` was never reachable. I left a 
comment where `initialize` used to be called.



##########
spark/src/test/scala/org/apache/comet/exec/CometGenerateExecSuite.scala:
##########
@@ -722,4 +737,98 @@ class CometGenerateExecSuite extends CometTestBase {
     }
   }
 
+  /** Run `f` over a single-file Parquet table `t` of `(id, s, js)` rows. */
+  private def withGeneratorTable(f: => Unit): Unit = {
+    val rows = Seq(
+      (1, "a", """{"a": 1, "b": "x"}"""),
+      (2, null, """{"a": null}"""),
+      (3, "c", "not json"),
+      (4, "d", null))
+    withTempPath { path =>
+      rows.toDF("id", "s", 
"js").repartition(1).write.parquet(path.getAbsolutePath)
+      withParquetTable(path.getAbsolutePath, "t")(f)
+    }
+  }
+
+  /** Check `df` against Spark, and that its generator ran in Comet's native 
operator. */
+  private def checkSparkAnswerWithNativeGenerate(df: DataFrame): Unit = {
+    val (_, cometPlan) = checkSparkAnswer(df)
+    assert(
+      collectFirst(cometPlan) { case generate: CometExplodeExec => generate 
}.isDefined,
+      s"Expected a native generator in\n$cometPlan")
+  }
+
+  test("Hive UDTF runs natively and emits its terminate rows") {
+    CometHiveUDTFs.register(spark, "counting_udtf", classOf[CountingUDTF])
+    withGeneratorTable {
+      // The row `close` emits carries the last input row's `id`, as in Spark.
+      checkSparkAnswerAndOperator(sql("SELECT id, counting_udtf(s) FROM t"))
+      checkSparkAnswerAndOperator(
+        sql("SELECT id, value, n FROM t LATERAL VIEW OUTER counting_udtf(s) u 
AS value, n"))
+      // With no input rows only the `close` row remains.
+      checkSparkAnswerAndOperator(sql("SELECT counting_udtf(s) FROM t WHERE id 
> 10"))
+      // Two identical UDTFs in one plan each keep their own count.
+      checkSparkAnswerAndOperator(sql("""
+          |SELECT id, a.value, a.n, b.value, b.n FROM t
+          |LATERAL VIEW counting_udtf(s) a AS value, n
+          |LATERAL VIEW counting_udtf(s) b AS value, n""".stripMargin))
+    }
+  }
+
+  test("Hive UDTF keeps separate state per partition") {
+    CometHiveUDTFs.register(spark, "counting_udtf", classOf[CountingUDTF])
+    withSQLConf(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+      SQLConf.SHUFFLE_PARTITIONS.key -> "3") {
+      withGeneratorTable {
+        // Each partition ends with one `close` row holding its own count, so 
the counts add up
+        // to the input rows whichever partition each row lands in.
+        checkSparkAnswerWithNativeGenerate(sql("""
+            |SELECT count(*), sum(n) FROM (
+            |  SELECT counting_udtf(s) FROM (SELECT * FROM t DISTRIBUTE BY id))
+            |WHERE value = 'total'""".stripMargin))
+      }
+    }
+  }
+
+  test("json_tuple runs natively") {

Review Comment:
   Done in 4c335a8a5. Added `expressions/json/json_tuple.sql` (valid, missing, 
repeated and null fields, invalid and empty documents, `LATERAL VIEW` with and 
without `OUTER`, filter and aggregate above) and `operators/except_all.sql`. 
The latter runs `EXCEPT ALL` and `INTERSECT ALL` over `DECIMAL`, `BINARY`, 
`FLOAT` and `DOUBLE` (NaN, `-0.0`, infinity), `DATE`, `TIMESTAMP`, 
`TIMESTAMP_NTZ`, array, struct and string, each with NULLs, one type at a time 
and all columns at once. Both use plain `query` blocks and pass with native 
placement checked. The Hive UDTF cases stay in Scala.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to