Skip to content
186 changes: 175 additions & 11 deletions python/pyspark/sql/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4763,17 +4763,6 @@ def test_vectorized_udf_invalid_length(self):
'Result vector from pandas_udf was not the required length'):
df.select(raise_exception(col('id'))).collect()

def test_vectorized_udf_mix_udf(self):
from pyspark.sql.functions import pandas_udf, udf, col
df = self.spark.range(10)
row_by_row_udf = udf(lambda x: x, LongType())
pd_udf = pandas_udf(lambda x: x, LongType())
with QuietTest(self.sc):
with self.assertRaisesRegexp(
Exception,
'Can not mix vectorized and non-vectorized UDFs'):
df.select(row_by_row_udf(col('id')), pd_udf(col('id'))).collect()

def test_vectorized_udf_chained(self):
from pyspark.sql.functions import pandas_udf, col
df = self.spark.range(10)
Expand Down Expand Up @@ -5060,6 +5049,166 @@ def test_type_annotation(self):
df = self.spark.range(1).select(pandas_udf(f=_locals['noop'], returnType='bigint')('id'))
self.assertEqual(df.first()[0], 0)

def test_mixed_udf(self):
import pandas as pd
from pyspark.sql.functions import col, udf, pandas_udf

df = self.spark.range(0, 1).toDF('v')

# Test mixture of multiple UDFs and Pandas UDFs.

@udf('int')
def f1(x):
assert type(x) == int
return x + 1

@pandas_udf('int')
def f2(x):
assert type(x) == pd.Series
return x + 10

@udf('int')
def f3(x):
assert type(x) == int
return x + 100

@pandas_udf('int')
def f4(x):
assert type(x) == pd.Series
return x + 1000

# Test single expression with chained UDFs
df_chained_1 = df.withColumn('f2_f1', f2(f1(df['v'])))
df_chained_2 = df.withColumn('f3_f2_f1', f3(f2(f1(df['v']))))
df_chained_3 = df.withColumn('f4_f3_f2_f1', f4(f3(f2(f1(df['v'])))))
df_chained_4 = df.withColumn('f4_f2_f1', f4(f2(f1(df['v']))))
df_chained_5 = df.withColumn('f4_f3_f1', f4(f3(f1(df['v']))))

expected_chained_1 = df.withColumn('f2_f1', df['v'] + 11)
expected_chained_2 = df.withColumn('f3_f2_f1', df['v'] + 111)
expected_chained_3 = df.withColumn('f4_f3_f2_f1', df['v'] + 1111)
expected_chained_4 = df.withColumn('f4_f2_f1', df['v'] + 1011)
expected_chained_5 = df.withColumn('f4_f3_f1', df['v'] + 1101)

self.assertEquals(expected_chained_1.collect(), df_chained_1.collect())
self.assertEquals(expected_chained_2.collect(), df_chained_2.collect())
self.assertEquals(expected_chained_3.collect(), df_chained_3.collect())
self.assertEquals(expected_chained_4.collect(), df_chained_4.collect())
self.assertEquals(expected_chained_5.collect(), df_chained_5.collect())

# Test multiple mixed UDF expressions in a single projection
df_multi_1 = df \
.withColumn('f1', f1(col('v'))) \
.withColumn('f2', f2(col('v'))) \
.withColumn('f3', f3(col('v'))) \
.withColumn('f4', f4(col('v'))) \
.withColumn('f2_f1', f2(col('f1'))) \
.withColumn('f3_f1', f3(col('f1'))) \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks testing udf + udf

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the way the test is written is that I am trying to test many combinations so some combinations might not be mixed UDF. Do you prefer that I remove these cases?

.withColumn('f4_f1', f4(col('f1'))) \
.withColumn('f3_f2', f3(col('f2'))) \
.withColumn('f4_f2', f4(col('f2'))) \
.withColumn('f4_f3', f4(col('f3'))) \
.withColumn('f3_f2_f1', f3(col('f2_f1'))) \
.withColumn('f4_f2_f1', f4(col('f2_f1'))) \
.withColumn('f4_f3_f1', f4(col('f3_f1'))) \
.withColumn('f4_f3_f2', f4(col('f3_f2'))) \
.withColumn('f4_f3_f2_f1', f4(col('f3_f2_f1')))

