This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 5d1840dce8c5 [SPARK-59091][SQL] Emit the NaN guard in transpiled
equality only for floating-point columns
5d1840dce8c5 is described below
commit 5d1840dce8c5aecab89f554ef232b766ce035bad
Author: jzhan-2026 <[email protected]>
AuthorDate: Wed Oct 28 16:47:07 2026 -0700
[SPARK-59091][SQL] Emit the NaN guard in transpiled equality only for
floating-point columns
### What changes were proposed in this pull request?
Split the transpiler's `"numeric"` category into `"integer"` (IntegralType)
and `"float"`
(FractionalType, excluding DecimalType). The NaN guard in `_lower_eq` is
now only emitted
when at least one operand has category `"float"`.
- `transpile.py`: `_category` returns `"integer"` / `"float"` instead of
`"numeric"` for
literals and parameters; `_param_category_combos` tries `["integer",
"float", "string"]`
per parameter; `_lower_eq` gates the guard on `lc == "float" or rc ==
"float"`;
`_convert_chunk` BinOp dispatch uses `_is_numeric_cat()` for all three
sub-categories.
- `ResolveTranspiledPythonUDFOptions.scala`: adds `"integer"` →
IntegralType and `"float"`
→ FractionalType (excluding Decimal) cases to `optionMatchesTypes`.
Co-authored with jing.zhansnowflake.com
### Why are the changes needed?
For integer columns (e.g. `BIGINT`), the old plan for `x == y` included
`isnan(cast(x AS DOUBLE))`, which is always false—integers can't be NaN. The
NaN guard only makes sense for floating-point types.
### Does this PR introduce _any_ user-facing change?
No. The generated Catalyst plan for integer-column equality no longer
contains dead `isnan` guards, but the query results are identical.
### How was this patch tested?
New Python tests in `test_udf_transpile_unit`:
- `test_udf_transpile_integer_eq_no_nan_guard` — no `isnan` in plan for
`BIGINT ==`
- `test_udf_transpile_float_eq_nan_semantics` — `isnan` present for `DOUBLE
==`; NaN and null semantics verified end-to-end
- `test_udf_transpile_int_float_annotation_categories` — unit tests for new
category names
New Scala tests in `ResolveTranspiledPythonUDFOptionsSuite`: `"integer"` /
`"float"` matching, cross-type rejection, DecimalType exclusion, FloatType
matching.
### Was this patch authored or co-authored using generative AI tooling?
Co-authored by: Claude Sonnet 4.6
Closes #58901 from jzhan-2026/spark-59091-nan-guard.
Authored-by: jzhan-2026 <[email protected]>
Signed-off-by: Holden Karau <[email protected]>
---
.../pyspark/sql/tests/test_udf_transpile_unit.py | 115 ++++++++++-
python/pyspark/sql/transpile.py | 219 ++++++++++++++-------
.../ResolveTranspiledPythonUDFOptions.scala | 31 +--
.../ResolveTranspiledPythonUDFOptionsSuite.scala | 66 ++++++-
4 files changed, 344 insertions(+), 87 deletions(-)
diff --git a/python/pyspark/sql/tests/test_udf_transpile_unit.py
b/python/pyspark/sql/tests/test_udf_transpile_unit.py
index 6a650a08a767..5c0309bb53b3 100644
--- a/python/pyspark/sql/tests/test_udf_transpile_unit.py
+++ b/python/pyspark/sql/tests/test_udf_transpile_unit.py
@@ -1640,12 +1640,12 @@ class UDFTranspileUnitTests(ReusedSQLTestCase):
# unresolved and ResolveRandomSeed then gives each spliced copy
its OWN seed. Both have
# to end up sharing one evaluation, or `x == x` compares two
independent draws.
#
- # Sharing leaves `col = col`, which SimplifyBinaryComparison folds
to true and column
- # pruning then drops the draw entirely -- so zero draws in the
plan is the proof that
- # both sides became the same column. Two independent draws would
leave both.
+ # The NaN guard for the untyped (numeric) parameter keeps one
`isnan` reference to the
+ # shared arg, so one draw remains in the plan after
SimplifyBinaryComparison folds
+ # `col == col` to true. Two independent draws would leave both.
for arg in (rand(), expr("rand()")):
eq_df = rows.select(eq_udf(arg).alias("v"))
- self.assertEqual(0, self._draw_count(eq_df))
+ self.assertEqual(1, self._draw_count(eq_df))
self.assertTrue(all(r[0] for r in eq_df.collect()))
clamped = rows.select(clamp_udf(rand()).alias("v"))
@@ -2636,17 +2636,120 @@ class UDFTranspileUnitTests(ReusedSQLTestCase):
def test_param_category_combos_caps_preserve_typed_pins(self):
# With more than three untyped params the cap collapses the untyped
ones
- # to numeric/string but keeps each typed param pinned (here a: str).
+ # to integer/float/string but keeps each typed param pinned (here a:
str).
import ast as _ast
from pyspark.sql.transpile import _param_category_combos
fn = _ast.parse("def f(a: str, b, c, d, e): return a").body[0]
combos = _param_category_combos(fn, ["a", "b", "c", "d", "e"])
- self.assertEqual(len(combos), 2)
+ # Three fill values: integer / float / string -> 3 combos.
+ self.assertEqual(len(combos), 3)
for combo in combos:
self.assertEqual(combo[0], "string")
+ def test_udf_transpile_integer_eq_no_nan_guard(self):
+ # `a == b` on BIGINT columns must NOT emit a NaN guard. Integers can
never
+ # be NaN, so emitting `isnan(cast(bigint as double))` is unnecessary
dead
+ # code. Verify via the logical plan: neither "isnan" nor an implicit
double
+ # cast should appear for the integer equality variant.
+ from pyspark.sql.types import StructField, StructType
+
+ def x_eq_y(x: int, y: int) -> bool:
+ return x == y
+
+ schema = StructType(
+ [
+ StructField("a", LongType(), nullable=True),
+ StructField("b", LongType(), nullable=True),
+ ]
+ )
+ with self.sql_conf(_TRANSPILE_ON):
+ pudf = UserDefinedFunction(x_eq_y, BooleanType())
+ self.assertTrue(pudf.transpiled, "annotated int == int should
transpile")
+ df = self.spark.createDataFrame([(1, 1), (1, 2), (None, None)],
schema=schema)
+ projected = df.select(pudf("a", "b").alias("r"))
+ # Verify no Python eval node (plan was rewritten to Catalyst).
+ self.assertEqual(0, self._eval_python_count(projected))
+ # Verify isnan is NOT in the logical plan.
+ logical_plan =
projected._jdf.queryExecution().analyzed().toString()
+ self.assertNotIn("isnan", logical_plan.lower(), "integer == must
not emit NaN guard")
+ # Verify correct results: (1==1, 1==2, None==None in Python ==
True).
+ results = [r[0] for r in projected.collect()]
+ self.assertEqual(results, [True, False, True])
+
+ def test_udf_transpile_float_eq_nan_semantics(self):
+ # `a == b` on DOUBLE columns must correctly implement Python's NaN
semantics:
+ # IEEE 754 says NaN != NaN (Python returns False), while Spark's
EqualTo
+ # returns True for NaN = NaN. The NaN guard fixes this for float
columns.
+ from pyspark.sql.types import DoubleType, StructField, StructType
+
+ def x_eq_y(x: float, y: float) -> bool:
+ return x == y
+
+ def x_neq_y(x: float, y: float) -> bool:
+ return x != y
+
+ schema = StructType(
+ [
+ StructField("a", DoubleType(), nullable=True),
+ StructField("b", DoubleType(), nullable=True),
+ ]
+ )
+ nan = float("nan")
+ with self.sql_conf(_TRANSPILE_ON):
+ pudf_eq = UserDefinedFunction(x_eq_y, BooleanType())
+ pudf_neq = UserDefinedFunction(x_neq_y, BooleanType())
+ self.assertTrue(pudf_eq.transpiled, "annotated float == float
should transpile")
+ self.assertTrue(pudf_neq.transpiled, "annotated float != float
should transpile")
+ # Verify isnan IS present in the logical plan (NaN guard was
emitted).
+ df = self.spark.createDataFrame([(1.0, 1.0)], schema=schema)
+ projected_eq = df.select(pudf_eq("a", "b").alias("r"))
+ logical_plan =
projected_eq._jdf.queryExecution().analyzed().toString()
+ self.assertIn("isnan", logical_plan.lower(), "float == must emit
NaN guard")
+ # Verify semantics: NaN == NaN -> False (Python), None == None ->
True.
+ rows = [
+ (nan, nan), # NaN == NaN -> False in Python
+ (nan, 1.0), # NaN == 1.0 -> False in Python
+ (1.0, 1.0), # 1.0 == 1.0 -> True
+ (1.0, 2.0), # 1.0 == 2.0 -> False
+ (None, None), # None == None -> True (Python semantics)
+ (None, 1.0), # None == 1.0 -> False
+ ]
+ df = self.spark.createDataFrame(rows, schema=schema)
+ eq_results = [r[0] for r in df.select(pudf_eq("a", "b")).collect()]
+ neq_results = [r[0] for r in df.select(pudf_neq("a",
"b")).collect()]
+ self.assertEqual(eq_results, [False, False, True, False, True,
False])
+ self.assertEqual(neq_results, [True, True, False, True, False,
True])
+
+ def test_udf_transpile_int_float_annotation_categories(self):
+ # `int` annotation -> "integer" category; `float` annotation -> "float"
+ # category. For mixed-numeric comparisons the wider category is used.
+ import ast as _ast
+
+ from pyspark.sql.transpile import _annotation_category,
_param_category_combos
+
+ # _annotation_category: int -> "integer", float -> "float".
+ fn_ann = _ast.parse("def f(x: int, y: float): pass").body[0]
+ int_ann = fn_ann.args.args[0].annotation
+ flt_ann = fn_ann.args.args[1].annotation
+ self.assertEqual(_annotation_category(int_ann), "integer")
+ self.assertEqual(_annotation_category(flt_ann), "float")
+
+ # _param_category_combos pins typed params and tries all three for
untyped.
+ fn_typed = _ast.parse("def f(a: int, b: float): return a + b").body[0]
+ combos = _param_category_combos(fn_typed, ["a", "b"])
+ # One combo: both typed -> single variant {0: "integer", 1: "float"}.
+ self.assertEqual(len(combos), 1)
+ self.assertEqual(combos[0][0], "integer")
+ self.assertEqual(combos[0][1], "float")
+
+ fn_untyped = _ast.parse("def f(a): return a").body[0]
+ combos_u = _param_category_combos(fn_untyped, ["a"])
+ # Three combos for a single untyped param: integer / float / string.
+ self.assertEqual(len(combos_u), 3)
+ self.assertEqual({c[0] for c in combos_u}, {"integer", "float",
"string"})
+
if __name__ == "__main__":
from pyspark.testing import main
diff --git a/python/pyspark/sql/transpile.py b/python/pyspark/sql/transpile.py
index 21289a15a7e1..9d371a3d64ca 100644
--- a/python/pyspark/sql/transpile.py
+++ b/python/pyspark/sql/transpile.py
@@ -111,6 +111,7 @@ from pyspark.sql.functions import (
coalesce,
col,
concat,
+ isnan,
length,
lit,
pmod,
@@ -131,6 +132,27 @@ if TYPE_CHECKING:
from pyspark.sql import SparkSession
from pyspark.sql._typing import DataTypeOrString
+# Categories that represent numeric (non-string) scalar types.
+_NUMERIC_CATS = frozenset(("numeric", "integer", "float"))
+
+
+def _is_numeric_cat(cat: str) -> bool:
+ """True for any numeric sub-category (``"numeric"``, ``"integer"``,
``"float"``)."""
+ return cat in _NUMERIC_CATS
+
+
+def _wider_numeric(lc: str, rc: str) -> str:
+ """Return the wider of two numeric categories (numeric > float >
integer)."""
+ if not (_is_numeric_cat(lc) and _is_numeric_cat(rc)):
+ raise UnsupportedOperationException(
+ f"_wider_numeric called with non-numeric categories: {lc!r},
{rc!r}"
+ )
+ if "numeric" in (lc, rc):
+ return "numeric"
+ if "float" in (lc, rc):
+ return "float"
+ return "integer"
+
class AbstractTranspiler(object):
"""Base class for transpilers. All experimental."""
@@ -219,16 +241,17 @@ def _truthiness_col(cat: Optional[str], c: Column) ->
Optional[Column]:
Returns ``None`` for unsupported or unknown categories (caller falls back).
Semantics (NULL-as-False throughout, matching Python's ``None`` is falsy):
- "bool" -> coalesce(c, False)
- "string" -> coalesce(length(c) > 0, False) -- empty string is falsy
- "numeric" -> coalesce(c != 0, False) -- zero is falsy
- NaN != 0 is True in Spark so float NaN is truthy, matching
Python.
+ "bool" -> coalesce(c, False)
+ "string" -> coalesce(length(c) > 0, False) -- empty is
falsy
+ "numeric"/"integer"/"float" -> coalesce(c != 0, False) -- zero is
falsy
+ NaN != 0 is True in Spark so float NaN is
+ truthy, matching Python.
"""
if cat == "bool":
return coalesce(c, lit(False))
if cat == "string":
return coalesce(length(c) > lit(0), lit(False))
- if cat == "numeric":
+ if cat is not None and _is_numeric_cat(cat):
return coalesce(c != lit(0), lit(False))
return None
@@ -286,6 +309,12 @@ class CatalystTranspiler(AbstractTranspiler):
body_c = self._safe_category(params, node.body[0]) if node.body
else None
else_c = self._safe_category(params, node.orelse[0]) if
node.orelse else None
if body_c is not None and else_c is not None and body_c != else_c:
+ # Numeric sub-categories are mutually compatible; return the
+ # wider one. Truly mismatched families (e.g. numeric vs string)
+ # return None so the branch-compatibility check in
+ # _convert_if_like raises and the variant is dropped.
+ if _is_numeric_cat(body_c) and _is_numeric_cat(else_c):
+ return _wider_numeric(body_c, else_c)
return None
return body_c if body_c is not None else else_c
if isinstance(node, ast.Constant) and node.value is None:
@@ -341,11 +370,15 @@ class CatalystTranspiler(AbstractTranspiler):
body_cat = self._safe_category(params, body_node)
else_cat = self._safe_category(params, else_node)
if body_cat is not None and else_cat is not None and body_cat !=
else_cat:
- raise UnsupportedOperationException(
- f"if/else branches have incompatible categories ({body_cat} vs
"
- f"{else_cat}); the lowered CASE WHEN has no common type under
ANSI, "
- "so the transpiler falls back to interpreted Python"
- )
+ # Numeric sub-categories (integer/float) are compatible: Spark
widens
+ # to double in the CASE WHEN expression. Truly mismatched families
+ # (e.g. numeric vs string) are still refused.
+ if not (_is_numeric_cat(body_cat) and _is_numeric_cat(else_cat)):
+ raise UnsupportedOperationException(
+ f"if/else branches have incompatible categories
({body_cat} vs "
+ f"{else_cat}); the lowered CASE WHEN has no common type
under ANSI, "
+ "so the transpiler falls back to interpreted Python"
+ )
return when(safe_test, body_col).otherwise(else_col)
def _lower_eq(
@@ -355,7 +388,7 @@ class CatalystTranspiler(AbstractTranspiler):
right_node: ast.AST,
equal: bool,
) -> Column:
- """Lower ``==`` / ``!=`` with Python's None-equality semantics.
+ """Lower ``==`` / ``!=`` with Python's None- and NaN-equality
semantics.
Unlike ordering operators, Python doesn't raise on ``None == x`` /
``None != x``: ``None == None`` is True, ``None == 0`` is False,
@@ -372,31 +405,56 @@ class CatalystTranspiler(AbstractTranspiler):
where Python's ``==`` is simply False. Refuse those so the UDF falls
back to interpreted Python. A ``None`` literal operand stays allowed
(the four-branch NULL handling above reproduces Python exactly).
+ Numeric sub-categories (``"integer"`` vs ``"float"``) are treated as
+ compatible; Spark widens to double in the comparison.
+
+ A NaN guard is added unless both operands are provably ``"integer"``:
+ ``NaN == NaN`` is ``False`` in Python (IEEE 754), but Spark's
+ ``EqualTo`` returns ``True``. ``"numeric"`` is an unknown sub-type
+ that may resolve to a float column, so it keeps the guard.
- One value-level difference remains (needs runtime values, so it is
- documented, not guarded): Spark treats ``NaN = NaN`` as true, while
- Python's ``nan == nan`` is False.
+ One value-level difference remains for ordering comparisons (tracked
+ separately): Spark orders NaN as greater than every value, whereas
+ Python's NaN comparisons are all False.
"""
lc = self._safe_category(params, left_node)
rc = self._safe_category(params, right_node)
if lc is not None and rc is not None and lc != rc:
- raise UnsupportedOperationException(
- f"`==`/`!=` operands have incompatible categories ({lc} vs
{rc}); "
- "Python compares across types as unequal while Spark would
coerce "
- "or fail analysis, so the transpiler falls back to interpreted
Python"
- )
+ # Numeric sub-categories are mutually compatible for equality.
+ if not (_is_numeric_cat(lc) and _is_numeric_cat(rc)):
+ raise UnsupportedOperationException(
+ f"`==`/`!=` operands have incompatible categories ({lc} vs
{rc}); "
+ "Python compares across types as unequal while Spark would
coerce "
+ "or fail analysis, so the transpiler falls back to
interpreted Python"
+ )
left_col = self._convert_chunk(params, left_node)
right_col = self._convert_chunk(params, right_node)
left_null = left_col.isNull()
right_null = right_col.isNull()
+ # NaN guard: Python's `NaN == NaN` is False (IEEE 754 reflexivity
fails),
+ # but Spark's EqualTo returns True. Emit only for numeric operands, and
+ # skip only when both are provably integral ("integer"); "numeric" is
an
+ # unknown sub-type that may resolve to a float column, so it keeps the
guard.
+ if (
+ lc is not None
+ and rc is not None
+ and _is_numeric_cat(lc)
+ and _is_numeric_cat(rc)
+ and not (lc == "integer" and rc == "integer")
+ ):
+ nan_result = lit(not equal)
+ nan_cmp = isnan(left_col) | isnan(right_col)
+ value_cmp: Column = when(nan_cmp, nan_result).otherwise(
+ left_col == right_col if equal else left_col != right_col
+ )
+ else:
+ value_cmp = left_col == right_col if equal else left_col !=
right_col
if equal:
both_null_val: Column = lit(True)
one_null_val: Column = lit(False)
- value_cmp = left_col == right_col
else:
both_null_val = lit(False)
one_null_val = lit(True)
- value_cmp = left_col != right_col
return (
when(left_null & right_null, both_null_val)
.when(left_null | right_null, one_null_val)
@@ -435,7 +493,7 @@ class CatalystTranspiler(AbstractTranspiler):
"""
lc = self._category(params, left_node)
rc = self._category(params, right_node)
- if lc != rc:
+ if lc != rc and not (_is_numeric_cat(lc) and _is_numeric_cat(rc)):
raise UnsupportedOperationException(
f"`{op_repr}` compares operands of different categories "
f"({lc} vs {rc}); Python would raise TypeError, so the "
@@ -452,9 +510,12 @@ class CatalystTranspiler(AbstractTranspiler):
return when(null_guard, raise_error(err)).otherwise(op(left_col,
right_col))
def _category(self, params: List[str], node: ast.AST) -> str:
- """Infer ``"numeric"`` or ``"string"`` for ``node`` under the current
+ """Infer the scalar category of ``node`` under the current
``self._param_categories`` assumption (set per input-type variant).
+ Returns one of ``"integer"``, ``"float"``, ``"numeric"`` (unknown
+ numeric sub-type), ``"string"``, ``"bool"``, or ``"binary"``.
+
Drives operator selection (``+`` -> add vs concat, ``*`` -> multiply vs
repeat) and raises ``UnsupportedOperationException`` when an operator's
operands are type-incompatible, so the caller drops that variant and
the
@@ -471,18 +532,21 @@ class CatalystTranspiler(AbstractTranspiler):
def _category_uncached(self, params: List[str], node: ast.AST) -> str:
match node:
case ast.Constant(value=v):
- # bool subclasses int, so classify it first: int/float ->
numeric,
- # str -> string, bool -> bool, bytes -> binary. None/complex/
- # Ellipsis have no usable Spark column type, so raise to drop
this
- # variant and fall back rather than emit an option that fails
- # CheckAnalysis or silently diverges (e.g. `x + None` -> NULL
where
- # Python raises TypeError).
+ # bool subclasses int, so classify it first.
+ # int -> "integer", float -> "float", str -> "string",
+ # bool -> "bool", bytes -> "binary".
+ # None/complex/Ellipsis have no usable Spark column type, so
+ # raise to drop this variant and fall back rather than emit an
+ # option that fails CheckAnalysis or silently diverges (e.g.
+ # `x + None` -> NULL where Python raises TypeError).
if isinstance(v, bool):
return "bool"
if isinstance(v, bytes):
return "binary"
- if isinstance(v, (int, float)):
- return "numeric"
+ if isinstance(v, float):
+ return "float"
+ if isinstance(v, int):
+ return "integer"
if isinstance(v, str):
return "string"
raise UnsupportedOperationException(
@@ -496,15 +560,25 @@ class CatalystTranspiler(AbstractTranspiler):
case ast.BinOp(left=left, op=op, right=right):
lc = self._category(params, left)
rc = self._category(params, right)
- if isinstance(op, ast.Add) and lc == rc:
- return lc # str + str -> str, num + num -> num
+ if isinstance(op, ast.Add):
+ if lc == rc:
+ return lc # str+str, integer+integer, float+float
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
+ return _wider_numeric(lc, rc) # integer+float -> float
if isinstance(op, ast.Mult):
- if {lc, rc} == {"numeric", "numeric"}:
- return "numeric"
- if {lc, rc} == {"numeric", "string"}:
- return "string" # str * int / int * str -> repeat
- if isinstance(op, (ast.Sub, ast.Mod)) and lc == rc ==
"numeric":
- return "numeric"
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
+ return _wider_numeric(lc, rc)
+ # repeat(str, n): second operand must be integral (not
float).
+ # Python raises TypeError for `str * float`, so we allow
only
+ # "integer" or the legacy "numeric" catch-all (which the
JVM
+ # will validate against the actual column type at query
time).
+ if lc == "string" and rc in ("integer", "numeric"):
+ return "string"
+ if rc == "string" and lc in ("integer", "numeric"):
+ return "string"
+ if isinstance(op, (ast.Sub, ast.Mod)):
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
+ return _wider_numeric(lc, rc)
raise UnsupportedOperationException(
f"operands of `{type(op).__name__}` are not
type-compatible "
"for this input-type variant"
@@ -537,6 +611,11 @@ class CatalystTranspiler(AbstractTranspiler):
body_cat = branch_category(if_body)
else_cat = branch_category(if_orelse)
if body_cat is not None and else_cat is not None and body_cat
!= else_cat:
+ # Numeric sub-categories (integer/float) are mutually
+ # compatible: `1 if c else 1.0` is valid Python and Spark
+ # widens to double in the CASE WHEN.
+ if _is_numeric_cat(body_cat) and _is_numeric_cat(else_cat):
+ return _wider_numeric(body_cat, else_cat)
raise UnsupportedOperationException(
f"ternary branches have mismatched categories
({body_cat} "
f"vs {else_cat}) and cannot drive operator selection"
@@ -591,7 +670,7 @@ class CatalystTranspiler(AbstractTranspiler):
# of falling back, since the option is type-checked as a
# child of TranspiledPythonUDF before ConvertToCatalyst can
# drop it. Fail closed for every non-numeric category.
- if self._category(params, operand) != "numeric":
+ if not _is_numeric_cat(self._category(params, operand)):
raise UnsupportedOperationException(
"unary `+`/`-` is only supported for numeric operands "
"(Python raises TypeError on strings, and Spark would "
@@ -754,20 +833,20 @@ class CatalystTranspiler(AbstractTranspiler):
case ast.Add():
if lc == rc == "string":
return concat(left_col, right_col)
- if lc == rc == "numeric":
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
return left_col.__add__(right_col)
case ast.Sub():
- if lc == rc == "numeric":
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
return left_col.__sub__(right_col)
case ast.Mult():
- if lc == "numeric" and rc == "numeric":
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
return left_col.__mul__(right_col)
- if lc == "string" and rc == "numeric":
+ if lc == "string" and rc in ("integer", "numeric"):
return repeat(left_col, right_col.cast("int"))
- if lc == "numeric" and rc == "string":
+ if lc in ("integer", "numeric") and rc == "string":
return repeat(right_col, left_col.cast("int"))
case ast.Mod():
- if lc == rc == "numeric":
+ if _is_numeric_cat(lc) and _is_numeric_cat(rc):
# Python's `%` takes the sign of the divisor;
Spark's
# takes the dividend's. `sign(b) * pmod(sign(b) *
a,
# abs(b))` reproduces Python for every non-zero
divisor
@@ -895,7 +974,7 @@ class CatalystTranspiler(AbstractTranspiler):
cast_ok = (
body_cat is None
or (
- body_cat == "numeric"
+ _is_numeric_cat(body_cat)
and isinstance(returnType, NumericType)
and not isinstance(returnType, DecimalType)
)
@@ -936,21 +1015,24 @@ def _get_transpilers(session: "SparkSession") ->
List[AbstractTranspiler]:
def _annotation_category(annotation: Optional[ast.AST]) -> Optional[str]:
- """Map a parameter's type annotation to a category
- (``"numeric"``/``"string"``/``"bool"``/``"binary"``), or ``None`` when it's
- absent or unrecognised (the caller then tries both numeric and string)."""
+ """Map a parameter's type annotation to a category, or ``None`` when absent
+ or unrecognised (the caller then tries all numeric and string variants).
+
+ Returns one of ``"integer"``, ``"float"``, ``"string"``, ``"bool"``, or
+ ``"binary"`` (matching the constant handling in ``_category``). ``int``
+ maps to ``"integer"`` and ``float`` maps to ``"float"`` so the JVM can
+ select the variant whose NaN semantics match the bound column type."""
name: Optional[str] = None
if isinstance(annotation, ast.Name):
name = annotation.id
elif isinstance(annotation, ast.Constant) and isinstance(annotation.value,
str):
name = annotation.value # stringized annotation, e.g. def f(a: "int")
- # str -> "string", int/float -> "numeric", bool -> "bool", bytes ->
"binary"
- # (matching the constant handling in ``_category``). complex and anything
- # unrecognised return None so the caller tries both numeric and string.
if name == "str":
return "string"
- if name in ("int", "float"):
- return "numeric"
+ if name == "int":
+ return "integer"
+ if name == "float":
+ return "float"
if name == "bool":
return "bool"
if name == "bytes":
@@ -959,14 +1041,16 @@ def _annotation_category(annotation: Optional[ast.AST])
-> Optional[str]:
def _param_category_combos(function_ast: ast.FunctionDef, public_params:
List[str]) -> List[dict]:
- """Per-variant maps ``{public_param_index -> category}`` where category is
- one of ``"numeric"``/``"string"``/``"bool"``/``"binary"``.
-
- A typed param (``def f(a: str, b: int)``) is pinned to its category; an
- untyped param is tried as both numeric and string. To cap plan growth, when
- more than three params are untyped we collapse the untyped ones to the
- all-numeric and all-string variants (encourage typing inputs to keep the
- matrix small) while keeping every typed param pinned.
+ """Per-variant maps ``{public_param_index -> category}``.
+
+ A typed param (``def f(a: str, b: int)``) is pinned to its category
+ (``"integer"``/``"float"``/``"string"``/``"bool"``/``"binary"``); an
+ untyped param is tried as ``"integer"``, ``"float"``, and ``"string"``
+ separately so the JVM can select the variant that matches the bound column
+ type and apply the right NaN semantics. To cap plan growth, when more than
+ three params are untyped we collapse the untyped ones to one variant per
+ base category (encourage typing inputs to keep the matrix small) while
+ keeping every typed param pinned.
"""
n = len(public_params)
all_args = _positional_args(function_ast)
@@ -976,17 +1060,16 @@ def _param_category_combos(function_ast:
ast.FunctionDef, public_params: List[st
for arg in public_args:
cat = _annotation_category(arg.annotation)
if cat is None:
- candidates.append(["numeric", "string"])
+ candidates.append(["integer", "float", "string"])
untyped += 1
else:
candidates.append([cat])
if untyped > 3:
- # Cap the 2**untyped blow-up, but keep each typed param pinned to its
- # category (a single-element ``candidates`` entry); only the untyped
- # params collapse to the all-numeric / all-string pair.
+ # Cap the 3**untyped blow-up; collapse untyped params to one variant
+ # per base fill while keeping typed params pinned.
return [
{i: c[0] if len(c) == 1 else fill for i, c in
enumerate(candidates)}
- for fill in ("numeric", "string")
+ for fill in ("integer", "float", "string")
]
return [{i: choice[i] for i in range(n)} for choice in
itertools.product(*candidates)] or [{}]
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveTranspiledPythonUDFOptions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveTranspiledPythonUDFOptions.scala
index d9b07093fb89..4559831f767f 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveTranspiledPythonUDFOptions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveTranspiledPythonUDFOptions.scala
@@ -21,7 +21,9 @@ import
org.apache.spark.sql.catalyst.expressions.{TranspiledPythonUDF, Transpile
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.TRANSPILED_PYTHON_UDF
-import org.apache.spark.sql.types.{BinaryType, BooleanType, DataType,
DecimalType, NumericType, StringType}
+import org.apache.spark.sql.types.{
+ BinaryType, BooleanType, DataType, DecimalType, FractionalType,
+ IntegralType, NumericType, StringType}
/**
* Prunes the per-input-type options carried by a [[TranspiledPythonUDF]] down
to those whose
@@ -76,18 +78,22 @@ object ResolveTranspiledPythonUDFOptions extends
Rule[LogicalPlan] {
}
}
- // True when each declared category matches the corresponding argument type:
- // "numeric" -> NumericType, "string" -> StringType, "bool" -> BooleanType,
- // "binary" -> BinaryType. "string" matches only StringType (not
BinaryType): a
- // bytes/BinaryType column is tagged "binary" instead, so the string
lowerings
- // (e.g. `repeat`) never see it. Empty categories means "no restriction", so
the
- // option is kept.
+ // True when each declared category matches the corresponding argument type.
+ // Empty categories means "no restriction" and the option is kept.
+ //
+ // Category -> Spark type mapping:
+ // "integer" -> IntegralType (byte/short/int/long; NaN impossible)
+ // "float" -> FractionalType minus DecimalType (float/double; NaN
possible)
+ // "numeric" -> any NumericType minus DecimalType (legacy catch-all)
+ // "string" -> StringType with UTF8_BINARY collation only
+ // "bool" -> BooleanType
+ // "binary" -> BinaryType
//
// Two deliberate exclusions keep the transpiled semantics faithful to
Python:
- // - DecimalType is NOT "numeric": Python receives decimal.Decimal objects,
- // which raise TypeError when mixed with float literals and carry different
- // precision semantics than Spark's decimal arithmetic, so decimal columns
- // fall back to interpreted Python.
+ // - DecimalType is NOT matched by any numeric category: Python receives
+ // decimal.Decimal objects, which raise TypeError when mixed with float
+ // literals and carry different precision semantics than Spark's decimal
+ // arithmetic, so decimal columns fall back to interpreted Python.
// - "string" requires the default UTF8_BINARY collation: under a non-binary
// collation (e.g. UTF8_LCASE) Spark's `=`/`<`/`concat` follow collation
// rules while Python compares codepoints, so `'abc' == 'ABC'` would return
@@ -99,6 +105,9 @@ object ResolveTranspiledPythonUDFOptions extends
Rule[LogicalPlan] {
false
} else {
categories.zip(argTypes).forall {
+ case ("integer", dt) => dt.isInstanceOf[IntegralType]
+ case ("float", dt) =>
+ dt.isInstanceOf[FractionalType] && !dt.isInstanceOf[DecimalType]
case ("numeric", dt) => dt.isInstanceOf[NumericType] &&
!dt.isInstanceOf[DecimalType]
case ("string", st: StringType) => st.isUTF8BinaryCollation
case ("bool", dt) => dt.isInstanceOf[BooleanType]
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveTranspiledPythonUDFOptionsSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveTranspiledPythonUDFOptionsSuite.scala
index f9295eb17b00..4a12ecbbcb4c 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveTranspiledPythonUDFOptionsSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveTranspiledPythonUDFOptionsSuite.scala
@@ -19,10 +19,11 @@ package org.apache.spark.sql.catalyst.analysis
import org.apache.spark.api.python.PythonEvalType
import org.apache.spark.sql.catalyst.dsl.expressions._
-import org.apache.spark.sql.catalyst.expressions.{Add, Alias, Concat,
Expression, Literal, PythonUDF, TranspiledPythonUDF}
+import org.apache.spark.sql.catalyst.expressions.{
+ Add, Alias, AttributeReference, Concat, Expression, Literal, PythonUDF,
TranspiledPythonUDF}
import org.apache.spark.sql.catalyst.plans.PlanTest
import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, Project}
-import org.apache.spark.sql.types.LongType
+import org.apache.spark.sql.types.{DecimalType, DoubleType, FloatType,
LongType}
/**
* Unit tests for [[ResolveTranspiledPythonUDFOptions]], which prunes a
@@ -92,4 +93,65 @@ class ResolveTranspiledPythonUDFOptionsSuite extends
PlanTest {
val pruned = prune(node, LocalRelation(a))
assert(pruned.transpiledOptions == List(onlyOpt))
}
+
+ test("keeps the integer option for integral columns and drops float and
string") {
+ val a = $"a".long
+ val intOpt = Add(a, Literal(1L))
+ val floatOpt = Add(a, Literal(1.0))
+ val stringOpt = Concat(Seq(a, a))
+ val node = TranspiledPythonUDF("udf", pyUDF(Seq(a)),
+ List(intOpt, floatOpt, stringOpt),
+ List(List("integer"), List("float"), List("string")))
+ val pruned = prune(node, LocalRelation(a))
+ assert(pruned.transpiledOptions == List(intOpt))
+ assert(pruned.optionInputCategories.isEmpty)
+ }
+
+ test("keeps the float option for fractional columns and drops integer and
string") {
+ val a = AttributeReference("a", DoubleType)()
+ val intOpt = Add(a, Literal(1L))
+ val floatOpt = Add(a, Literal(1.0))
+ val stringOpt = Concat(Seq(a, a))
+ val node = TranspiledPythonUDF("udf", pyUDF(Seq(a)),
+ List(intOpt, floatOpt, stringOpt),
+ List(List("integer"), List("float"), List("string")))
+ val pruned = prune(node, LocalRelation(a))
+ assert(pruned.transpiledOptions == List(floatOpt))
+ assert(pruned.optionInputCategories.isEmpty)
+ }
+
+ test("integer does not match float columns; float does not match integer
columns") {
+ val aLong = $"a".long
+ val aDouble = AttributeReference("a", DoubleType)()
+ val intOpt = Add(aLong, Literal(1L))
+ val floatOpt = Add(aLong, Literal(1.0))
+ // float column, integer-only option -> dropped
+ val intOnlyForDouble = TranspiledPythonUDF("udf", pyUDF(Seq(aDouble)),
+ List(intOpt), List(List("integer")))
+ assert(prune(intOnlyForDouble,
LocalRelation(aDouble)).transpiledOptions.isEmpty)
+ // long column, float-only option -> dropped
+ val floatOnlyForLong = TranspiledPythonUDF("udf", pyUDF(Seq(aLong)),
+ List(floatOpt), List(List("float")))
+ assert(prune(floatOnlyForLong,
LocalRelation(aLong)).transpiledOptions.isEmpty)
+ }
+
+ test("neither integer nor float matches decimal columns") {
+ val a = AttributeReference("a", DecimalType(10, 2))()
+ val intOpt = Add(a, Literal(1L))
+ val floatOpt = Add(a, Literal(1.0))
+ val node = TranspiledPythonUDF("udf", pyUDF(Seq(a)),
+ List(intOpt, floatOpt),
+ List(List("integer"), List("float")))
+ val pruned = prune(node, LocalRelation(a))
+ assert(pruned.transpiledOptions.isEmpty)
+ }
+
+ test("float matches FloatType columns") {
+ val a = AttributeReference("a", FloatType)()
+ val floatOpt = Add(a, Literal(1.0f))
+ val node = TranspiledPythonUDF("udf", pyUDF(Seq(a)),
+ List(floatOpt), List(List("float")))
+ val pruned = prune(node, LocalRelation(a))
+ assert(pruned.transpiledOptions == List(floatOpt))
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]