# Test mixed udfs in a single expression
df_multi_2 = df \
.withColumn('f1', f1(col('v'))) \
.withColumn('f2', f2(col('v'))) \
.withColumn('f3', f3(col('v'))) \
.withColumn('f4', f4(col('v'))) \
.withColumn('f2_f1', f2(f1(col('v')))) \
.withColumn('f3_f1', f3(f1(col('v')))) \
.withColumn('f4_f1', f4(f1(col('v')))) \
.withColumn('f3_f2', f3(f2(col('v')))) \
.withColumn('f4_f2', f4(f2(col('v')))) \
.withColumn('f4_f3', f4(f3(col('v')))) \
.withColumn('f3_f2_f1', f3(f2(f1(col('v'))))) \
.withColumn('f4_f2_f1', f4(f2(f1(col('v'))))) \
.withColumn('f4_f3_f1', f4(f3(f1(col('v'))))) \
.withColumn('f4_f3_f2', f4(f3(f2(col('v'))))) \
.withColumn('f4_f3_f2_f1', f4(f3(f2(f1(col('v'))))))

expected = df \
.withColumn('f1', df['v'] + 1) \
.withColumn('f2', df['v'] + 10) \
.withColumn('f3', df['v'] + 100) \
.withColumn('f4', df['v'] + 1000) \
.withColumn('f2_f1', df['v'] + 11) \
.withColumn('f3_f1', df['v'] + 101) \
.withColumn('f4_f1', df['v'] + 1001) \
.withColumn('f3_f2', df['v'] + 110) \
.withColumn('f4_f2', df['v'] + 1010) \
.withColumn('f4_f3', df['v'] + 1100) \
.withColumn('f3_f2_f1', df['v'] + 111) \
.withColumn('f4_f2_f1', df['v'] + 1011) \
.withColumn('f4_f3_f1', df['v'] + 1101) \
.withColumn('f4_f3_f2', df['v'] + 1110) \
.withColumn('f4_f3_f2_f1', df['v'] + 1111)

self.assertEquals(expected.collect(), df_multi_1.collect())
self.assertEquals(expected.collect(), df_multi_2.collect())

def test_mixed_udf_and_sql(self):
import pandas as pd
from pyspark.sql import Column
from pyspark.sql.functions import udf, pandas_udf

df = self.spark.range(0, 1).toDF('v')

# Test mixture of UDFs, Pandas UDFs and SQL expression.

@udf('int')
def f1(x):
assert type(x) == int
return x + 1

def f2(x):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this is neither @udf nor @pandas_udf, is it on purpose? If so, could you add a comment to explain why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the purpose is to test mixing udf, pandas_udf and sql expression. I will add comments to make it clearer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comments in test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see why it looks confusing. Can we add an assert here too (check if it's a column)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

assert type(x) == Column
return x + 10

@pandas_udf('int')
def f3(x):
assert type(x) == pd.Series
return x + 100

df1 = df.withColumn('f1', f1(df['v'])) \
.withColumn('f2', f2(df['v'])) \
.withColumn('f3', f3(df['v'])) \
.withColumn('f1_f2', f1(f2(df['v']))) \
.withColumn('f1_f3', f1(f3(df['v']))) \
.withColumn('f2_f1', f2(f1(df['v']))) \
.withColumn('f2_f3', f2(f3(df['v']))) \
.withColumn('f3_f1', f3(f1(df['v']))) \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks combination between f1 and f3 duplicating few tests in test_mixed_udf, for instance f4_f3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the way the test is written is that I am trying to test many combinations so there are some dup cases. Do you prefer that I remove these?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea.. I know it's still minor since the elapsed time will be virtually the same but recently the build / test time was an issue, and I wonder if there's better way then avoding duplicated tests for now..

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was discussed here #21845

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I don't think it's necessary (we are only likely to remove a few cases and like you said, the test time is virtually the same) and helps the readability of the tests (so it doesn't look like some test cases are missed).

But if that's the preferred practice I can remove duplicate cases in the next commit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am okay to leave it too here since it's clear they are virtually the same but let's remove duplicated tests or orthogonal tests next time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. I will keep that in mind next time.

.withColumn('f3_f2', f3(f2(df['v']))) \
.withColumn('f1_f2_f3', f1(f2(f3(df['v'])))) \
.withColumn('f1_f3_f2', f1(f3(f2(df['v'])))) \
.withColumn('f2_f1_f3', f2(f1(f3(df['v'])))) \
.withColumn('f2_f3_f1', f2(f3(f1(df['v'])))) \
.withColumn('f3_f1_f2', f3(f1(f2(df['v'])))) \
.withColumn('f3_f2_f1', f3(f2(f1(df['v']))))

expected = df.withColumn('f1', df['v'] + 1) \
.withColumn('f2', df['v'] + 10) \
.withColumn('f3', df['v'] + 100) \
.withColumn('f1_f2', df['v'] + 11) \
.withColumn('f1_f3', df['v'] + 101) \
.withColumn('f2_f1', df['v'] + 11) \
.withColumn('f2_f3', df['v'] + 110) \
.withColumn('f3_f1', df['v'] + 101) \
.withColumn('f3_f2', df['v'] + 110) \
.withColumn('f1_f2_f3', df['v'] + 111) \
.withColumn('f1_f3_f2', df['v'] + 111) \
.withColumn('f2_f1_f3', df['v'] + 111) \
.withColumn('f2_f3_f1', df['v'] + 111) \
.withColumn('f3_f1_f2', df['v'] + 111) \
.withColumn('f3_f2_f1', df['v'] + 111)

self.assertEquals(expected.collect(), df1.collect())


@unittest.skipIf(
not _have_pandas or not _have_pyarrow,
Expand Down Expand Up @@ -5487,6 +5636,21 @@ def dummy_pandas_udf(df):
F.col('temp0.key') == F.col('temp1.key'))
self.assertEquals(res.count(), 5)

def test_mixed_scalar_udfs_followed_by_grouby_apply(self):
import pandas as pd

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a big deal at all really .. but I would swap the import order (thridparty, pyspark)

from pyspark.sql.functions import udf, pandas_udf, PandasUDFType

df = self.spark.range(0, 10).toDF('v1')
df = df.withColumn('v2', udf(lambda x: x + 1, 'int')(df['v1'])) \
.withColumn('v3', pandas_udf(lambda x: x + 2, 'int')(df['v1']))

result = df.groupby() \
.apply(pandas_udf(lambda x: pd.DataFrame([x.sum().sum()]),
'sum int',
PandasUDFType.GROUPED_MAP))

self.assertEquals(result.collect()[0]['sum'], 165)


@unittest.skipIf(
not _have_pandas or not _have_pyarrow,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer

import org.apache.spark.api.python.PythonEvalType
import org.apache.spark.sql.AnalysisException
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, LogicalPlan, Project}
Expand Down Expand Up @@ -94,36 +95,94 @@ object ExtractPythonUDFFromAggregate extends Rule[LogicalPlan] {
*/
object ExtractPythonUDFs extends Rule[SparkPlan] with PredicateHelper {

private def hasPythonUDF(e: Expression): Boolean = {
private case class LazyEvalType(var evalType: Int = -1) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmmmm looks messier then I thought .. previous one looks a bit better to me .. wdyt @BryanCutler ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too fond of the name LazyEvalType, makes it sound like something else. Maybe CurrentEvalType?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the idea of the LazyEvalType is a container object that can be set once. Maybe the name LazyEvalType is confusing. I don't think CurrentEvalType is accurate either because the original idea is that we don't change the value once it's set. Maybe call it EvalTypeHolder and add docs to explain?


def isSet: Boolean = evalType >= 0

def set(evalType: Int): Unit = {
if (isSet) {
throw new IllegalStateException("Eval type has already been set")
} else {
this.evalType = evalType
}
}

def get(): Int = {
if (!isSet) {
throw new IllegalStateException("Eval type is not set")
} else {
evalType
}
}
}

private def hasScalarPythonUDF(e: Expression): Boolean = {
e.find(PythonUDF.isScalarPythonUDF).isDefined
}

private def canEvaluateInPython(e: PythonUDF): Boolean = {
e.children match {
// single PythonUDF child could be chained and evaluated in Python
case Seq(u: PythonUDF) => canEvaluateInPython(u)
// Python UDF can't be evaluated directly in JVM
case children => !children.exists(hasPythonUDF)
/**
* Check whether a PythonUDF expression can be evaluated in Python.
*
* If the lazy eval type is not set, this method checks for either Batched Python UDF and Scalar
* Pandas UDF. If the lazy eval type is set, this method checks for the expression of the
* specified eval type.
*
* This method will also set the lazy eval type to be the type of the first evaluable expression,
* i.e., if lazy eval type is not set and we find a evaluable Python UDF expression, lazy eval
* type will be set to the eval type of the expression.
*
*/
private def canEvaluateInPython(e: PythonUDF, lazyEvalType: LazyEvalType): Boolean = {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BryanCutler I rewrite this function using mutable state based on your suggestion. It's not quite the same as your code so please take a look and let me know if this looks better now. Thanks!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one method seems overly complicated, so I prefer the code from my suggestion.

@icexelloss icexelloss Jul 25, 2018

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your code:

  private def canEvaluateInPython(e: PythonUDF, firstEvalType: FirstEvalType): Boolean = {
    if (firstEvalType.isEvalTypeSet() && e.evalType != firstEvalType.evalType) {
      false
    } else {
      firstEvalType.evalType = e.evalType
      e.children match {
        // single PythonUDF child could be chained and evaluated in Python
        case Seq(u: PythonUDF) => canEvaluateInPython(u, firstEvalType)
        // Python UDF can't be evaluated directly in JVM
        case children => !children.exists(hasScalarPythonUDF)
      }
    }
  }

I think what's confusing part here is that the value of firstEvalType.evalType keeps changing while we are traversing the tree, and we could be carrying the value across independent subtrees (i.e., after finish traversing one subtree, the firstEvalType can be set to Scalar Pandas, even we didn't find a evaluable UDF and we never reset it so when we visit another subtree, we could get wrong results). The fact that the firstEvalType keeps changing as we traverse the tree seems very error prone to me.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow how this could get wrong results. firstEvalType.evalType = e.evalType is called only if the eval type is not set or if it is set and it equals the current eval type. In the latter case, it does assign the same value again, but that's fine. If there is some case that this fails, can you add that as a test?

@icexelloss icexelloss Jul 25, 2018

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bryan, I tried to apply your implementation and the simple test fails:

@udf('int')
def f1(x):
    assert type(x) == int
    return x + 1

@pandas_udf('int')
def f2(x):
    assert type(x) == pd.Series
    return x + 10

df = self.spark.range(0, 1).toDF('v')
df_chained_1 = df.withColumn('f2_f1', f2(f1(df['v'])))
expected_chained_1 = df.withColumn('f2_f1', df['v'] + 11)
self.assertEquals(expected_chained_1.collect(), df_chained_1.collect())

Do you mind trying this too? Hopefully I didn't do something silly here..

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the above test part of sql/tests.py?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it's in the most recent commit.

@BryanCutler BryanCutler Jul 25, 2018

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think I see the problem. Since there was a map over plan.expressions, a new FirstEvalType object was being created for each expression. Changing this to the following corrected the failure:

val setEvalType = new FirstEvalType
val udfs = plan.expressions.flatMap(collectEvaluableUDFs(_, setEvalType))

I updated my above code to this, does that look correct now?

@icexelloss icexelloss Jul 26, 2018

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I applied you new code but the test I mentioned above still fails.

I think the issue could be when visiting f2(f1(col('v'))), firstEvalType is set to Scalar Pandas first and isn't set to Batched SQL later so f1 is not extracted. It's possible that my code is still different than yours somehow.

But similar to #21650 (comment), I think the state machine of the firstEvalType here is fairly complicated (i.e., what is the expected state of the eval type holder before and after canEvaluateInPythonand what's the invariants of the algo) with your suggested implementation and I found myself think pretty hard to prove the state machine is correct in all cases. If we want to go with this implementation, we need to carefully think about it and explain it in code...

The lazyEvalType implementation is better IMHO because the state machine is simpler - lazyEvalType is empty until we find the first evaluable UDF and the value doesn't change once it's set.

The first implementation (two pass, immutable state) is probably the simplest in terms of the mental complexity of the algo but is less efficient.

I think I am ok with both immutable state or the lazy state. I think @HyukjinKwon prefers the immutable state one. @BryanCutler WDYT?

if (!lazyEvalType.isSet) {
e.children match {
// single PythonUDF child could be chained and evaluated in Python if eval type is the same
case Seq(u: PythonUDF) =>
// Need to recheck the eval type because lazy eval type will be set if child Python UDF is
// evaluable
canEvaluateInPython(u, lazyEvalType) && lazyEvalType.get == e.evalType
// Python UDF can't be evaluated directly in JVM
case children => if (!children.exists(hasScalarPythonUDF)) {
// We found the first evaluable expression, set lazy eval type to its eval type.
lazyEvalType.set(e.evalType)
true
} else {
false
}
}
} else {
if (e.evalType != lazyEvalType.get) {
false
} else {
e.children match {
case Seq(u: PythonUDF) => canEvaluateInPython(u, lazyEvalType)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are 2 paths for recursion here, which is probably not a good idea. This method is much more complicated now and a little difficult to follow.

case children => !children.exists(hasScalarPythonUDF)
}
}
}
}

private def collectEvaluatableUDF(expr: Expression): Seq[PythonUDF] = expr match {
case udf: PythonUDF if PythonUDF.isScalarPythonUDF(udf) && canEvaluateInPython(udf) => Seq(udf)
case e => e.children.flatMap(collectEvaluatableUDF)
private def collectEvaluableUDFs(
expr: Expression,
evalType: LazyEvalType
): Seq[PythonUDF] = {
expr match {
case udf: PythonUDF if
PythonUDF.isScalarPythonUDF(udf) && canEvaluateInPython(udf, evalType) =>
Seq(udf)

@icexelloss icexelloss Jul 27, 2018

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HyukjinKwon In your code this line is collectEvaluableUDFs(expr). I think we should just return Seq(udf) to avoid checking the expression twice.

case e => e.children.flatMap(collectEvaluableUDFs(_, evalType))
}
}

def apply(plan: SparkPlan): SparkPlan = plan transformUp {
// AggregateInPandasExec and FlatMapGroupsInPandas can be evaluated directly in python worker
// Therefore we don't need to extract the UDFs
case plan: FlatMapGroupsInPandasExec => plan

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer needed because this rule will only extract Python UDF and Scalar Pandas UDF and ignore other types of UDFs

case plan: SparkPlan => extract(plan)
}

/**
* Extract all the PythonUDFs from the current operator and evaluate them before the operator.
*/
private def extract(plan: SparkPlan): SparkPlan = {
val udfs = plan.expressions.flatMap(collectEvaluatableUDF)
val lazyEvalType = new LazyEvalType
val udfs = plan.expressions.flatMap(collectEvaluableUDFs(_, lazyEvalType))
// ignore the PythonUDF that come from second/third aggregate, which is not used
.filter(udf => udf.references.subsetOf(plan.inputSet))
if (udfs.isEmpty) {
Expand Down Expand Up @@ -167,7 +226,8 @@ object ExtractPythonUDFs extends Rule[SparkPlan] with PredicateHelper {
case (vectorizedUdfs, plainUdfs) if vectorizedUdfs.isEmpty =>
BatchEvalPythonExec(plainUdfs, child.output ++ resultAttrs, child)
case _ =>
throw new IllegalArgumentException("Can not mix vectorized and non-vectorized UDFs")
throw new AnalysisException(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why change the exception type? Can you make a test that causes this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because we shouldn't reach here. (Otherwise it's bug). Don't know what's the best exception type here though.

"Expected either Scalar Pandas UDFs or Batched UDFs but got both")
}

attributeMap ++= validUdfs.zip(resultAttrs)
Expand Down Expand Up @@ -205,7 +265,7 @@ object ExtractPythonUDFs extends Rule[SparkPlan] with PredicateHelper {
case filter: FilterExec =>
val (candidates, nonDeterministic) =
splitConjunctivePredicates(filter.condition).partition(_.deterministic)
val (pushDown, rest) = candidates.partition(!hasPythonUDF(_))
val (pushDown, rest) = candidates.partition(!hasScalarPythonUDF(_))
if (pushDown.nonEmpty) {
val newChild = FilterExec(pushDown.reduceLeft(And), filter.child)
FilterExec((rest ++ nonDeterministic).reduceLeft(And), newChild)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,10 @@ class MyDummyPythonUDF extends UserDefinedPythonFunction(
dataType = BooleanType,
pythonEvalType = PythonEvalType.SQL_BATCHED_UDF,
udfDeterministic = true)

class MyDummyScalarPandasUDF extends UserDefinedPythonFunction(
name = "dummyScalarPandasUDF",
func = new DummyUDF,
dataType = BooleanType,
pythonEvalType = PythonEvalType.SQL_SCALAR_PANDAS_UDF,
udfDeterministic = true)
Loading