| # | |
| # 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. | |
| # | |
| """ | |
| Unit tests for UDF transpilation. | |
| These were previously interleaved with the broader UDF mixin in | |
| ``test_udf.py``. They are split out because UDF transpilation is currently | |
| only supported in regular (non-Connect) Spark, so they should not be | |
| inherited into the Spark Connect parity test class. The companion | |
| property-based suite lives in ``test_udf_transpile_hypothesis.py``. | |
| """ | |
| import unittest | |
| from pyspark.sql import Row | |
| from pyspark.sql.transpile import CatalystTranspiler, _get_transpilers | |
| from pyspark.sql.types import ( | |
| BinaryType, | |
| BooleanType, | |
| DoubleType, | |
| LongType, | |
| StringType, | |
| ) | |
| from pyspark.sql.udf import UserDefinedFunction | |
| from pyspark.testing.sqlutils import ReusedSQLTestCase | |
| from pyspark.util import is_remote_only | |
| # Both flags must be on for the transpiler to attempt a rewrite (at UDF | |
| # construction time and again in the optimizer); ANSI is required because | |
| # transpilation targets ANSI semantics. | |
| _TRANSPILE_ON = { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| # The interpreted path to compare against where ANSI is what makes the two differ. | |
| _TRANSPILE_OFF_ANSI_ON = { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": False, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| @unittest.skipIf( | |
| is_remote_only(), | |
| "UDF transpilation is only supported in regular (non-Connect) Spark.", | |
| ) | |
| class UDFTranspileUnitTests(ReusedSQLTestCase): | |
| def test_udf_transpile_basic(self): | |
| # Test callable object | |
| class PlusFour: | |
| def __call__(self, col): | |
| return col + 4 | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| # Make sure we can transpile the object | |
| call = PlusFour() | |
| pudf = UserDefinedFunction(call, LongType()) | |
| self.assertTrue(pudf.transpiled) | |
| # Now make sure we can run the transpiled UDF* | |
| input_df = self.spark.createDataFrame([Row(a=1)]) | |
| transformed_df = input_df.select(pudf("a")) | |
| [row] = transformed_df.collect() | |
| self.assertEqual(row[0], 5) | |
| with self.sql_conf({"spark.sql.experimental.optimizer.transpilePyUDFs": False}): | |
| call = PlusFour() | |
| pudf = UserDefinedFunction(call, LongType()) | |
| self.assertEqual([], pudf.transpiled) | |
| # Now make sure we can run the UDF | |
| input_df = self.spark.createDataFrame([Row(a=1)]) | |
| transformed_df = input_df.select(pudf("a")) | |
| [row] = transformed_df.collect() | |
| self.assertEqual(row[0], 5) | |
| def test_udf_transpile_with_nones(self): | |
| # Test callable object | |
| class PlusFour: | |
| def __call__(self, col): | |
| if col is not None: | |
| return col + 4 | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| # Make sure we can transpile the object | |
| call = PlusFour() | |
| pudf = UserDefinedFunction(call, LongType()) | |
| self.assertTrue(pudf.transpiled) | |
| # Now make sure we can run the transpiled UDF* | |
| input_df = self.spark.createDataFrame([Row(a=1)]) | |
| transformed_df = input_df.select(pudf("a").alias("result")) | |
| [row] = transformed_df.collect() | |
| self.assertEqual(row[0], 5) | |
| physical_plan = transformed_df._jdf.queryExecution().executedPlan().toString() | |
| self.assertNotIn("UDF", physical_plan) | |
| with self.sql_conf({"spark.sql.experimental.optimizer.transpilePyUDFs": False}): | |
| call = PlusFour() | |
| pudf = UserDefinedFunction(call, LongType()) | |
| self.assertEqual([], pudf.transpiled) | |
| # Now make sure we can run the UDF | |
| input_df = self.spark.createDataFrame([Row(a=1)]) | |
| transformed_df = input_df.select(pudf("a").alias("result")) | |
| [row] = transformed_df.collect() | |
| self.assertEqual(row[0], 5) | |
| physical_plan = transformed_df._jdf.queryExecution().executedPlan().toString() | |
| self.assertIn("UDF", physical_plan) | |
| def test_udf_not_transpilable(self): | |
| class UnsupportedEx: | |
| def __call__(self, col): | |
| if col is not None: | |
| return col in "4" | |
| with self.sql_conf({"spark.sql.experimental.optimizer.transpilePyUDFs": True}): | |
| call = UnsupportedEx() | |
| pudf = UserDefinedFunction(call, BooleanType()) | |
| self.assertEqual([], pudf.transpiled) | |
| def test_udf_transpile_requires_ansi(self): | |
| # Transpilation targets ANSI semantics. With ANSI off the transpiler | |
| # must skip rewriting (and warn the user) so we don't silently | |
| # diverge from the Python interpretation; with ANSI on it should | |
| # produce a Catalyst expression. | |
| import warnings | |
| def plus_four(x): | |
| if x is not None: | |
| return x + 4 | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": False, | |
| } | |
| ): | |
| with warnings.catch_warnings(record=True) as caught: | |
| warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(plus_four, LongType()) | |
| self.assertEqual([], pudf.transpiled) | |
| ansi_warnings = [w for w in caught if "ANSI mode" in str(w.message)] | |
| self.assertTrue( | |
| ansi_warnings, | |
| "expected an 'ANSI mode' warning when transpilation is " | |
| "requested but ANSI is disabled", | |
| ) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| pudf = UserDefinedFunction(plus_four, LongType()) | |
| self.assertTrue( | |
| pudf.transpiled, | |
| "expected transpilation to produce a Catalyst expression " | |
| "when both transpilePyUDFs and ANSI mode are enabled", | |
| ) | |
| def test_get_transpilers_warns_on_unknown_names(self): | |
| import warnings | |
| for names, expected, unknown in [ | |
| ("catalyst", [CatalystTranspiler], None), | |
| ("catalyst,catalist", [CatalystTranspiler], "catalist"), | |
| ("catalist", [], "catalist"), | |
| ]: | |
| with self.subTest(names=names): | |
| with self.sql_conf({"spark.sql.experimental.optimizer.pyTranspilers": names}): | |
| with warnings.catch_warnings(record=True) as caught: | |
| warnings.simplefilter("always") | |
| transpilers = _get_transpilers(self.spark) | |
| self.assertEqual([type(t) for t in transpilers], expected) | |
| messages = [str(w.message) for w in caught if "pyTranspilers" in str(w.message)] | |
| if unknown is None: | |
| self.assertEqual(messages, []) | |
| else: | |
| self.assertEqual(len(messages), 1) | |
| self.assertIn(repr(unknown), messages[0]) | |
| def test_udf_transpile_does_not_mask_char_varchar_ddl(self): | |
| # DDL CHAR/VARCHAR is parsed via ``returnType`` inside the optional | |
| # transpilation try. That structured error must not become a | |
| # transpilation UserWarning (which warnings-as-errors would raise). | |
| import warnings | |
| from pyspark.errors import PySparkNotImplementedError | |
| def identity(x): | |
| return x | |
| def _assert_char_varchar(return_type, data_type): | |
| with self.assertRaises(PySparkNotImplementedError) as pe: | |
| UserDefinedFunction(identity, return_type) | |
| self.check_error( | |
| exception=pe.exception, | |
| errorClass="CHAR_VARCHAR_NOT_SUPPORTED_IN_PYTHON", | |
| messageParameters={ | |
| "feature": "Python UDF return types", | |
| "data_type": data_type, | |
| }, | |
| ) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with warnings.catch_warnings(record=True) as caught: | |
| warnings.simplefilter("always") | |
| _assert_char_varchar("char(3)", "char(3)") | |
| self.assertFalse( | |
| any("Exception transpiling" in str(w.message) for w in caught), | |
| "CHAR/VARCHAR DDL must not be reported as a transpilation failure", | |
| ) | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("error") | |
| _assert_char_varchar("varchar(8)", "varchar(8)") | |
| def test_udf_transpile_falls_back_for_unsupported_patterns(self): | |
| # The transpiler intentionally only handles a small subset of | |
| # Python AST today. Everything outside that subset must | |
| # gracefully fall back to interpreted Python (with an empty | |
| # `transpiled` list and a UserWarning) rather than break the | |
| # UDF -- the "don't break people's Spark code" promise. This test | |
| # walks the most common unsupported shapes, registers each as a | |
| # UDF with transpilation on, and asserts (a) construction does | |
| # not raise, (b) `transpiled == []`, (c) the UDF still produces | |
| # the correct interpreted result. | |
| def divide_by_two(x): # `/` -- ast.Div, not handled. | |
| if x is not None: | |
| return x / 2 | |
| def floor_divide_by_two(x): # `//` -- ast.FloorDiv, not handled. | |
| if x is not None: | |
| return x // 2 | |
| def bit_and_one(x): # `&` -- ast.BitAnd, not handled. | |
| if x is not None: | |
| return x & 1 | |
| def bit_or_one(x): # `|` -- ast.BitOr, not handled. | |
| if x is not None: | |
| return x | 1 | |
| def left_shift(x): # `<<` -- ast.LShift, not handled. | |
| if x is not None: | |
| return x << 1 | |
| def multi_statement(x): # First stmt is an assignment, not an if-guard. | |
| y = 1 | |
| return x + y if x is not None else 0 | |
| def func_closure_capture(x): | |
| offset = 7 | |
| if x is not None: | |
| return x + offset | |
| cases = [ | |
| ("divide_by_two", divide_by_two, DoubleType(), Row(a=4.0), 2.0), | |
| ("floor_divide_by_two", floor_divide_by_two, LongType(), Row(a=5), 2), | |
| ("bit_and_one", bit_and_one, LongType(), Row(a=5), 1), | |
| ("bit_or_one", bit_or_one, LongType(), Row(a=4), 5), | |
| ("left_shift", left_shift, LongType(), Row(a=3), 6), | |
| ("multi_statement", multi_statement, LongType(), Row(a=5), 6), | |
| ("func_closure_capture", func_closure_capture, LongType(), Row(a=10), 17), | |
| ] | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| for label, func, return_type, row, expected in cases: | |
| with self.subTest(case=label): | |
| import warnings as _warnings | |
| with _warnings.catch_warnings(record=True) as caught_warnings: | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(func, return_type) | |
| self.assertEqual( | |
| [], | |
| pudf.transpiled, | |
| f"{label}: transpiler should not produce a Catalyst " | |
| "expression for this AST shape", | |
| ) | |
| fallback = [ | |
| w | |
| for w in caught_warnings | |
| if "Unable to transpile" in str(w.message) | |
| or "Errors encountered" in str(w.message) | |
| or "Exception transpiling" in str(w.message) | |
| ] | |
| self.assertTrue( | |
| fallback, | |
| f"{label}: expected a fallback warning when the " | |
| "transpiler can't lower the function", | |
| ) | |
| df = self.spark.createDataFrame([row]) | |
| [result] = df.select(pudf("a")).collect() | |
| self.assertEqual( | |
| result[0], | |
| expected, | |
| f"{label}: interpreted UDF result diverged from expected", | |
| ) | |
| def test_udf_transpile_boolean_and_or_lowered(self): | |
| # When `and`/`or` operands are syntactically boolean (Compare | |
| # results in this case), the transpiler should lower to bitwise | |
| # `&`/`|` and produce results matching the interpreted UDF. | |
| # Each UDF is a single top-level statement (the transpiler | |
| # doesn't support multi-statement bodies yet). | |
| from pyspark.sql.types import StructField, StructType | |
| def both_positive(x, y): | |
| return x > 0 and y > 0 | |
| def either_positive(x, y): | |
| return x > 0 or y > 0 | |
| schema = StructType( | |
| [ | |
| StructField("a", LongType(), nullable=True), | |
| StructField("b", LongType(), nullable=True), | |
| ] | |
| ) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| # NULL inputs propagate through `>` to NULL, which then | |
| # passes through `&` / `|` per SQL three-valued logic. We | |
| # only assert on non-NULL inputs here since Python's | |
| # interpreted `x > 0 and y > 0` would raise on None; the | |
| # NULL handling itself is covered by the hypothesis suite. | |
| for func, x, y, expected in [ | |
| (both_positive, 1, 2, True), | |
| (both_positive, 1, -1, False), | |
| (both_positive, -1, -1, False), | |
| (either_positive, -1, 2, True), | |
| (either_positive, -1, -1, False), | |
| (either_positive, 1, 1, True), | |
| ]: | |
| with self.subTest(func=func.__name__, x=x, y=y): | |
| pudf = UserDefinedFunction(func, BooleanType()) | |
| self.assertTrue( | |
| pudf.transpiled, | |
| f"{func.__name__}: bool-typed and/or should transpile", | |
| ) | |
| df = self.spark.createDataFrame([Row(a=x, b=y)], schema=schema) | |
| [row] = df.select(pudf("a", "b")).collect() | |
| self.assertEqual(row[0], expected) | |
| def test_udf_transpile_less_than_zero(self): | |
| # Restored from the unsupported-patterns matrix: now that the | |
| # transpiler handles ast.Lt, `x < 0` should lower to a Catalyst | |
| # expression and match interpreted Python. The ``is not None`` | |
| # guard short-circuits None inputs through the else branch, so | |
| # the comparison itself never sees a NULL in this UDF. | |
| from pyspark.sql.types import StructField, StructType | |
| def less_than_zero(x): | |
| if x is not None: | |
| return x < 0 | |
| schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| pudf = UserDefinedFunction(less_than_zero, BooleanType()) | |
| self.assertTrue(pudf.transpiled, "less_than_zero should now transpile") | |
| for value, expected in [(-1, True), (0, False), (5, False), (None, None)]: | |
| with self.subTest(value=value): | |
| df = self.spark.createDataFrame([Row(a=value)], schema=schema) | |
| [row] = df.select(pudf("a")).collect() | |
| self.assertEqual(row[0], expected) | |
| def test_udf_transpile_compare_with_none_raises(self): | |
| # When a comparison's operand is NULL in Spark, Python would have | |
| # raised TypeError ('>' not supported between NoneType and int). | |
| # The transpiler wraps Compare ops with a raise_error guard so | |
| # the rewritten plan fails loudly instead of silently producing | |
| # NULL three-valued-logic results. | |
| from pyspark.sql.types import StructField, StructType | |
| def gt_zero(x): | |
| return x > 0 | |
| schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| pudf = UserDefinedFunction(gt_zero, BooleanType()) | |
| self.assertTrue(pudf.transpiled, "gt_zero should transpile") | |
| df = self.spark.createDataFrame([Row(a=None)], schema=schema) | |
| with self.assertRaises(Exception) as ctx: | |
| df.select(pudf("a")).collect() | |
| self.assertIn("cannot compare NULL", str(ctx.exception)) | |
| def test_udf_transpile_eq_none_semantics(self): | |
| # Python ``==``/``!=`` differ from Spark's three-valued NULL equality: | |
| # in Python ``None == None`` is ``True`` and ``None == 0`` is ``False``, | |
| # whereas SQL ``NULL = NULL`` and ``NULL = 0`` both yield ``NULL``. The | |
| # transpiler's ``_lower_eq`` reproduces Python's semantics; this test | |
| # exercises every arm of that logic. | |
| from pyspark.sql.types import StructField, StructType | |
| def x_eq_zero(x): | |
| if x is not None: | |
| return x == 0 | |
| else: | |
| return None | |
| def x_neq_zero(x): | |
| if x is not None: | |
| return x != 0 | |
| else: | |
| return None | |
| def x_eq_y(x, y): | |
| return x == y | |
| def x_neq_y(x, y): | |
| return x != y | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| two_col_schema = StructType( | |
| [ | |
| StructField("a", LongType(), nullable=True), | |
| StructField("b", LongType(), nullable=True), | |
| ] | |
| ) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| # Single-arg ``x == 0`` / ``x != 0`` with a None guard. | |
| pudf_eq = UserDefinedFunction(x_eq_zero, BooleanType()) | |
| pudf_neq = UserDefinedFunction(x_neq_zero, BooleanType()) | |
| self.assertTrue(pudf_eq.transpiled, "x == 0 should transpile") | |
| self.assertTrue(pudf_neq.transpiled, "x != 0 should transpile") | |
| for value, eq_expected, neq_expected in [ | |
| (0, True, False), | |
| (1, False, True), | |
| (-3, False, True), | |
| (None, None, None), | |
| ]: | |
| with self.subTest(value=value): | |
| df = self.spark.createDataFrame([Row(a=value)], schema=long_schema) | |
| [row_eq] = df.select(pudf_eq("a")).collect() | |
| [row_neq] = df.select(pudf_neq("a")).collect() | |
| self.assertEqual(row_eq[0], eq_expected) | |
| self.assertEqual(row_neq[0], neq_expected) | |
| # Two-arg ``x == y`` / ``x != y`` exercising every NULL combination. | |
| pudf_eq_xy = UserDefinedFunction(x_eq_y, BooleanType()) | |
| pudf_neq_xy = UserDefinedFunction(x_neq_y, BooleanType()) | |
| self.assertTrue(pudf_eq_xy.transpiled, "x == y should transpile") | |
| self.assertTrue(pudf_neq_xy.transpiled, "x != y should transpile") | |
| # Python semantics: | |
| # None == None -> True; None != None -> False | |
| # None == 0 -> False; None != 0 -> True | |
| # 0 == None -> False; 0 != None -> True | |
| # 1 == 1 -> True; 1 != 1 -> False | |
| # 1 == 2 -> False; 1 != 2 -> True | |
| for x, y, eq_expected, neq_expected in [ | |
| (None, None, True, False), | |
| (None, 0, False, True), | |
| (0, None, False, True), | |
| (1, 1, True, False), | |
| (1, 2, False, True), | |
| ]: | |
| with self.subTest(x=x, y=y): | |
| df = self.spark.createDataFrame([Row(a=x, b=y)], schema=two_col_schema) | |
| [row_eq] = df.select(pudf_eq_xy("a", "b")).collect() | |
| [row_neq] = df.select(pudf_neq_xy("a", "b")).collect() | |
| self.assertEqual(row_eq[0], eq_expected, f"({x} == {y})") | |
| self.assertEqual(row_neq[0], neq_expected, f"({x} != {y})") | |
| def test_udf_transpile_lte_gte(self): | |
| # ``<=`` and ``>=`` go through the same ``_lower_value_compare`` path | |
| # as ``<`` / ``>`` (and so share the NULL-raises-TypeError guard), but | |
| # the entry points are not exercised elsewhere. Cover both with a None | |
| # guard so the comparison only sees non-NULL operands here. | |
| from pyspark.sql.types import StructField, StructType | |
| def lte_zero(x): | |
| if x is not None: | |
| return x <= 0 | |
| def gte_zero(x): | |
| if x is not None: | |
| return x >= 0 | |
| schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| pudf_lte = UserDefinedFunction(lte_zero, BooleanType()) | |
| pudf_gte = UserDefinedFunction(gte_zero, BooleanType()) | |
| self.assertTrue(pudf_lte.transpiled, "x <= 0 should transpile") | |
| self.assertTrue(pudf_gte.transpiled, "x >= 0 should transpile") | |
| for value, lte_expected, gte_expected in [ | |
| (-1, True, False), | |
| (0, True, True), | |
| (1, False, True), | |
| (None, None, None), | |
| ]: | |
| with self.subTest(value=value): | |
| df = self.spark.createDataFrame([Row(a=value)], schema=schema) | |
| [row_lte] = df.select(pudf_lte("a")).collect() | |
| [row_gte] = df.select(pudf_gte("a")).collect() | |
| self.assertEqual(row_lte[0], lte_expected) | |
| self.assertEqual(row_gte[0], gte_expected) | |
| def test_udf_transpile_chained_comparison_falls_back(self): | |
| # ``a < b < c`` is a chained comparison: Python evaluates it as | |
| # ``(a < b) and (b < c)``. The transpiler refuses chained Compare | |
| # nodes (``len(ops) != 1``) and must fall back to interpreted Python. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def chained(x): | |
| return 0 < x < 10 | |
| schema = StructType([StructField("a", LongType(), nullable=False)]) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| with _warnings.catch_warnings(record=True) as caught: | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(chained, BooleanType()) | |
| self.assertEqual([], pudf.transpiled, "chained comparison must NOT transpile") | |
| fallback = [ | |
| w | |
| for w in caught | |
| if "Unable to transpile" in str(w.message) or "Errors encountered" in str(w.message) | |
| ] | |
| self.assertTrue(fallback, "expected a fallback warning") | |
| for value, expected in [(5, True), (0, False), (10, False), (-3, False)]: | |
| with self.subTest(value=value): | |
| df = self.spark.createDataFrame([Row(a=value)], schema=schema) | |
| [row] = df.select(pudf("a")).collect() | |
| self.assertEqual(row[0], expected) | |
| def test_udf_transpile_multi_row(self): | |
| # Every other transpile test uses a 1-row DataFrame; this one runs | |
| # the same arithmetic transpile on a multi-row input to catch any | |
| # column-reference / batch-boundary bug that single-row tests can't. | |
| from pyspark.sql.types import StructField, StructType | |
| def plus_four(x): | |
| if x is not None: | |
| return x + 4 | |
| schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| pudf = UserDefinedFunction(plus_four, LongType()) | |
| self.assertTrue(pudf.transpiled) | |
| inputs = [Row(a=v) for v in [-3, -1, 0, 1, 7, None, 100]] | |
| df = self.spark.createDataFrame(inputs, schema=schema) | |
| transformed_df = df.select(pudf("a").alias("result")) | |
| rows = transformed_df.collect() | |
| actual = [row[0] for row in rows] | |
| expected = [None if v is None else v + 4 for v in [-3, -1, 0, 1, 7, None, 100]] | |
| self.assertEqual(actual, expected) | |
| # Plan should also have the UDF stripped under the rewrite. | |
| physical_plan = transformed_df._jdf.queryExecution().executedPlan().toString() | |
| self.assertNotIn("UDF", physical_plan) | |
| def test_udf_transpile_falls_back_for_non_boolean_short_circuit(self): | |
| # Python's `x or 0` returns x if truthy else 0; Spark's `|` is | |
| # bitwise, so we'd silently produce wrong results. The transpiler | |
| # must refuse, fall back to interpreted Python, and still produce | |
| # the correct result. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def or_zero(x): | |
| return x or 0 | |
| def and_one(x): | |
| return x and 1 | |
| def not_int(x): | |
| return not 0 + x # operand is BinOp, statically non-boolean | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| cases = [ | |
| ("or_zero", or_zero, LongType(), long_schema, Row(a=5), 5), | |
| ("or_zero_none", or_zero, LongType(), long_schema, Row(a=None), 0), | |
| ("and_one", and_one, LongType(), long_schema, Row(a=5), 1), | |
| ("and_one_zero", and_one, LongType(), long_schema, Row(a=0), 0), | |
| ("not_int", not_int, BooleanType(), long_schema, Row(a=0), True), | |
| ("not_int_nonzero", not_int, BooleanType(), long_schema, Row(a=3), False), | |
| ] | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| for label, func, return_type, schema, row, expected in cases: | |
| with self.subTest(case=label): | |
| with _warnings.catch_warnings(record=True) as caught: | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(func, return_type) | |
| self.assertEqual( | |
| [], | |
| pudf.transpiled, | |
| f"{label}: non-boolean and/or/not must NOT be lowered", | |
| ) | |
| fallback = [ | |
| w | |
| for w in caught | |
| if "Unable to transpile" in str(w.message) | |
| or "Errors encountered" in str(w.message) | |
| ] | |
| self.assertTrue(fallback, f"{label}: expected a fallback warning") | |
| df = self.spark.createDataFrame([row], schema=schema) | |
| [result] = df.select(pudf("a")).collect() | |
| self.assertEqual(result[0], expected, f"{label}: interpreted mismatch") | |
| def test_udf_transpile_bare_if_truthiness(self): | |
| # `if x:` / ternary `x if x else y` on numeric and string columns must | |
| # now transpile (SPARK-56925). The transpiler lowers to a type-specific | |
| # truthiness expression: | |
| # numeric -> coalesce(x != 0, False) | |
| # string -> coalesce(length(x) > 0, False) | |
| # bool -> coalesce(x, False) (same as the existing boolean path) | |
| # NULL (Python None) is always treated as falsy, matching Python. | |
| from pyspark.sql.types import DoubleType, StructField, StructType | |
| def truthy_int(x): | |
| if x: | |
| return x | |
| else: | |
| return -1 | |
| def truthy_str(x): | |
| return x if x else "default" | |
| def truthy_float(x): | |
| # NaN is truthy in Python (bool(float('nan')) == True). | |
| return 1 if x else 0 | |
| def truthy_bool(x: bool): | |
| return "yes" if x else "no" | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| str_schema = StructType([StructField("a", StringType(), nullable=True)]) | |
| dbl_schema = StructType([StructField("a", DoubleType(), nullable=True)]) | |
| bool_schema = StructType([StructField("a", BooleanType(), nullable=True)]) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| # --- integer --- | |
| pudf_int = UserDefinedFunction(truthy_int, LongType()) | |
| self.assertTrue(pudf_int.transpiled, "integer bare-if should transpile") | |
| df_int = self.spark.createDataFrame([(3,), (0,), (None,)], schema=long_schema) | |
| projected = df_int.select(pudf_int("a").alias("r")) | |
| self.assertEqual(0, self._eval_python_count(projected)) | |
| self.assertEqual([r[0] for r in projected.collect()], [3, -1, -1]) | |
| # --- string --- | |
| pudf_str = UserDefinedFunction(truthy_str, StringType()) | |
| self.assertTrue(pudf_str.transpiled, "string bare-if should transpile") | |
| df_str = self.spark.createDataFrame([("hi",), ("",), (None,)], schema=str_schema) | |
| projected = df_str.select(pudf_str("a").alias("r")) | |
| self.assertEqual(0, self._eval_python_count(projected)) | |
| self.assertEqual([r[0] for r in projected.collect()], ["hi", "default", "default"]) | |
| # --- float (NaN is truthy in Python) --- | |
| pudf_flt = UserDefinedFunction(truthy_float, LongType()) | |
| self.assertTrue(pudf_flt.transpiled, "float bare-if should transpile") | |
| nan = float("nan") | |
| df_flt = self.spark.createDataFrame( | |
| [(1.5,), (0.0,), (nan,), (None,)], schema=dbl_schema | |
| ) | |
| projected = df_flt.select(pudf_flt("a").alias("r")) | |
| self.assertEqual(0, self._eval_python_count(projected)) | |
| # 1.5 truthy, 0.0 falsy, NaN truthy (NaN != 0 is True in Spark), NULL falsy | |
| self.assertEqual([r[0] for r in projected.collect()], [1, 0, 1, 0]) | |
| # --- bool --- | |
| pudf_bool = UserDefinedFunction(truthy_bool, StringType()) | |
| self.assertTrue(pudf_bool.transpiled, "bool bare-if should transpile") | |
| df_bool = self.spark.createDataFrame([(True,), (False,), (None,)], schema=bool_schema) | |
| projected = df_bool.select(pudf_bool("a").alias("r")) | |
| self.assertEqual(0, self._eval_python_count(projected)) | |
| self.assertEqual([r[0] for r in projected.collect()], ["yes", "no", "no"]) | |
| def test_udf_transpile_falls_back_for_bare_truthiness_test(self): | |
| # For categories the transpiler cannot lower to a truthiness expression | |
| # (currently "binary"), bare `if x:` must still fall back to interpreted | |
| # Python rather than silently emitting a wrong or un-analyzable plan. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| # bytes annotation -> "binary" category -> _truthiness_col returns None | |
| def truthy_bytes(x: bytes) -> bool: | |
| if x: | |
| return True | |
| return False | |
| bin_schema = StructType([StructField("a", BinaryType(), nullable=True)]) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with _warnings.catch_warnings(record=True) as caught: | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(truthy_bytes, BooleanType()) | |
| self.assertEqual( | |
| [], | |
| pudf.transpiled, | |
| "binary bare-if must NOT be lowered to Catalyst", | |
| ) | |
| fallback = [ | |
| w | |
| for w in caught | |
| if "Unable to transpile" in str(w.message) or "Errors encountered" in str(w.message) | |
| ] | |
| self.assertTrue(fallback, "expected a fallback warning for binary bare-if") | |
| df = self.spark.createDataFrame([(b"hi",), (b"",), (None,)], schema=bin_schema) | |
| results = [r[0] for r in df.select(pudf("a")).collect()] | |
| self.assertEqual(results, [True, False, False]) | |
| def test_udf_transpile_falls_back_for_mismatched_branch_types(self): | |
| # An if/ternary whose two branches produce different Spark categories | |
| # (e.g. numeric vs string) would lower to a CASE WHEN whose branch | |
| # values share no common type under ANSI. That node is carried as a | |
| # child of the TranspiledPythonUDF and is type-checked by CheckAnalysis | |
| # before ConvertToCatalyst can drop it, so without a guard the whole | |
| # query would fail analysis instead of falling back. The transpiler must | |
| # refuse and run the UDF as interpreted Python. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def mixed_ternary(x): | |
| return 1 if x > 0 else "neg" | |
| def mixed_if(x): | |
| # Single top-level `if`/`else` so the If-statement lowering path | |
| # (not the "more than one statement" fallback) exercises the guard. | |
| if x > 0: | |
| return "pos" | |
| else: | |
| return x | |
| # Positive control: matching-category branches must still transpile, so | |
| # the guard does not over-refuse. | |
| def homogeneous(x): | |
| return x if x > 0 else 0 | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| # Inputs are chosen to take the string-returning branch so the | |
| # interpreted result is unambiguous. | |
| mismatch_cases = [ | |
| ("mixed_ternary", mixed_ternary, Row(a=-3), "neg"), | |
| ("mixed_if", mixed_if, Row(a=10), "pos"), | |
| ] | |
| for label, func, row, expected in mismatch_cases: | |
| with self.subTest(case=label): | |
| with _warnings.catch_warnings(record=True) as caught: | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(func, StringType()) | |
| self.assertEqual( | |
| [], | |
| pudf.transpiled, | |
| f"{label}: mismatched branch types must NOT be lowered to Catalyst", | |
| ) | |
| fallback = [w for w in caught if "Unable to transpile" in str(w.message)] | |
| self.assertTrue(fallback, f"{label}: expected a fallback warning") | |
| df = self.spark.createDataFrame([row], schema=long_schema) | |
| # Must run without an analysis failure and match interpreted Python. | |
| [result] = df.select(pudf("a")).collect() | |
| self.assertEqual(result[0], expected, f"{label}: interpreted mismatch") | |
| with self.subTest(case="homogeneous"): | |
| pudf = UserDefinedFunction(homogeneous, LongType()) | |
| self.assertNotEqual( | |
| [], | |
| pudf.transpiled, | |
| "matching-category branches must still transpile", | |
| ) | |
| df = self.spark.createDataFrame([Row(a=5), Row(a=-3)], schema=long_schema) | |
| results = [r[0] for r in df.select(pudf("a")).collect()] | |
| self.assertEqual(results, [5, 0], "homogeneous branch result mismatch") | |
| def test_udf_transpile_falls_back_for_cross_category_eq(self): | |
| # `x == True` on a numeric column would lower to `x = true`, which | |
| # fails ANSI analysis (BIGINT vs BOOLEAN) while the option is still a | |
| # child of the TranspiledPythonUDF -- breaking a working UDF. The | |
| # category gate must refuse so it runs as interpreted Python. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def eq_true(x): | |
| return x == True # noqa: E712 | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(eq_true, BooleanType()) | |
| self.assertEqual([], pudf.transpiled, "cross-category == must not transpile") | |
| df = self.spark.createDataFrame([Row(a=5), Row(a=1)], schema=long_schema) | |
| results = [r[0] for r in df.select(pudf("a")).collect()] | |
| self.assertEqual(results, [5 == True, 1 == True]) | |
| def test_udf_transpile_falls_back_for_nested_ternary_eq(self): | |
| # A ternary operand used inside `==` must contribute its branches' | |
| # category, not the old "numeric" catch-all: `("5" if c else "6") == 5` | |
| # previously passed the equality guard as numeric-vs-numeric and | |
| # Spark's string-number coercion silently returned True where Python's | |
| # cross-type == is False. (Reported by Codex review on PR #34.) | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def nested_ternary_eq(x): | |
| return ("5" if x > 0 else "6") == 5 | |
| def none_branch_ternary_eq(x): | |
| return ("5" if x > 0 else None) == 5 | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| df = self.spark.createDataFrame([Row(a=5), Row(a=-5)], schema=long_schema) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for func in [nested_ternary_eq, none_branch_ternary_eq]: | |
| with self.subTest(func=func.__name__): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(func, BooleanType()) | |
| self.assertEqual( | |
| [], pudf.transpiled, "string-ternary == int must not transpile" | |
| ) | |
| results = [r[0] for r in df.select(pudf("a")).collect()] | |
| self.assertEqual(results, [False, False], "must match Python's ==") | |
| def test_udf_transpile_str_int_compare_matches_python(self): | |
| # Comparing a value against a string literal (``x == "5"`` / ``x < "5"``) | |
| # under the untyped-parameter path produces two candidate options -- a | |
| # numeric variant and a string variant. The numeric variant mixes | |
| # categories (numeric column vs string literal): Python compares such | |
| # values as unequal / raises TypeError, while a lowered ``x = '5'`` would | |
| # coerce under ANSI and silently diverge -- so ``_lower_eq`` / | |
| # ``_lower_value_compare`` refuse it. Only the string variant survives. | |
| # When the UDF is applied to a numeric column, ResolveTranspiledPython- | |
| # UDFOptions drops the string option (no category match) and the UDF | |
| # falls back to interpreted Python. We verify the observable guarantee on | |
| # both sides: ``==`` returns Python's result (no coerced ``True``), and | |
| # ``<`` raises on the Python side directly and surfaces the same error | |
| # through Spark rather than returning a coerced answer. | |
| from pyspark.errors import PythonException | |
| from pyspark.sql.types import StructField, StructType | |
| def eq_str(x): | |
| return x == "5" | |
| def lt_str(x): | |
| return x < "5" | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| df = self.spark.createDataFrame([Row(a=5), Row(a=1)], schema=long_schema) | |
| # ``==`` : numeric column -> string option dropped -> interpreted | |
| # Python, so the result matches ``5 == "5"`` (False), not a coerced | |
| # ``True`` from ``bigint = '5'``. | |
| pudf_eq = UserDefinedFunction(eq_str, BooleanType()) | |
| results = [r[0] for r in df.select(pudf_eq("a")).collect()] | |
| self.assertEqual(results, [5 == "5", 1 == "5"], "must match Python's ==") | |
| # ``<`` : Python raises TypeError for ``int < str``; the transpiled | |
| # string option is dropped for a numeric column, so Spark runs the | |
| # interpreted UDF and surfaces the same error rather than coercing. | |
| # Asserting PythonException (not a bare Exception) pins this to the | |
| # interpreted-fallback path: a Catalyst AnalysisException here would | |
| # instead mean the string option was wrongly kept and broke the | |
| # query rather than falling back. | |
| pudf_lt = UserDefinedFunction(lt_str, BooleanType()) | |
| with self.assertRaises(TypeError): | |
| lt_str(5) | |
| with self.assertRaises(PythonException) as ctx: | |
| df.select(pudf_lt("a")).collect() | |
| self.assertIn("not supported between", str(ctx.exception)) | |
| def test_udf_transpile_falls_back_for_bool_arithmetic(self): | |
| # `(x > 0) + 1` is valid Python (True + 1 == 2), but the lowered | |
| # Add(boolean, int) fails ANSI analysis. The category of a | |
| # boolean-producing operand is now "bool" (not the numeric catch-all), | |
| # so this refuses and runs as interpreted Python. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def bool_plus_one(x): | |
| return (x > 0) + 1 | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(bool_plus_one, LongType()) | |
| self.assertEqual([], pudf.transpiled, "bool arithmetic must not transpile") | |
| df = self.spark.createDataFrame([Row(a=5), Row(a=-5)], schema=long_schema) | |
| results = [r[0] for r in df.select(pudf("a")).collect()] | |
| self.assertEqual(results, [2, 1]) | |
| def test_udf_transpile_falls_back_for_return_wrapped_bool_branch(self): | |
| # If-statement branches arrive as ast.Return nodes; the branch-category | |
| # guard must see through the wrapper. A boolean-returning branch vs a | |
| # numeric one previously slipped past the guard and failed analysis | |
| # (CASE WHEN [BOOLEAN, INT]) instead of falling back. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def mixed(x): | |
| if x > 0: | |
| return x > 5 | |
| else: | |
| return 1 | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(mixed, LongType()) | |
| self.assertEqual([], pudf.transpiled, "bool-vs-int branches must not transpile") | |
| df = self.spark.createDataFrame([Row(a=-3)], schema=long_schema) | |
| [result] = df.select(pudf("a")).collect() | |
| self.assertEqual(result[0], 1) | |
| def test_udf_transpile_plain_self_param_is_an_ordinary_param(self): | |
| # A plain function whose first parameter is literally named `self` is not a | |
| # bound receiver -- the call site supplies it. Stripping it emitted | |
| # `_udf_param_-1` and threw at call construction, so it used to be refused | |
| # outright; the receiver is decided by dispatch now, so this lowers. | |
| def weird(self, other): | |
| return self + other | |
| self.assertEqual( | |
| self._vals(weird, LongType(), "a long, b long", [(2, 3)]), | |
| [5], | |
| "both parameters are supplied at the call site, so both get placeholders", | |
| ) | |
| def test_udf_transpile_positional_only_params_lower(self): | |
| # No placeholder-omission hazard -- always bound by position -- so this | |
| # lowers now, unlike default/variadic/keyword-only params. Typed str/int | |
| # and value-dependent on both, so a swapped placeholder or category | |
| # fails on the result, not just a missing lowering. | |
| def two_posonly(a: str, b: int, /): | |
| return a if b > 0 else "-" | |
| def mixed_posonly(a: str, /, b: int): | |
| return a if b > 0 else "-" | |
| posonly_lambda = lambda x, /, y: y - x # noqa: E731 | |
| self.assertEqual( | |
| self._vals(two_posonly, StringType(), "a string, b long", [("xy", 5)]), ["xy"] | |
| ) | |
| self.assertEqual( | |
| self._vals(mixed_posonly, StringType(), "a string, b long", [("xy", 5)]), ["xy"] | |
| ) | |
| self.assertEqual(self._vals(posonly_lambda, LongType(), "a long, b long", [(2, 5)]), [3]) | |
| def test_udf_transpile_positional_only_param_with_default_still_falls_back(self): | |
| # A default's a default whether it's positional-only or not. | |
| def posonly_with_default(a, b=1, /): | |
| return a + b | |
| with self.sql_conf(_TRANSPILE_ON): | |
| pudf = UserDefinedFunction(posonly_with_default, LongType()) | |
| self.assertEqual([], pudf.transpiled) | |
| df = self.spark.createDataFrame([(2,)], "a long") | |
| self.assertEqual(df.select(pudf("a")).first()[0], 3) | |
| def test_udf_transpile_positional_only_receiver_and_public_params(self): | |
| # self/a/b all land in posonlyargs here, none in args.args -- the slice | |
| # _param_category_combos used to miss. Typed str/int so a reversed | |
| # slice (self swapped in for a public param) fails on the result. | |
| class Adder: | |
| def __call__(self, a: str, b: int, /): | |
| return a if b > 0 else "-" | |
| self.assertEqual(self._vals(Adder(), StringType(), "a string, b long", [("xy", 3)]), ["xy"]) | |
| def test_udf_transpile_positional_only_kwarg_call_raises_like_python(self): | |
| # A kwarg naming a positional-only param must still raise -- the | |
| # transpile candidacy shim resolves kwargs to positions and must not | |
| # paper over a call Python itself rejects. | |
| def posonly(a, b, /): | |
| return a + b | |
| with self.sql_conf(_TRANSPILE_ON): | |
| pudf = UserDefinedFunction(posonly, LongType()) | |
| df = self.spark.createDataFrame([(2, 3)], "a long, b long") | |
| with self.assertRaisesRegex(Exception, "positional-only arguments"): | |
| df.select(pudf(a=df.a, b=df.b)).collect() | |
| def test_udf_transpile_falls_back_for_wraps_decorated_function(self): | |
| # inspect.getsource follows __wrapped__, so a functools.wraps-decorated | |
| # UDF previously transpiled the WRAPPED function's source while the | |
| # interpreted path ran the wrapper -- a silent wrong result. | |
| import functools | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def base(x): | |
| return x + 1 | |
| @functools.wraps(base) | |
| def wrapper(x): | |
| return base(x) * 10 | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(wrapper, LongType()) | |
| self.assertEqual([], pudf.transpiled, "wraps-decorated UDF must not transpile") | |
| df = self.spark.createDataFrame([Row(a=5)], schema=long_schema) | |
| [result] = df.select(pudf("a")).collect() | |
| self.assertEqual(result[0], 60, "must run the wrapper, not the wrapped source") | |
| def test_udf_transpile_falls_back_for_none_in_boolop(self): | |
| # Python's `None and x` short-circuits to None; Spark's three-valued | |
| # `null AND false` is false. A literal None operand must force a | |
| # fallback rather than silently diverge. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def none_and(x): | |
| return None and (x > 0) | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(none_and, BooleanType()) | |
| self.assertEqual([], pudf.transpiled, "literal None in and/or must not transpile") | |
| df = self.spark.createDataFrame([Row(a=-5)], schema=long_schema) | |
| [result] = df.select(pudf("a")).collect() | |
| self.assertIsNone(result[0]) | |
| def test_udf_transpile_falls_back_for_uncastable_return_type(self): | |
| # The lowered expression is cast to the declared return type; a return | |
| # type no atomic lowering can be cast to (arrays, maps, datetimes, ...) | |
| # would make that Cast fail CheckAnalysis and break the whole query | |
| # (the options are children of TranspiledPythonUDF), so such UDFs must | |
| # fall back at construction instead. Interpreted execution still works | |
| # (the pickled-UDF converter nulls the type-mismatched results). | |
| import warnings as _warnings | |
| from pyspark.sql.types import ArrayType, TimestampType | |
| plus_one = lambda x: x + 1 # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for rt in (ArrayType(LongType()), TimestampType()): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(plus_one, rt) | |
| self.assertEqual([], pudf.transpiled, f"return type {rt} must not transpile") | |
| # Interpreted execution keeps working; an int result for an array | |
| # return type is nulled by the pickled-UDF converter. (Timestamp | |
| # is not exercised here: its converter accepts ints as micros.) | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| array_udf = UserDefinedFunction(plus_one, ArrayType(LongType())) | |
| df = self.spark.createDataFrame([Row(a=1)]) | |
| [result] = df.select(array_udf("a")).collect() | |
| self.assertIsNone(result[0], "interpreted fallback nulls the mismatch") | |
| def test_udf_transpile_falls_back_for_cross_category_return_cast(self): | |
| # Per-variant guard: the body category must MATCH the declared return | |
| # type's category. Un-castable combos (binary body -> numeric return, | |
| # boolean body -> binary return) would fail analysis outright, and | |
| # analysis-valid cross-category casts (string -> long, numeric -> | |
| # boolean, anything -> decimal) diverge from the interpreted path, | |
| # which nulls type-mismatched results instead of casting -- e.g. | |
| # `def f(s: str): return s` declared LongType() would return 123 for | |
| # '123' (or raise CAST_INVALID_INPUT) where interpreted returns NULL. | |
| import warnings as _warnings | |
| from pyspark.sql.types import DecimalType | |
| def bytes_to_long(x: bytes): | |
| return x | |
| def bool_to_binary(x): | |
| return (x > 0) if x is not None else None | |
| def str_ident(s: str): | |
| return s | |
| def plus_one(x): | |
| return x + 1 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for func, rt, label in ( | |
| (bytes_to_long, LongType(), "binary body -> numeric return"), | |
| (bool_to_binary, BinaryType(), "boolean body -> binary return"), | |
| (bytes_to_long, StringType(), "binary body -> string return"), | |
| (str_ident, LongType(), "string body -> numeric return"), | |
| (plus_one, BooleanType(), "numeric body -> boolean return"), | |
| (plus_one, DecimalType(10, 2), "numeric body -> decimal return"), | |
| ): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(func, rt) | |
| self.assertEqual([], pudf.transpiled, f"{label} must not transpile") | |
| # Interpreted execution of the Codex-flagged example: NULL, not a | |
| # cast. (The transpiled cast would have returned 123.) | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| str_long = UserDefinedFunction(str_ident, LongType()) | |
| df = self.spark.createDataFrame([("123",)], "s string") | |
| self.assertIsNone(df.select(str_long("s")).first()[0]) | |
| def test_udf_transpile_falls_back_for_non_numeric_unary(self): | |
| # Unary +/- only lower for numeric operands: Python raises TypeError | |
| # on `+s`/`-s` for strings while Spark's ANSI string promotion would | |
| # silently coerce (`-'5'` -> -5.0), and `-x` on a boolean would fail | |
| # analysis outright rather than fall back. | |
| import warnings as _warnings | |
| def neg_str(s: str): | |
| return -s | |
| def pos_str(s: str): | |
| return +s | |
| def neg_bool(x: bool): | |
| return -x | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for func in (neg_str, pos_str, neg_bool): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(func, LongType()) | |
| self.assertEqual([], pudf.transpiled, f"{func.__name__} must not transpile") | |
| # Numeric unary still lowers and matches Python. | |
| neg = lambda x: -x # noqa: E731 | |
| self.assertEqual(self._vals(neg, LongType(), "a long", [(5,), (-3,)]), [-5, 3]) | |
| def test_udf_transpile_falls_back_for_self_reference(self): | |
| # A __call__ body that references bare `self` has no column | |
| # equivalent; the offset scheme previously emitted `_udf_param_-1`, | |
| # which the JVM builder rejected with an AnalysisException at call | |
| # construction instead of falling back to interpreted Python. | |
| import warnings as _warnings | |
| class PickSelf: | |
| def __call__(self, x): | |
| return x if x is not None else self | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(PickSelf(), LongType()) | |
| self.assertEqual([], pudf.transpiled, "`self` reference must not transpile") | |
| # Interpreted execution still works (previously the call itself | |
| # raised). Only non-null rows are exercised: a row that RETURNS | |
| # `self` would fail JVM-side unpickling of the instance, which is | |
| # interpreted-UDF behavior unrelated to this guard. | |
| df = self.spark.createDataFrame([(2,), (7,)], "a long") | |
| results = [r[0] for r in df.select(pudf("a")).collect()] | |
| self.assertEqual(results, [2, 7]) | |
| def test_udf_transpile_preserves_auto_column_name(self): | |
| # The auto-generated column name must stay `f(a)` whether or not the | |
| # rewrite engages; the TranspiledPythonUDF wrapper (and its option | |
| # children) must not leak into user-visible schema names. | |
| from pyspark.sql.types import StructField, StructType | |
| def plus_four(x): | |
| return x + 4 | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| df = self.spark.createDataFrame([Row(a=1)], schema=long_schema) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| pudf = UserDefinedFunction(plus_four, LongType()) | |
| self.assertTrue(pudf.transpiled) | |
| self.assertEqual(df.select(pudf("a")).columns, ["plus_four(a)"]) | |
| def test_udf_transpile_arity_mismatch_falls_back(self): | |
| # Calling with the wrong number of arguments is a user error that must | |
| # surface as the standard Python-side TypeError, not be silently | |
| # absorbed by a transpiled constant (zero-param case) nor raise a | |
| # misleading "internal error" AnalysisException (too-few-args case). | |
| import warnings as _warnings | |
| from pyspark.errors import PythonException | |
| from pyspark.sql.types import StructField, StructType | |
| def zero(): | |
| return 42 | |
| def two(x, y): | |
| return x + y | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| df = self.spark.createDataFrame([Row(a=5)], schema=long_schema) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with _warnings.catch_warnings(record=True): | |
| _warnings.simplefilter("always") | |
| pudf_zero = UserDefinedFunction(zero, LongType()) | |
| pudf_two = UserDefinedFunction(two, LongType()) | |
| with self.assertRaises(PythonException): | |
| df.select(pudf_zero("a")).collect() | |
| with self.assertRaises(PythonException): | |
| df.select(pudf_two("a")).collect() | |
| def test_udf_transpile_decimal_input_falls_back(self): | |
| # Python receives decimal.Decimal objects, which raise TypeError when | |
| # mixed with float literals; the transpiled numeric lowering would | |
| # silently succeed. Decimal columns must fall back to interpreted | |
| # Python (pruned by input category at analysis time). | |
| from pyspark.errors import PythonException | |
| def add_half(x): | |
| return x + 1.5 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| # DoubleType: the return type must category-match the numeric body | |
| # for the option to be emitted (a string return type would itself | |
| # force a fallback before the decimal-input pruning under test). | |
| pudf = UserDefinedFunction(add_half, DoubleType()) | |
| self.assertTrue(pudf.transpiled, "numeric option should still be produced") | |
| df = self.spark.sql("SELECT CAST(1.0 AS DECIMAL(10,2)) AS d") | |
| with self.assertRaises(PythonException): | |
| df.select(pudf("d")).collect() | |
| def test_udf_transpile_collated_string_falls_back(self): | |
| # Under a non-binary collation Spark's `=` follows collation rules | |
| # ('abc' = 'ABC' is true under UTF8_LCASE) while Python compares | |
| # codepoints. Collated columns must fall back to interpreted Python. | |
| def eq_abc(s): | |
| return s == "ABC" | |
| with self.sql_conf(_TRANSPILE_ON): | |
| pudf = UserDefinedFunction(eq_abc, BooleanType()) | |
| self.assertTrue(pudf.transpiled, "string option should still be produced") | |
| df = self.spark.sql("SELECT 'abc' COLLATE UTF8_LCASE AS s") | |
| [result] = df.select(pudf("s")).collect() | |
| self.assertIs(result[0], False, "must match Python, not collation semantics") | |
| def test_udf_transpile_is_none_semantics(self): | |
| # `x is None` and `None is x` (and their `is not` variants) should | |
| # transpile to isNull/isNotNull. Any other identity check (`x is 0`, | |
| # `x is y`, `x is True`) must NOT transpile -- Python's `is` is an | |
| # object-identity test with no SQL equivalent outside of None. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| def x_is_none(x): | |
| return x is None | |
| def x_is_not_none(x): | |
| if x is not None: | |
| return x + 1 | |
| def none_is_x(x): | |
| return None is x | |
| def none_is_not_x(x): | |
| if None is not x: | |
| return x + 1 | |
| def x_is_zero(x): | |
| return x is 0 # noqa: F632 identity vs equality | |
| def x_is_true(x): | |
| return x is True | |
| def x_is_y(x, y): | |
| return x is y | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| # `x is None` and `None is x` should transpile and produce | |
| # identical results. | |
| for func, label in [(x_is_none, "x_is_none"), (none_is_x, "none_is_x")]: | |
| with self.subTest(case=label): | |
| pudf = UserDefinedFunction(func, BooleanType()) | |
| self.assertTrue( | |
| pudf.transpiled, | |
| f"{label}: expected transpilation to succeed", | |
| ) | |
| df = self.spark.createDataFrame([Row(a=None)], schema=long_schema) | |
| [row] = df.select(pudf("a")).collect() | |
| self.assertTrue(row[0], f"{label}: None is None should be True") | |
| df = self.spark.createDataFrame([Row(a=1)], schema=long_schema) | |
| [row] = df.select(pudf("a")).collect() | |
| self.assertFalse(row[0], f"{label}: 1 is None should be False") | |
| # `x is not None` and `None is not x` should transpile. | |
| for func, label in [ | |
| (x_is_not_none, "x_is_not_none"), | |
| (none_is_not_x, "none_is_not_x"), | |
| ]: | |
| with self.subTest(case=label): | |
| pudf = UserDefinedFunction(func, LongType()) | |
| self.assertTrue( | |
| pudf.transpiled, | |
| f"{label}: expected transpilation to succeed", | |
| ) | |
| df = self.spark.createDataFrame([Row(a=2)], schema=long_schema) | |
| [row] = df.select(pudf("a")).collect() | |
| self.assertEqual(row[0], 3, f"{label}: non-None input should return x+1") | |
| df = self.spark.createDataFrame([Row(a=None)], schema=long_schema) | |
| [row] = df.select(pudf("a")).collect() | |
| self.assertIsNone(row[0], f"{label}: None input should return None") | |
| # Non-None identity checks must NOT transpile and must still | |
| # return correct results via interpreted Python. | |
| bool_schema = StructType([StructField("a", BooleanType(), nullable=True)]) | |
| two_col_schema = StructType( | |
| [ | |
| StructField("a", LongType(), nullable=True), | |
| StructField("b", LongType(), nullable=True), | |
| ] | |
| ) | |
| non_none_cases = [ | |
| # CPython interns small ints so `0 is 0` happens to be True in CPython, | |
| # but that is an implementation detail. The transpiler must still refuse | |
| # to lower these to isNull/isNotNull. We just verify: (a) no transpile, | |
| # (b) the interpreted result matches what Python actually produces. | |
| ("x_is_zero", x_is_zero, BooleanType(), long_schema, Row(a=0), True), | |
| # `True is True` is True because bool singletons are interned. | |
| ("x_is_true", x_is_true, BooleanType(), bool_schema, Row(a=True), True), | |
| ("x_is_y", x_is_y, BooleanType(), two_col_schema, Row(a=1, b=1), True), | |
| ] | |
| for label, func, return_type, schema, row, expected in non_none_cases: | |
| with self.subTest(case=label): | |
| with _warnings.catch_warnings(record=True) as caught: | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(func, return_type) | |
| self.assertEqual( | |
| [], | |
| pudf.transpiled, | |
| f"{label}: non-None identity check must NOT transpile", | |
| ) | |
| fallback = [ | |
| w | |
| for w in caught | |
| if "Unable to transpile" in str(w.message) | |
| or "Errors encountered" in str(w.message) | |
| ] | |
| self.assertTrue(fallback, f"{label}: expected a fallback warning") | |
| df = self.spark.createDataFrame([row], schema=schema) | |
| args = ["a", "b"] if "b" in schema.fieldNames() else ["a"] | |
| [result] = df.select(pudf(*args)).collect() | |
| self.assertEqual(result[0], expected, f"{label}: interpreted result mismatch") | |
| def test_udf_transpile_not_bare_param_falls_back(self): | |
| # `not x` where x is a bare UDF parameter (unknown type at | |
| # transpile time) must NOT be lowered: Spark's `~` is bitwise, not | |
| # Python truthiness, so `not 0` would produce True via Python but | |
| # Spark's `~0L` is -1 (truthy). The transpiler must refuse and fall | |
| # back to interpreted Python. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def not_x(x): | |
| return not x | |
| long_schema = StructType([StructField("a", LongType(), nullable=True)]) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| with _warnings.catch_warnings(record=True) as caught: | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(not_x, BooleanType()) | |
| self.assertEqual([], pudf.transpiled, "not x on bare param must NOT transpile") | |
| fallback = [ | |
| w | |
| for w in caught | |
| if "Unable to transpile" in str(w.message) or "Errors encountered" in str(w.message) | |
| ] | |
| self.assertTrue(fallback, "expected a fallback warning for `not x`") | |
| # Verify interpreted result is still correct. | |
| for value, expected in [(0, True), (1, False), (None, True)]: | |
| with self.subTest(value=value): | |
| df = self.spark.createDataFrame([Row(a=value)], schema=long_schema) | |
| [row] = df.select(pudf("a")).collect() | |
| self.assertEqual(row[0], expected) | |
| def test_udf_transpile_and_or_bare_param_falls_back(self): | |
| # `x and y` / `x or y` where x/y are bare UDF parameters (unknown | |
| # type) must NOT be lowered: Python returns one of the operands | |
| # (truthiness semantics) while Spark's `&`/`|` are bitwise. The | |
| # transpiler must refuse and fall back. | |
| import warnings as _warnings | |
| from pyspark.sql.types import StructField, StructType | |
| def x_and_y(x, y): | |
| return x and y | |
| def x_or_y(x, y): | |
| return x or y | |
| schema = StructType( | |
| [ | |
| StructField("a", LongType(), nullable=True), | |
| StructField("b", LongType(), nullable=True), | |
| ] | |
| ) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": True, | |
| "spark.sql.ansi.enabled": True, | |
| } | |
| ): | |
| for func, label, row, expected in [ | |
| (x_and_y, "x_and_y_falsy", Row(a=0, b=5), 0), | |
| (x_and_y, "x_and_y_truthy", Row(a=3, b=5), 5), | |
| (x_or_y, "x_or_y_falsy_left", Row(a=0, b=5), 5), | |
| (x_or_y, "x_or_y_truthy_left", Row(a=3, b=0), 3), | |
| ]: | |
| with self.subTest(case=label): | |
| with _warnings.catch_warnings(record=True) as caught: | |
| _warnings.simplefilter("always") | |
| pudf = UserDefinedFunction(func, LongType()) | |
| self.assertEqual( | |
| [], | |
| pudf.transpiled, | |
| f"{label}: and/or on bare params must NOT transpile", | |
| ) | |
| fallback = [ | |
| w | |
| for w in caught | |
| if "Unable to transpile" in str(w.message) | |
| or "Errors encountered" in str(w.message) | |
| ] | |
| self.assertTrue(fallback, f"{label}: expected a fallback warning") | |
| df = self.spark.createDataFrame([row], schema=schema) | |
| [result] = df.select(pudf("a", "b")).collect() | |
| self.assertEqual(result[0], expected, f"{label}: interpreted result mismatch") | |
| def test_cannot_convert_column_into_bool_includes_column_repr(self): | |
| # The error fired by ``Column.__bool__`` should name the offending | |
| # column so users can see which expression triggered the fallback. | |
| from pyspark.errors import PySparkValueError | |
| df = self.spark.createDataFrame([Row(a=1, b=2)]) | |
| col_a = df["a"] | |
| with self.assertRaises(PySparkValueError) as ctx: | |
| bool(col_a) | |
| message = str(ctx.exception) | |
| self.assertIn("Cannot convert column into bool", message) | |
| # Column's stringification is JVM-side and may render the column | |
| # as ``a`` (unresolved) or with a backtick variant, so we just | |
| # require the column name appears somewhere in the message. | |
| self.assertIn("a", message) | |
| def test_udf_transpile_category_inference_is_linear(self): | |
| import ast | |
| from pyspark.sql.transpile import CatalystTranspiler | |
| class CountingCatalystTranspiler(CatalystTranspiler): | |
| def __init__(self): | |
| super().__init__() | |
| self.category_calls = 0 | |
| def _category_uncached(self, params, node): | |
| self.category_calls += 1 | |
| return super()._category_uncached(params, node) | |
| expression = " + ".join(["a"] * 161) | |
| source = f"def f(a):\n return {expression}\n" | |
| function_ast = ast.parse(source).body[0] | |
| self.assertIsInstance(function_ast, ast.FunctionDef) | |
| transpiler = CountingCatalystTranspiler() | |
| converted = transpiler._transpile_from_ast( | |
| source, | |
| function_ast, | |
| function_ast, | |
| ["a"], | |
| LongType(), | |
| {0: "numeric"}, | |
| ) | |
| self.assertIsNotNone(converted) | |
| self.assertLessEqual(transpiler.category_calls, len(list(ast.walk(function_ast)))) | |
| # ------------------------------------------------------------------ | |
| # Edge cases (SPARK-55206 follow-up). Helpers build a UDF with | |
| # transpilation on; `_vals` runs it and returns outputs (asserting it | |
| # transpiled), `_raises` asserts it raises. Arg columns come from the | |
| # schema. Operator cases are table-driven. Plan-elision checks count | |
| # `EvalPython` nodes because an ordering compare's `raise_error` message | |
| # contains "UDF" (so the "UDF" substring is unreliable). | |
| # ------------------------------------------------------------------ | |
| @staticmethod | |
| def _udf_and_warnings(func, return_type): | |
| """Build a UDF, returning it with the text of any warnings it emitted. | |
| ``udf.py`` reports WHY a UDF fell back only as a warning, so every test that | |
| cares about a fallback needs them captured. The caller must already be inside | |
| ``sql_conf(_TRANSPILE_ON)`` -- this does not set the conf. | |
| """ | |
| import warnings | |
| with warnings.catch_warnings(record=True) as caught: | |
| warnings.simplefilter("always") | |
| u = UserDefinedFunction(func, return_type) | |
| return u, " ".join(str(w.message) for w in caught) | |
| def _fallback_reason(self, func, return_type=LongType()): | |
| """Assert ``func`` produced no options, and hand back the reason it reported.""" | |
| u, reasons = self._udf_and_warnings(func, return_type) | |
| self.assertEqual([], u.transpiled, f"{func} must fall back") | |
| self.assertTrue(reasons, f"{func} fell back without saying why") | |
| return u, reasons | |
| def _transpiled_udf(self, func, return_type): | |
| """A UDF asserted to have produced options; naming the fallback reason if not. | |
| Without the captured warning an empty ``transpiled`` asserts as bare "[] is | |
| not true", and from CI the text is only in a credentialed log artifact. The | |
| caller must already be inside ``sql_conf(_TRANSPILE_ON)``. | |
| """ | |
| u, reasons = self._udf_and_warnings(func, return_type) | |
| self.assertTrue(u.transpiled, f"{func} produced no transpiled options: {reasons}") | |
| return u | |
| def _vals(self, func, return_type, schema, rows, require_lowered=True): | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u = self._transpiled_udf(func, return_type) | |
| df = self.spark.createDataFrame(rows, schema) | |
| projected = df.select(u(*df.columns)) | |
| # ``u.transpiled`` only says options were PRODUCED; the JVM may still | |
| # discard them and run interpreted Python, returning the right value and | |
| # hiding a wrong lowering. Pass ``require_lowered=False`` only where the | |
| # JVM is EXPECTED to discard them. | |
| if require_lowered: | |
| self.assertEqual(0, self._eval_python_count(projected), str(func)) | |
| return [r[0] for r in projected.collect()] | |
| def _raises(self, func, schema, rows, needle="numeric"): | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u = self._transpiled_udf(func, LongType()) | |
| df = self.spark.createDataFrame(rows, schema) | |
| with self.assertRaises(Exception) as ctx: | |
| df.select(u(*df.columns)).collect() | |
| self.assertIn(needle, str(ctx.exception).lower(), str(func)) | |
| @staticmethod | |
| def _eval_python_count(df): | |
| return df._jdf.queryExecution().executedPlan().toString().count("EvalPython") | |
| @staticmethod | |
| def _optimized_plan(df): | |
| return df._jdf.queryExecution().optimizedPlan().toString() | |
| # Internal plan-string details: the alias a pre-evaluated argument gets (ConvertToCatalyst | |
| # names them ``_udf_param_N``), and the token a plan shows for a draw it still evaluates. | |
| _SHARED_ARG_ALIAS = "_udf_param_" | |
| _DRAW = "rand(" | |
| def _shares_an_argument(self, df): | |
| return self._SHARED_ARG_ALIAS in self._optimized_plan(df) | |
| def _draw_count(self, df): | |
| return self._optimized_plan(df).count(self._DRAW) | |
| def test_udf_transpile_evaluates_inputs_once(self): | |
| # SPARK-58626: an argument the body uses twice is drawn once per row, so `x == x` over a | |
| # random input is True on every row and `x if x > 0.5 else 0.0` never returns a value its | |
| # own condition would have rejected. | |
| from pyspark.sql.functions import expr, lit, rand | |
| eq_self = lambda x: x == x # noqa: E731 | |
| clamp = lambda x: x if x > 0.5 else 0.0 # noqa: E731 | |
| square_fn = lambda x: x * x # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| eq_udf = UserDefinedFunction(eq_self, BooleanType()) | |
| clamp_udf = UserDefinedFunction(clamp, DoubleType()) | |
| self.assertTrue(eq_udf.transpiled and clamp_udf.transpiled) | |
| rows = self.spark.range(100) | |
| # `functions.rand()` bakes a literal seed in, but `expr("rand()")` arrives with the seed | |
| # 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. | |
| # | |
| # 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(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")) | |
| self.assertEqual(1, self._draw_count(clamped)) | |
| vals = [r[0] for r in clamped.collect()] | |
| self.assertTrue(all(v == 0.0 or v > 0.5 for v in vals), vals) | |
| # Both branches have to run for the check above to mean anything. | |
| self.assertTrue(any(v == 0.0 for v in vals) and any(v > 0.5 for v in vals), vals) | |
| # A literal and a plain column are as cheap to read twice as to share, so the optimizer | |
| # puts them back at each use site and neither grows the plan. | |
| square = UserDefinedFunction(square_fn, LongType()) | |
| self.assertTrue(square.transpiled) | |
| lit_df = rows.select(square(lit(3)).alias("v")) | |
| self.assertFalse(self._shares_an_argument(lit_df)) | |
| self.assertEqual([9] * 100, [r[0] for r in lit_df.collect()]) | |
| col_df = self.spark.range(3).select(square("id").alias("v")) | |
| self.assertFalse(self._shares_an_argument(col_df)) | |
| self.assertEqual([0, 1, 4], [r[0] for r in col_df.collect()]) | |
| def test_udf_transpile_shares_a_python_udf_argument(self): | |
| # SPARK-58626: an argument that is itself a nondeterministic Python UDF gets a column like | |
| # any other draw the body reads twice, even though CollapseProject.isCheap counts a | |
| # PythonUDF as cheap. Cheapness is about wasted work; two calls would be two draws, and | |
| # `a - a` would come out nonzero. | |
| # | |
| # ExtractPythonUDFs would have pointed both copies at one result attribute anyway, so this | |
| # was the right answer before the column too -- by accident, from an unrelated rule, and at | |
| # the cost of a second Python round trip (the dedup runs through an ExpressionSet, which | |
| # holds a nondeterministic expression once per add). | |
| import random | |
| draw = lambda x: random.random() # noqa: E731 | |
| subtract_self = lambda a: a - a # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| rand_udf = UserDefinedFunction(draw, DoubleType()).asNondeterministic() | |
| diff = UserDefinedFunction(subtract_self, DoubleType()) | |
| self.assertTrue(diff.transpiled) | |
| df = self.spark.range(20).select(diff(rand_udf("id"))) | |
| self.assertTrue(self._shares_an_argument(df), self._optimized_plan(df)) | |
| self.assertEqual([0.0] * 20, [r[0] for r in df.collect()], "One draw, read twice") | |
| def test_udf_transpile_repeats_a_deterministic_python_udf_argument(self): | |
| # SPARK-58626: a deterministic Python UDF argument is cheap to repeat as far as | |
| # CollapseProject.isCheap is concerned, so it stays at each use site instead of getting a | |
| # column -- and ExtractPythonUDFs then points both copies at one eval node, so the body | |
| # still reads one evaluation. What we promise is the evaluation count, not which rule keeps | |
| # it, and this is the shape where another rule does. A second argument because a call whose | |
| # arguments are all Python UDFs keeps the batch pipeline instead of lowering. | |
| def helper(v): | |
| return v + 1 | |
| opaque = lambda x: helper(x) # noqa: E731 | |
| first_twice = lambda a, b: a - a + b # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| inner = UserDefinedFunction(opaque, LongType()) | |
| self.assertEqual([], inner.transpiled, "The inner UDF has to stay Python") | |
| outer = self._transpiled_udf(first_twice, LongType()) | |
| df = self.spark.range(5).select(outer(inner("id"), "id")) | |
| plan = self._optimized_plan(df) | |
| self.assertFalse(self._shares_an_argument(df), plan) | |
| self.assertEqual( | |
| 1, | |
| self._eval_python_count(df), | |
| df._jdf.queryExecution().executedPlan().toString(), | |
| ) | |
| self.assertEqual(list(range(5)), [r[0] for r in df.collect()]) | |
| def test_udf_transpile_declines_inside_a_higher_order_function(self): | |
| # SPARK-58626: a call inside a lambda is not lowered at all -- Spark applies a Python UDF | |
| # over the whole array there and we leave that to it -- so the draw stays a single draw per | |
| # call rather than one per read. | |
| # | |
| # `clamp` returns its input or 0.0, so a value in (0.0, 0.5] would be two draws: the | |
| # condition saw one and the branch another. | |
| from pyspark.sql.functions import array, col, lit, rand, transform | |
| clamp = lambda x: x if x > 0.5 else 0.0 # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| c = self._transpiled_udf(clamp, DoubleType()) | |
| arr = self.spark.range(500).select(array(lit(1.0), lit(2.0)).alias("a")) | |
| hof = arr.select(transform(col("a"), lambda e: c(rand())).alias("v")) | |
| self.assertGreater(self._eval_python_count(hof), 0, self._optimized_plan(hof)) | |
| vals = [v for r in hof.collect() for v in r["v"]] | |
| self.assertTrue(vals and all(v == 0.0 or v > 0.5 for v in vals), vals) | |
| def test_udf_transpile_declines_under_an_aggregate(self): | |
| # SPARK-58626: an Aggregate can't host the column -- a result expression no aggregate | |
| # function wraps has to be built from the grouping expressions -- so a body reading a | |
| # nondeterministic argument twice keeps the interpreted UDF, which draws once. `clamp` | |
| # returns its input or 0.0, so a value in (0.0, 0.5] would be two draws. | |
| from pyspark.sql.functions import col, rand | |
| clamp = lambda x: x if x > 0.5 else 0.0 # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| c = self._transpiled_udf(clamp, DoubleType()) | |
| agg = self.spark.range(500).groupBy(col("id").alias("g")).agg(c(rand()).alias("v")) | |
| self.assertGreater(self._eval_python_count(agg), 0, self._optimized_plan(agg)) | |
| vals = [r["v"] for r in agg.collect()] | |
| self.assertTrue(all(v == 0.0 or v > 0.5 for v in vals), vals) | |
| self.assertTrue(any(v == 0.0 for v in vals) and any(v > 0.5 for v in vals), vals) | |
| def test_udf_transpile_gives_each_call_its_own_draw(self): | |
| # SPARK-58626: one draw per parameter, not one per operator. Two calls each passing their | |
| # own `rand()` owe two draws, so the doubled values differ on every row. Keying a shared | |
| # column on the parameter index alone put both bodies on one draw and made this exactly 0.0. | |
| from pyspark.sql.functions import rand | |
| add_self = lambda x: x + x # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u = self._transpiled_udf(add_self, DoubleType()) | |
| df = self.spark.range(500).select((u(rand()) - u(rand())).alias("v")) | |
| self.assertEqual(0, self._eval_python_count(df)) | |
| self.assertEqual(2, self._optimized_plan(df).count("AS " + self._SHARED_ARG_ALIAS)) | |
| diffs = [r["v"] for r in df.collect()] | |
| self.assertTrue(all(d != 0.0 for d in diffs), "each call draws for itself") | |
| def test_udf_transpile_position_can_rule_out_sharing(self): | |
| # SPARK-58626: inside a lambda nothing is lowered, so nothing is pre-evaluated either -- the | |
| # empty array below must NOT raise even though the argument divides by zero. | |
| # | |
| # A conditional branch, by contrast, DOES get one evaluation, even though a bare Catalyst | |
| # `when` is lazy. Measured: the interpreted Python UDF this replaces evaluates its inputs | |
| # in a projection below the conditional and raises there too, so eager is the Python-parity | |
| # answer as well as the cheaper one. See transpile.py. | |
| from pyspark.sql.functions import array, col, lit, rand, transform, when | |
| clamp = lambda x: x if x > 0.5 else 0.0 # noqa: E731 | |
| used_twice = lambda a, b: (a + a) + b # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| c = UserDefinedFunction(clamp, DoubleType()) | |
| self.assertTrue(c.transpiled) | |
| rows = self.spark.range(200).select((col("id") + 1).cast("double").alias("x")) | |
| nested = rows.select(when(col("x") > 0, c(rand())).otherwise(lit(-1.0)).alias("v")) | |
| vals = [r[0] for r in nested.collect()] | |
| # `x` is `id + 1`, so the otherwise branch never fires and every value came from the | |
| # body -- which means a value in (0.0, 0.5] could only be the body seeing two draws. | |
| self.assertTrue( | |
| all(v == 0.0 or v > 0.5 for v in vals), | |
| f"a conditional branch must share one draw too: {vals}", | |
| ) | |
| self.assertEqual(1, self._draw_count(nested)) | |
| self.assertTrue(self._shares_an_argument(nested)) | |
| s = UserDefinedFunction(used_twice, DoubleType()) | |
| self.assertTrue(s.transpiled) | |
| df = self.spark.createDataFrame([(1.0, 0.0)], "a double, b double").select( | |
| col("a"), col("b"), array().cast("array<double>").alias("arr") | |
| ) | |
| lazy = df.select(transform(col("arr"), lambda e: s(col("a") / col("b"), e)).alias("v")) | |
| self.assertEqual([[]], [r[0] for r in lazy.collect()]) | |
| def test_udf_transpile_shares_a_nondeterministic_argument_in_a_predicate(self): | |
| # SPARK-58626: a `where` keeps the shared column when the argument is nondeterministic. | |
| # PushPredicateThroughNonJoin only pushes a Filter through a Project whose fields are all | |
| # deterministic, so the draw is not substituted back and the body sees one value. A | |
| # deterministic argument IS pushed back and evaluated per use -- same rows, just extra work. | |
| from pyspark.sql.functions import col, rand | |
| add_self = lambda x: x + x # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u = UserDefinedFunction(add_self, DoubleType()) | |
| self.assertTrue(u.transpiled) | |
| nd = self.spark.range(0, 10).where(u(rand()) > 0.5) | |
| self.assertTrue(self._shares_an_argument(nd), self._optimized_plan(nd)) | |
| self.assertEqual(1, self._draw_count(nd)) | |
| det = self.spark.range(0, 10).where(u((col("id") % 3).cast("double")) > 1.0) | |
| self.assertFalse(self._shares_an_argument(det), self._optimized_plan(det)) | |
| def test_udf_transpile_udf_as_a_predicate(self): | |
| # A predicate is transpiled like anything else in a `where`: no Python worker, same answers. | |
| # In a join condition it is not, since no column can go there -- see below. | |
| from pyspark.sql.functions import col | |
| used_twice = lambda a, b: (a + a) if b > 0 else 0.0 # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u = UserDefinedFunction(used_twice, DoubleType()) | |
| self.assertTrue(u.transpiled) | |
| df = self.spark.createDataFrame([(1.0, 2.0), (4.0, 2.0)], "a double, b double") | |
| other = self.spark.createDataFrame([(2.0,)], "c double") | |
| predicate = u(col("a") / col("b"), col("b")) > 2.0 | |
| filtered = df.where(predicate) | |
| self.assertEqual(0, self._eval_python_count(filtered)) | |
| self.assertEqual([(4.0, 2.0)], [(r[0], r[1]) for r in filtered.collect()]) | |
| # A join has no side to hold the column, so the call goes back to Python -- the same | |
| # path the predicate takes with transpilation off. | |
| joined = df.join(other, predicate) | |
| self.assertGreater(self._eval_python_count(joined), 0, self._optimized_plan(joined)) | |
| self.assertEqual([(4.0, 2.0, 2.0)], [tuple(r) for r in joined.collect()]) | |
| def test_udf_transpile_survives_an_argument_the_analyzer_moved(self): | |
| # SPARK-58626: PullOutNondeterministic pulls a nondeterministic call into a projection below | |
| # an operator that cannot hold one, leaving an attribute where the call was -- and so no | |
| # arguments for the option's references to point at. The call keeps the Python path there | |
| # rather than failing the query, which is also one evaluation: the projection computes it | |
| # once. Master lowered these, with the draw spliced into the option body and read twice from | |
| # the column PullOutNondeterministic left; this gives up that lowering, not correctness. | |
| from pyspark.sql.functions import col, rand | |
| add_self = lambda x: x + x # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u = self._transpiled_udf(add_self, DoubleType()) | |
| rows = self.spark.range(0, 4).select(col("id").cast("double").alias("a")) | |
| self.assertEqual(4, len(rows.orderBy(u(rand())).collect())) | |
| self.assertEqual(4, len(rows.repartition(2, u(rand())).collect())) | |
| self.assertEqual(4, len(rows.groupBy(u(rand())).count().collect())) | |
| def test_udf_transpile_evaluates_inputs_once_in_a_group_by(self): | |
| # SPARK-58626: an Aggregate is the awkward one -- a use no aggregate function wraps has to | |
| # *be* a grouping expression, so it cannot read a pre-evaluated column. `x * x` reads its | |
| # parameter twice and `a + 1` is not cheap, so all three shapes here keep the interpreted | |
| # UDF, which evaluates the argument once itself. This pins the answers and that fallback. | |
| from pyspark.sql.functions import col | |
| from pyspark.sql.functions import sum as sum_ | |
| square_fn = lambda x: x * x # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| square = UserDefinedFunction(square_fn, LongType()) | |
| self.assertTrue(square.transpiled) | |
| df = self.spark.createDataFrame([(1,), (2,), (2,)], "a long") | |
| grouped = df.groupBy(col("a") + 1).agg(square(col("a") + 1).alias("v")) | |
| self.assertEqual([(2, 4), (3, 9)], sorted((r[0], r[1]) for r in grouped.collect())) | |
| summed = df.agg(sum_(square(col("a") + 1)).alias("s")) | |
| self.assertEqual([22], [r[0] for r in summed.collect()]) | |
| mixed = df.groupBy(col("a") + 1).agg( | |
| square(col("a") + 1).alias("v"), sum_(square(col("a") + 1)).alias("s") | |
| ) | |
| self.assertEqual([(2, 4, 4), (3, 9, 18)], sorted(tuple(r) for r in mixed.collect())) | |
| def test_udf_transpile_evaluates_inputs_once_in_a_subquery(self): | |
| # SPARK-58626: end to end because decorrelation is what makes this interesting. In the last | |
| # shape the shared argument reads a column of the OUTER query, so the pre-evaluated column | |
| # lands inside the subquery holding an OuterReference, and decorrelation has to carry it | |
| # back out. With decorrelation off we decline the column instead -- see ConvertToCatalyst. | |
| square_fn = lambda x: x * x # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| square = UserDefinedFunction(square_fn, LongType()) | |
| self.assertTrue(square.transpiled) | |
| # Both registrations go through SQLTestUtils' context managers: ReusedSQLTestCase | |
| # shares one session across the class, so a failure between a bare register() and the | |
| # try would leave sq_test_58626 in every later test's session. | |
| with self.temp_func("sq_test_58626"), self.temp_view("t_58626"): | |
| self.spark.udf.register("sq_test_58626", square) | |
| self.spark.range(0, 5).selectExpr("id as a").createOrReplaceTempView("t_58626") | |
| uncorrelated = self.spark.sql( | |
| "SELECT a FROM t_58626 WHERE a < " | |
| "(SELECT max(sq_test_58626(a + 1)) FROM t_58626)" | |
| ) | |
| self.assertEqual([0, 1, 2, 3, 4], [r[0] for r in uncorrelated.collect()]) | |
| correlated = self.spark.sql( | |
| "SELECT a FROM t_58626 o WHERE a < " | |
| "(SELECT max(sq_test_58626(i.a + 1)) FROM t_58626 i WHERE i.a = o.a)" | |
| ) | |
| self.assertEqual([0, 1, 2, 3, 4], sorted(r[0] for r in correlated.collect())) | |
| outer_arg = self.spark.sql( | |
| "SELECT a FROM t_58626 o WHERE EXISTS " | |
| "(SELECT 1 FROM t_58626 i WHERE sq_test_58626(o.a + 1) = i.a)" | |
| ) | |
| self.assertEqual([0, 1], sorted(r[0] for r in outer_arg.collect())) | |
| def test_udf_transpile_puts_no_column_directly_above_an_aggregate(self): | |
| # SPARK-58626: `splitSubquery` finds the Aggregate a correlated scalar subquery is built on | |
| # by matching `Filter(_, Aggregate)` -- adjacency, not a search -- in an earlier batch than | |
| # the pushdown that would collapse a Project away. A column between a HAVING and its | |
| # Aggregate hid it, `mayHaveCountBug` read false, and the outer rows matching nothing were | |
| # dropped instead of counted 0: measured as [] where the same plan without the column | |
| # returns [3, 4]. So nothing goes directly above an Aggregate. The call keeps the Python UDF | |
| # and raises here exactly as transpilation-off does, because a Python eval node lands | |
| # between the Filter and the Aggregate just the same -- that half is not ours. | |
| square_fn = lambda x: x * x # noqa: E731 | |
| legacy = {**_TRANSPILE_ON, "spark.sql.legacy.scalarSubqueryCountBugBehavior": True} | |
| # `count(*) + 1` is read twice and is not cheap, so it is what would be owed a column. | |
| query = ( | |
| "SELECT o.a FROM t_out_58626 o WHERE " | |
| "(SELECT count(*) FROM t_in_58626 i WHERE i.a = o.a " | |
| "HAVING sq_test_58626(count(*) + 1) > 0) = 0" | |
| ) | |
| with self.sql_conf(legacy): | |
| square = UserDefinedFunction(square_fn, LongType()) | |
| self.assertTrue(square.transpiled) | |
| with ( | |
| self.temp_func("sq_test_58626"), | |
| self.temp_view("t_out_58626"), | |
| self.temp_view("t_in_58626"), | |
| ): | |
| self.spark.udf.register("sq_test_58626", square) | |
| self.spark.range(0, 5).selectExpr("id as a").createOrReplaceTempView("t_out_58626") | |
| self.spark.range(0, 3).selectExpr("id as a").createOrReplaceTempView("t_in_58626") | |
| df = self.spark.sql(query) | |
| plan = self._optimized_plan(df) | |
| self.assertNotIn(self._SHARED_ARG_ALIAS, plan, plan) | |
| self.assertEqual(1, self._eval_python_count(df), plan) | |
| def test_udf_transpile_unused_argument_diverges_under_ansi(self): | |
| # SPARK-58626: an argument the body never uses never reaches the option, so nothing | |
| # evaluates it, where the interpreted UDF computes every argument column. Under ANSI | |
| # that is an error-vs-rows difference rather than merely saved work. | |
| from pyspark.sql.functions import col, lit | |
| first = lambda a, b: a # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u = UserDefinedFunction(first, LongType()) | |
| self.assertTrue(u.transpiled) | |
| df = self.spark.createDataFrame([(3,)], "x long") | |
| rows = df.select(u(col("x"), col("x") / lit(0))).collect() | |
| self.assertEqual([3], [r[0] for r in rows]) | |
| with self.sql_conf(_TRANSPILE_OFF_ANSI_ON): | |
| u_i = UserDefinedFunction(first, LongType()) | |
| df = self.spark.createDataFrame([(3,)], "x long") | |
| with self.assertRaises(Exception) as ctx: | |
| df.select(u_i(col("x"), col("x") / lit(0))).collect() | |
| self.assertIn("DIVIDE_BY_ZERO", str(ctx.exception)) | |
| def test_udf_transpile_lowers_operators(self): | |
| # Operators lower to Catalyst and match Python: modulo sign-parity, | |
| # non-commutative -/* (parameter order), unary nesting, constant | |
| # body, not(compare), nested boolean, string ==/<, reversed-operand and | |
| # column-to-column comparisons, if/elif/else, and assigned lambdas. | |
| L, B = LongType(), BooleanType() | |
| modulo = lambda x, y: x % y # noqa: E731 | |
| subtract = lambda a, b: a - b # noqa: E731 | |
| multiply = lambda a, b: a * b # noqa: E731 | |
| double_neg = lambda x: --x # noqa: E731 | |
| unary_pm = lambda x: +(-x) # noqa: E731 | |
| constant = lambda x: 42 # noqa: E731 | |
| not_pos = lambda x: (not (x > 0)) if x is not None else None # noqa: E731 | |
| nested = lambda x, y, z: ((x > 0) and (y > 0)) or (z == 0) # noqa: E731 | |
| str_eq = lambda x: (x == "foo") if x is not None else None # noqa: E731 | |
| str_lt = lambda x: (x < "m") if x is not None else None # noqa: E731 | |
| rev_lt = lambda x: (0 < x) if x is not None else None # noqa: E731 | |
| rev_eq = lambda x: 5 == x # noqa: E731 | |
| none_eq = lambda x: None == x # noqa: E711,E731 | |
| col_lt = lambda a, b: (a < b) if a is not None and b is not None else None # noqa: E731 | |
| assigned = lambda v: v + 1 # noqa: E731 | |
| def if_elif_else(x): | |
| if x is None: | |
| return -1 | |
| elif x == 0: | |
| return 0 | |
| else: | |
| return 1 | |
| # (func, return_type, schema, rows, expected); arg columns come from the schema. | |
| cases = [ | |
| (modulo, L, "a long, b long", [(7, 3), (7, -3), (-7, 3), (-7, -3)], [1, -2, 2, -1]), | |
| (subtract, L, "a long, b long", [(5, 3), (3, 5)], [2, -2]), | |
| (multiply, L, "a long, b long", [(4, 3), (-2, 5)], [12, -10]), | |
| (double_neg, L, "a long", [(5,), (-3,)], [5, -3]), | |
| (unary_pm, L, "a long", [(5,), (-3,)], [-5, 3]), | |
| (constant, L, "a long", [(1,), (999,)], [42, 42]), | |
| (not_pos, B, "a long", [(1,), (0,), (-1,), (None,)], [False, True, True, None]), | |
| (str_eq, B, "a string", [("foo",), ("bar",), (None,)], [True, False, None]), | |
| (str_lt, B, "a string", [("a",), ("z",), (None,)], [True, False, None]), | |
| (rev_lt, B, "a long", [(1,), (0,), (-1,)], [True, False, False]), | |
| (rev_eq, B, "a long", [(5,), (3,), (None,)], [True, False, False]), | |
| (none_eq, B, "a long", [(None,), (5,)], [True, False]), | |
| (col_lt, B, "a long, b long", [(1, 2), (2, 1), (1, 1)], [True, False, False]), | |
| (if_elif_else, L, "a long", [(None,), (0,), (5,), (-3,)], [-1, 0, 1, 1]), | |
| (assigned, L, "a long", [(1,), (10,)], [2, 11]), | |
| ( | |
| nested, | |
| B, | |
| "a long, b long, c long", | |
| [(1, 1, 5), (-1, 1, 0), (-1, 1, 5)], | |
| [True, True, False], | |
| ), | |
| ] | |
| for i, (func, rt, schema, rows, expected) in enumerate(cases): | |
| with self.subTest(case=i): | |
| self.assertEqual(self._vals(func, rt, schema, rows), expected, f"case {i}: {rows}") | |
| def test_udf_transpile_callable_object_drops_its_receiver(self): | |
| # A callable instance's `self` is dropped before anything indexes the param | |
| # list, so a/b are _udf_param_0/_udf_param_1 with no offsetting anywhere | |
| # downstream (the non-commutative body proves the order). | |
| class SubAB: | |
| def __call__(self, a, b): | |
| return a - b | |
| self.assertEqual( | |
| self._vals(SubAB(), LongType(), "a long, b long", [(5, 3), (3, 5)]), [2, -2] | |
| ) | |
| def test_udf_transpile_plan_elision(self): | |
| # Transpiled UDFs are elided in filter (not just select); a mixed | |
| # non-convertible -> convertible -> non-convertible chain inlines only | |
| # the middle UDF, leaving exactly two Python eval nodes. | |
| offset = 3 | |
| gt5 = lambda x: (x > 5) if x is not None else None # noqa: E731 | |
| add_offset = lambda x: x + offset # noqa: E731 closure -> fallback | |
| plus_one = lambda x: x + 1 # noqa: E731 convertible | |
| div_two = lambda x: x / 2 # noqa: E731 `/` -> fallback | |
| with self.sql_conf(_TRANSPILE_ON): | |
| f = UserDefinedFunction(gt5, BooleanType()) | |
| self.assertTrue(f.transpiled) | |
| fdf = self.spark.createDataFrame([(3,), (7,), (1,), (None,)], "a long").filter(f("a")) | |
| self.assertEqual([r[0] for r in fdf.collect()], [7]) | |
| self.assertEqual(0, self._eval_python_count(fdf)) | |
| u1 = UserDefinedFunction(add_offset, LongType()) | |
| u2 = UserDefinedFunction(plus_one, LongType()) | |
| u3 = UserDefinedFunction(div_two, DoubleType()) | |
| self.assertEqual(([], True, []), (u1.transpiled, bool(u2.transpiled), u3.transpiled)) | |
| chained = ( | |
| self.spark.createDataFrame([(10,)], "a long") | |
| .select(u1("a").alias("x")) | |
| .select(u2("x").alias("y")) | |
| .select(u3("y").alias("z")) | |
| ) | |
| self.assertEqual(chained.first()[0], 7.0) # ((10 + 3) + 1) / 2 | |
| self.assertEqual(2, self._eval_python_count(chained)) | |
| def test_udf_transpile_config_toggle_no_stale_nodes(self): | |
| # Built with the flags on, executed with them off -> clean fallback to | |
| # interpreted Python (the optimizer drops the transpiled node), no error. | |
| plus_one = lambda x: x + 1 # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u = UserDefinedFunction(plus_one, LongType()) | |
| self.assertTrue(u.transpiled) | |
| with self.sql_conf( | |
| { | |
| "spark.sql.experimental.optimizer.transpilePyUDFs": False, | |
| "spark.sql.ansi.enabled": False, | |
| } | |
| ): | |
| df = self.spark.createDataFrame([(1,), (5,)], "a long") | |
| self.assertEqual([r[0] for r in df.select(u("a")).collect()], [2, 6]) | |
| def test_udf_transpile_casts_to_return_type(self): | |
| # The lowered expression is cast to the declared return type. | |
| plus_one = lambda x: x + 1 # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| d = UserDefinedFunction(plus_one, DoubleType()) | |
| col = self.spark.createDataFrame([(1,)], "a long").select(d("a").alias("r")) | |
| self.assertEqual(col.schema["r"].dataType, DoubleType()) | |
| self.assertEqual(col.first()[0], 2.0) | |
| self.assertEqual(self._vals(plus_one, LongType(), "a long", [(1,)]), [2]) | |
| def test_udf_transpile_falls_back(self): | |
| # Shapes that must NOT transpile (and still compute via Python): | |
| # inline/wrapped/partial lambdas, default/variadic/keyword-only args, and | |
| # `%` string formatting. (String `+`/`*` now lower to concat/repeat -- see | |
| # test_udf_transpile_string_operands -- but `%` as a format is not handled.) | |
| import functools | |
| def wrapper(fn): | |
| return fn | |
| def with_default(a, b=0): | |
| return a + 10 * b | |
| def with_varargs(a, *rest): | |
| return a | |
| def with_kwargs(a, **opts): | |
| return a | |
| base = lambda v, w: v + w # noqa: E731 | |
| percent_fmt = lambda x: "n=%d" % x # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| # An inline or wrapped lambda is a call ARGUMENT, so the source is | |
| # read fine but parses as ``Call`` rather than a definition we can | |
| # unwrap; ``functools.partial`` has no reachable source at all. | |
| self.assertEqual([], UserDefinedFunction(lambda v: v + 1, LongType()).transpiled) | |
| self.assertEqual( | |
| [], UserDefinedFunction(wrapper(lambda v: v + 1), LongType()).transpiled | |
| ) | |
| self.assertEqual( | |
| [], UserDefinedFunction(functools.partial(base, 1), LongType()).transpiled | |
| ) | |
| # default / variadic / keyword-only args, and `%` string formatting | |
| for func, rt in [ | |
| (with_default, LongType()), | |
| (with_varargs, LongType()), | |
| (with_kwargs, LongType()), | |
| (percent_fmt, StringType()), | |
| ]: | |
| with self.subTest(func=func): | |
| self.assertEqual([], UserDefinedFunction(func, rt).transpiled) | |
| # Fell back -> interpreted Python still computes correctly. | |
| wd = UserDefinedFunction(with_default, LongType()) | |
| num = self.spark.createDataFrame([(5,)], "a long") | |
| self.assertEqual( | |
| [num.select(wd("a")).first()[0], num.select(wd("a", "a")).first()[0]], [5, 55] | |
| ) | |
| def test_udf_transpile_falls_back_when_a_sibling_lambda_shares_the_line(self): | |
| # ``inspect.getsource`` works in whole lines, so every lambda on a line hands | |
| # back the same source and nothing in it says which one we hold. Taking | |
| # whichever came first was right by position rather than by identity: | |
| # ``minus_one`` lowered ``x + 1``, and the lambda in the decorator lowered | |
| # the decorated ``def``'s body -- both silently wrong. Refusing costs | |
| # ``plus_one``, which the first-match rule happened to get right; being right | |
| # for 1-of-N by position is not something a caller can rely on (SPARK-58650). | |
| # | |
| # ``fmt: off`` keeps the two on one line; asserted below, since the formatter | |
| # would otherwise split them and quietly make this test vacuous. | |
| # fmt: off | |
| plus_one = lambda x: x + 1; minus_one = lambda x: x - 1 # noqa: E702,E731 | |
| # fmt: on | |
| self.assertEqual( | |
| plus_one.__code__.co_firstlineno, | |
| minus_one.__code__.co_firstlineno, | |
| "fixture must keep both lambdas on ONE line or it proves nothing", | |
| ) | |
| captured = [] | |
| def capture(g): | |
| captured.append(g) | |
| return lambda fn: fn | |
| @capture(lambda x: x + 1) | |
| def unrelated(a): | |
| return a * 12345 | |
| (decorator_lambda,) = captured | |
| # A class body is the same hazard: master read the whole line and lowered | |
| # ``helper``, computing 5*100 for a UDF that really returns 5*2. | |
| class TwoOnALine: | |
| helper, __call__ = lambda self, x: x * 100, lambda self, x: x * 2 | |
| self.assertEqual(10, TwoOnALine()(5)) | |
| num = self.spark.createDataFrame([(5,)], "a long") | |
| sibling = "put each lambda on its own line" | |
| not_a_statement = "does not define it as a statement of its own" | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for label, func, expected, needle in [ | |
| # Lowered the WRONG body before -- the bug. | |
| ("second lambda on the line", minus_one, 4, sibling), | |
| ("lambda inside a decorator", decorator_lambda, 6, not_a_statement), | |
| ("__call__ sharing a class-body line", TwoOnALine(), 10, not_a_statement), | |
| # Lowered correctly before; refused now as acknowledged collateral. | |
| ("first lambda on the line", plus_one, 6, sibling), | |
| ]: | |
| with self.subTest(case=label): | |
| u, reasons = self._fallback_reason(func) | |
| # Assert the REASON, so relaxing the guard fails here loudly | |
| # rather than passing for a new and unrelated cause. | |
| self.assertIn(needle, reasons) | |
| self.assertEqual(expected, num.select(u("a")).first()[0]) | |
| # The ``def`` itself is NOT collateral: we know we are holding it, so a | |
| # lambda in its decorator cannot be the body and does not block lowering. | |
| self.assertEqual(self._vals(unrelated, LongType(), "a long", [(5,)]), [61725]) | |
| def test_udf_transpile_refuses_a_lambda_whose_source_now_reads_as_a_def(self): | |
| # ``inspect.getsource`` reads the file as it is NOW, so an edit after import | |
| # can hand back source holding no lambda at all. Refusing only when a SIBLING | |
| # lambda is in view failed open here, lowering an unrelated ``def`` as the | |
| # body: 5 * 12345 for a lambda Python evaluates to 6. | |
| import importlib.util | |
| import os | |
| import tempfile | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = os.path.join(tmp, "drifted_lambda.py") | |
| with open(path, "w") as handle: | |
| handle.write("f = lambda x: x + 1\n") | |
| spec = importlib.util.spec_from_file_location("drifted_lambda", path) | |
| module = importlib.util.module_from_spec(spec) | |
| spec.loader.exec_module(module) | |
| with open(path, "w") as handle: | |
| handle.write("def unrelated(a):\n return a * 12345\n") | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u, reasons = self._fallback_reason(module.f) | |
| # Pin the REASON, or a source read that merely failed would pass too. | |
| self.assertIn("which lambda to lower cannot be determined", reasons) | |
| num = self.spark.createDataFrame([(5,)], "a long") | |
| self.assertEqual(6, num.select(u("a")).first()[0]) | |
| def test_udf_transpile_ambiguity_check_sees_only_rival_lambdas(self): | |
| # Two ways the check used to misfire or not fire at all. | |
| from pyspark.sql.transpile import _held_code | |
| # A lambda nested in the held lambda's own body is not a rival -- it can | |
| # never be the UDF, and the user cannot split it onto another line. It fell | |
| # back with the sibling message, advice that could not be acted on. | |
| nested = lambda x: (lambda y: y + 1)(x) # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| _, reasons = self._fallback_reason(nested) | |
| self.assertIn("Call", reasons, "must fall back for the body, not for ambiguity") | |
| self.assertNotIn("put each lambda on its own line", reasons) | |
| # The mirror: a lambda RETURNED by a one-line lambda. Here the outer one is | |
| # what the source read locates and the inner one is what we hold, so treating | |
| # nested lambdas as never-rivals let this through -- and it proceeded with the | |
| # outer signature, reporting `n` as the UDF's parameter for a UDF whose only | |
| # parameter is `x`. Matching the located lambda's parameters against the held | |
| # code object's is what separates this from the case above. | |
| # fmt: off | |
| make_adder = lambda n: lambda x: x + n # noqa: E731 | |
| # fmt: on | |
| add_three = make_adder(3) | |
| self.assertEqual(8, add_three(5)) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u, reasons = self._fallback_reason(add_three) | |
| self.assertIn("takes different parameters", reasons) | |
| self.assertEqual([], u._transpiled_param_names or []) | |
| self.assertEqual( | |
| 8, self.spark.createDataFrame([(5,)], "a long").select(u("a")).first()[0] | |
| ) | |
| # ``staticmethod``/``classmethod`` hide ``__code__`` behind the descriptor, so | |
| # reading it off them left the guard inactive -- skipping the check for a shape | |
| # it exists to catch. Now that these lower at all, the skip would be a wrong | |
| # answer: ``helper`` shares the line and takes the same parameter, so it is a | |
| # true rival and would be lowered instead (5 * 9, not 5 * 2). | |
| class Wrapped: | |
| # fmt: off | |
| helper = lambda x: x * 9; __call__ = staticmethod(lambda x: x * 2) # noqa: E702,E731 | |
| # fmt: on | |
| self.assertEqual( | |
| Wrapped.helper.__code__.co_firstlineno, | |
| Wrapped.__call__.__code__.co_firstlineno, | |
| "fixture must keep both lambdas on ONE line or it proves nothing", | |
| ) | |
| self.assertEqual( | |
| "<lambda>", | |
| getattr(_held_code(Wrapped()), "co_name", None), | |
| "the held code must be found inside the descriptor", | |
| ) | |
| self.assertEqual(10, Wrapped()(5)) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| u, reasons = self._fallback_reason(Wrapped()) | |
| self.assertIn("put each lambda on its own line", reasons) | |
| num = self.spark.createDataFrame([(5,)], "a long") | |
| self.assertEqual(10, num.select(u("a")).first()[0]) | |
| def test_udf_transpile_lowers_an_annotated_lambda_binding(self): | |
| # An annotated binding is the same shape as a plain one, and the form a typed | |
| # codebase writes. Only ``ast.Assign`` was unwrapped, so this was refused as | |
| # "not a statement of its own" -- while the module docstring told users to | |
| # bind the lambda to a name and give it a line, which is exactly this. | |
| from typing import Callable | |
| annotated: Callable[[int], int] = lambda x: x + 1 # noqa: E731 | |
| self.assertEqual(self._vals(annotated, LongType(), "a long", [(5,)]), [6]) | |
| def test_udf_transpile_recovers_shapes_with_an_unheld_lambda_in_view(self): | |
| # The ambiguity check applies only when the callable we hold IS a lambda. The | |
| # lambdas below belong to a ``def`` we are not lowering, so refusing on their | |
| # account would cost lowering for nothing. | |
| from typing import Annotated | |
| def annotated(x: Annotated[int, lambda v: v > 0]) -> int: | |
| return x + 1 | |
| def returns_annotated(x) -> Annotated[int, lambda v: v > 0]: | |
| return x + 2 | |
| for label, func, expected in [ | |
| ("lambda in a parameter annotation", annotated, 6), | |
| ("lambda in the return annotation", returns_annotated, 7), | |
| ]: | |
| with self.subTest(case=label): | |
| self.assertEqual(self._vals(func, LongType(), "a long", [(5,)]), [expected]) | |
| def test_udf_transpile_resolves_call_on_the_type_not_the_instance(self): | |
| # Python's call protocol looks ``__call__`` up on the TYPE, so an instance | |
| # attribute of that name is never what runs. ``getattr(obj, "__call__")`` | |
| # finds it anyway, so the transpiler used to lower the shadowing body and | |
| # return 5*99 where Python returns 5*4 -- silently wrong. The type's | |
| # ``__call__`` must win, and it must still lower. | |
| class Shadowed: | |
| def __call__(self, x): | |
| return x * 4 | |
| shadowed = Shadowed() | |
| # Alone on its line: a leading statement on the same line would make the | |
| # shadowing body unreachable for an unrelated reason and prove nothing. | |
| shadowed.__call__ = lambda x: x * 99 | |
| self.assertEqual(20, shadowed(5), "the type's __call__ is what Python runs") | |
| self.assertEqual(self._vals(shadowed, LongType(), "a long", [(5,)]), [20]) | |
| def test_udf_transpile_refuses_a_class_object(self): | |
| # Calling a CLASS whose metaclass is ``type`` runs ``__init__`` and yields an | |
| # instance, so its own ``__call__`` is never the body -- but that is the body | |
| # the old ``getattr(func, "__call__")`` found. Pinned for the | |
| # dynamically-created case too, where ``getsource`` cannot fall back to a | |
| # ``ClassDef``. (A class with a custom metaclass IS callable through | |
| # ``Meta.__call__``, and is resolved through it rather than refused.) | |
| class Lexical: | |
| def __init__(self, x): | |
| self.v = x * 7 | |
| def __call__(self, x): | |
| return x * 1000 | |
| def impl(self, x): | |
| return x * 1000 | |
| Dynamic = type("Dynamic", (), {"__call__": impl}) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for label, cls in [("lexical class", Lexical), ("type() class", Dynamic)]: | |
| with self.subTest(case=label): | |
| self.assertEqual([], UserDefinedFunction(cls, LongType()).transpiled) | |
| def test_udf_transpile_strips_a_bound_receiver_by_dispatch_not_by_name(self): | |
| # The receiver used to be dropped only when literally named ``self``, so a | |
| # bound ``__call__(this, x)`` or ``@classmethod f(cls, x)`` kept it in the | |
| # public parameter list. That declares one parameter too many and shifts | |
| # every ``_udf_param_N``: a two-column call returned column b's value where | |
| # Python raises TypeError. | |
| class Recv: | |
| def __call__(this, x): | |
| return x + 1 | |
| class Meth: | |
| def act(this, x): | |
| return x + 2 | |
| class Cls: | |
| @classmethod | |
| def act(kls, x): | |
| return x + 3 | |
| # A ``__call__`` that is a classmethod, or one that is ALREADY a bound method, | |
| # also has its receiver spoken for -- the class and the method's own | |
| # ``__self__`` respectively. Both used to keep it in the public list, which | |
| # shifts every placeholder. Each expectation below is what Python returns. | |
| class ClsCall: | |
| @classmethod | |
| def __call__(kls, x): | |
| return x + 4 | |
| class Helper: | |
| def impl(self, x): | |
| return x + 5 | |
| class BoundCall: | |
| __call__ = Helper().impl | |
| # And the two compose: a ``staticmethod`` prepends nothing, but the method it | |
| # wraps is already bound, so one parameter is still spoken for. Counting only | |
| # what the descriptor prepends declared a parameter too many here. | |
| class StaticBound: | |
| __call__ = staticmethod(Helper().impl) | |
| for label, func, expected in [ | |
| ("__call__ receiver not named self", Recv(), 6), | |
| ("bound method receiver not named self", Meth().act, 7), | |
| ("classmethod receiver not named self", Cls.act, 8), | |
| ("classmethod as __call__", ClsCall(), 9), | |
| ("already-bound method as __call__", BoundCall(), 10), | |
| ("staticmethod over a bound method", StaticBound(), 10), | |
| ]: | |
| with self.subTest(case=label): | |
| self.assertEqual(func(5), expected, "fixture must match Python's own answer") | |
| self.assertEqual(self._vals(func, LongType(), "a long", [(5,)]), [expected]) | |
| # Both at once is a callable Python itself rejects: the classmethod prepends | |
| # the class ON TOP of the method's own receiver, leaving no parameter for the | |
| # call site. Counting one receiver returned a value where Python raises. | |
| class ClassBound: | |
| __call__ = classmethod(Helper().impl) | |
| with self.assertRaises(TypeError): | |
| ClassBound()(5) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| _, reasons = self._fallback_reason(ClassBound()) | |
| self.assertIn("leaves no parameter for the call site", reasons) | |
| # The mirror: a ``staticmethod`` ``__call__`` prepends nothing, so its leading | |
| # ``self`` IS supplied at the call site and both parameters are public. | |
| # Resolving ``__call__`` on the type (rather than via getattr on the instance, | |
| # which fires the descriptor) hands back the raw ``staticmethod``, which | |
| # carries a ``__wrapped__`` of its own -- so for a while on this branch the | |
| # wraps guard refused every one of them for a decorator that is not there. | |
| class Static: | |
| @staticmethod | |
| def __call__(self, x): | |
| return self + x | |
| self.assertEqual(14, Static()(5, 9), "staticmethod __call__ binds no receiver") | |
| self.assertEqual( | |
| self._vals(Static(), LongType(), "a long, b long", [(5, 9)]), | |
| [14], | |
| "both parameters come from the call site", | |
| ) | |
| def test_udf_transpile_known_value_divergences(self): | |
| # Transpile but DIVERGE from Python (documented in transpile.py; pinned so | |
| # a future fix is noticed): unguarded arithmetic on NULL yields NULL | |
| # (Python raises TypeError), and NaN > 0 is True (Python False; Spark | |
| # orders NaN highest). Mixed str/numeric arithmetic is handled or falls | |
| # back -- see test_udf_transpile_string_operands{,_fall_back}. | |
| unguarded = lambda x: x + 1 # noqa: E731 | |
| nan_gt = lambda x: (x > 0) if x is not None else None # noqa: E731 | |
| eq_strlit = lambda x: (x == "5") if x is not None else None # noqa: E731 | |
| self.assertEqual(self._vals(unguarded, LongType(), "a long", [(None,), (5,)]), [None, 6]) | |
| self.assertEqual( | |
| self._vals(nan_gt, BooleanType(), "a double", [(float("nan"),), (1.0,)]), [True, True] | |
| ) | |
| # `x == "5"` used to be pinned as a coercion divergence (int == "5" -> True). | |
| # The eq category gate now drops the numeric variant, so on a long column the | |
| # string option is pruned, nothing is left to lower (hence require_lowered | |
| # =False), and the UDF falls back to interpreted Python -- matching Python's | |
| # cross-type == (always False). | |
| self.assertEqual( | |
| self._vals(eq_strlit, BooleanType(), "a long", [(5,), (3,)], require_lowered=False), | |
| [False, False], | |
| ) | |
| def test_udf_transpile_overflow_and_modulo_zero_raise(self): | |
| # Transpiled arithmetic that raises at runtime: `*` overflow raises under | |
| # ANSI where Python promotes to a big int (a real divergence, SPARK-55210), | |
| # while `% 0` raises in both Spark and Python (compatible -- pinned here so | |
| # it isn't mistaken for a divergence). | |
| overflow = lambda x: x * x # noqa: E731 | |
| modulo_zero = lambda x: x % 0 # noqa: E731 | |
| self._raises(overflow, "a long", [(4000000000,)], "overflow") | |
| self._raises(modulo_zero, "a long", [(5,)], "zero") | |
| def test_udf_transpile_string_operands(self): | |
| # Textual `+`/`*` lower to Catalyst string ops and match Python: `str + | |
| # str` -> concat, and `str * int` / `int * str` -> repeat (including a | |
| # string column times a numeric literal). The transpiler emits a string- | |
| # typed variant whose declared categories the JVM matches against the bound | |
| # column types (see UserDefinedPythonFunction.builder). | |
| S = StringType() | |
| add = lambda a, b: a + b # noqa: E731 | |
| mul = lambda a, b: a * b # noqa: E731 | |
| mul3 = lambda a: a * 3 # noqa: E731 | |
| concat_right = lambda a: a + "!" # noqa: E731 | |
| concat_left = lambda a: "pre-" + a # noqa: E731 | |
| repeat_lit = lambda x: "ab" * x # noqa: E731 | |
| # (func, return_type, schema, rows, expected); arg columns come from schema. | |
| cases = [ | |
| (add, S, "a string, b string", [("x", "y"), ("a", "b")], ["xy", "ab"]), | |
| (mul, S, "a string, b long", [("ab", 3)], ["ababab"]), | |
| (mul, S, "a long, b string", [(3, "ab")], ["ababab"]), | |
| (mul3, S, "a string", [("2",), ("ab",)], ["222", "ababab"]), | |
| (concat_right, S, "a string", [("hi",)], ["hi!"]), | |
| (concat_left, S, "a string", [("x",)], ["pre-x"]), | |
| (repeat_lit, S, "a long", [(3,)], ["ababab"]), | |
| ] | |
| for i, (func, rt, schema, rows, expected) in enumerate(cases): | |
| with self.subTest(case=i): | |
| self.assertEqual(self._vals(func, rt, schema, rows), expected, f"case {i}") | |
| def test_udf_transpile_string_len(self): | |
| # SPARK-55214: Empty, ASCII, and concat inputs match Python. | |
| # Unguarded ``len(NULL)`` raises like CPython (not Spark length's NULL). | |
| # A proven-non-null branch still returns None for NULL rows. ``len`` on | |
| # a numeric column has no matching string option, so the UDF falls back | |
| # and Python raises TypeError. | |
| L = LongType() | |
| strlen = lambda x: len(x) # noqa: E731 | |
| len_concat = lambda a, b: len(a + b) # noqa: E731 | |
| strlen_guarded = lambda x: len(x) if x is not None else None # noqa: E731 | |
| self.assertEqual( | |
| self._vals(strlen, L, "a string", [("",), ("ab",), ("a",)]), | |
| [0, 2, 1], | |
| ) | |
| self.assertEqual( | |
| self._vals(len_concat, L, "a string, b string", [("x", "yz"), ("", "ab")]), | |
| [3, 2], | |
| ) | |
| self.assertEqual( | |
| self._vals(strlen_guarded, L, "a string", [("ab",), (None,)]), | |
| [2, None], | |
| ) | |
| self._raises(strlen, "a string", [(None,)], needle="len()") | |
| self._raises(strlen, "a long", [(5,)], needle="") | |
| def test_udf_transpile_string_operands_fall_back(self): | |
| # Operand/type combos with no valid string lowering for the bound column | |
| # types fall back to the Python UDF, which raises the same way CPython does: | |
| # `str + int` (and reversed), `str - int`, `str * str`, `str % int`, and a | |
| # string column plus a numeric literal. The transpiler still emits numeric | |
| # (and/or concat/repeat) variants, but none match the column types, so the | |
| # JVM drops them and runs Python -- matching its TypeError. | |
| add = lambda a, b: a + b # noqa: E731 | |
| sub = lambda a, b: a - b # noqa: E731 | |
| mul = lambda a, b: a * b # noqa: E731 | |
| mod = lambda a, b: a % b # noqa: E731 | |
| add5 = lambda a: a + 5 # noqa: E731 | |
| # needle="" -> assert only that it raises (the message is CPython's). | |
| for func, schema, rows in [ | |
| (add, "a string, b long", [("10", 5)]), # str + int | |
| (add, "a long, b string", [(5, "10")]), # int + str | |
| (sub, "a string, b long", [("10", 5)]), # str - int | |
| (mul, "a string, b string", [("a", "b")]), # str * str | |
| (mod, "a string, b long", [("10", 3)]), # str % int | |
| (add5, "a string", [("10",)]), # str column + numeric literal | |
| ]: | |
| with self.subTest(func=func, schema=schema): | |
| self._raises(func, schema, rows, needle="") | |
| def test_udf_transpile_power_falls_back(self): | |
| # `**` is intentionally not lowered (Spark's pow is DOUBLE and loses | |
| # precision for large ints), so a UDF using it falls back to interpreted | |
| # Python. TODO(SPARK-55210): revisit once an exact integer-power lowering | |
| # exists. | |
| square = lambda x: x**2 # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| self.assertFalse(UserDefinedFunction(square, LongType()).transpiled) | |
| def test_udf_transpile_non_numeric_constant_falls_back(self): | |
| # bool/None constants have no faithful numeric/string lowering, so | |
| # arithmetic against them must fall back rather than emit an option that | |
| # crashes analysis (`x * True`) or silently returns NULL (`x + None`). | |
| mul_bool = lambda x: x * True # noqa: E731 | |
| add_none = lambda x: x + None # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| self.assertFalse(UserDefinedFunction(mul_bool, LongType()).transpiled) | |
| self.assertFalse(UserDefinedFunction(add_none, LongType()).transpiled) | |
| def test_udf_transpile_mixed_type_comparison_falls_back(self): | |
| # Python forbids ordering across types (`a < b` for int/str -> TypeError); | |
| # Spark would coerce and return a wrong boolean. A comparison whose | |
| # operand categories differ is dropped (so int-vs-str `<` falls back), | |
| # while a same-category comparison still transpiles. | |
| def lt_mixed(a: int, b: str): | |
| return (a < b) if a is not None and b is not None else None | |
| def lt_same(a: int, b: int): | |
| return (a < b) if a is not None and b is not None else None | |
| with self.sql_conf(_TRANSPILE_ON): | |
| self.assertFalse(UserDefinedFunction(lt_mixed, BooleanType()).transpiled) | |
| self.assertTrue(UserDefinedFunction(lt_same, BooleanType()).transpiled) | |
| def test_udf_transpile_skips_nondeterministic(self): | |
| # A nondeterministic UDF must not be transpiled: the optimizer could | |
| # fold/reorder/duplicate the plain expression, dropping the barrier. | |
| # Holds whether marked at construction or via asNondeterministic(). | |
| plus_one = lambda x: x + 1 # noqa: E731 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| self.assertTrue(UserDefinedFunction(plus_one, LongType()).transpiled) | |
| self.assertFalse( | |
| UserDefinedFunction(plus_one, LongType()).asNondeterministic().transpiled | |
| ) | |
| self.assertFalse( | |
| UserDefinedFunction(plus_one, LongType(), deterministic=False).transpiled | |
| ) | |
| def test_udf_transpile_bool_and_binary_params(self): | |
| # bool/bytes annotations map to the "bool"/"binary" categories and match | |
| # Boolean/Binary columns. Identity and same-category comparison transpile | |
| # (and match Python); boolean arithmetic has no lowering and falls back. | |
| def bool_ident(x: bool): | |
| return x | |
| def bool_lt(a: bool, b: bool): | |
| return (a < b) if a is not None and b is not None else None | |
| def bool_add(x: bool): | |
| return x + 1 # no boolean arithmetic lowering -> fall back | |
| def bytes_ident(x: bytes): | |
| return x | |
| self.assertEqual( | |
| self._vals(bool_ident, BooleanType(), "a boolean", [(True,), (False,), (None,)]), | |
| [True, False, None], | |
| ) | |
| self.assertEqual( | |
| self._vals( | |
| bool_lt, | |
| BooleanType(), | |
| "a boolean, b boolean", | |
| [(False, True), (True, False), (True, True)], | |
| ), | |
| [True, False, False], | |
| ) | |
| with self.sql_conf(_TRANSPILE_ON): | |
| self.assertFalse(UserDefinedFunction(bool_add, LongType()).transpiled) | |
| self.assertTrue(UserDefinedFunction(bytes_ident, BinaryType()).transpiled) | |
| def test_param_category_combos_caps_preserve_typed_pins(self): | |
| # With more than three untyped params the cap collapses the untyped ones | |
| # 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"]) | |
| # 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"}) | |
| def test_udf_transpile_null_narrowing_skips_null_guard(self): | |
| # When a parameter is guarded by `x is not None` in an | |
| # if/ternary test, the transpiler should skip the raise_error null guard | |
| # for ordering comparisons on that parameter within the guarded branch. | |
| def if_guard_gt(x): | |
| return (x > 0) if x is not None else None | |
| def if_guard_and_lt(a, b): | |
| return (a < b) if a is not None and b is not None else None | |
| def ternary_guard_ge(x): | |
| return (x >= 0) if x is not None else None | |
| def is_none_ternary(x): | |
| return None if x is None else x > 0 | |
| cases_if = [ | |
| # if/else with `is not None` guard | |
| ("if_guard_gt", if_guard_gt), | |
| # if/else with `is not None` and-chain (two params) | |
| ("if_guard_and_lt", if_guard_and_lt), | |
| # ternary with `is not None` guard | |
| ("ternary_guard_ge", ternary_guard_ge), | |
| # `is None` guard: non-null in the else branch | |
| ("is_none_ternary", is_none_ternary), | |
| ] | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for name, func in cases_if: | |
| with self.subTest(func=name): | |
| u, warnings_text = self._udf_and_warnings(func, BooleanType()) | |
| self.assertTrue(u.transpiled, f"{name} did not transpile: {warnings_text}") | |
| arity = func.__code__.co_argcount | |
| if arity == 1: | |
| df = self.spark.createDataFrame([(1,), (-1,), (None,)], "a long") | |
| plan = self._optimized_plan(df.select(u("a"))) | |
| self.assertNotIn("raise_error", plan, f"{name}: plan had raise_error") | |
| self.assertEqual( | |
| [True, False, None], | |
| [r[0] for r in df.select(u("a")).collect()], | |
| name, | |
| ) | |
| else: | |
| df = self.spark.createDataFrame( | |
| [(1, 2), (2, 1), (None, 1)], "a long, b long" | |
| ) | |
| plan = self._optimized_plan(df.select(u("a", "b"))) | |
| self.assertNotIn("raise_error", plan, f"{name}: plan had raise_error") | |
| self.assertEqual( | |
| [True, False, None], | |
| [r[0] for r in df.select(u("a", "b")).collect()], | |
| name, | |
| ) | |
| def test_udf_transpile_early_return_null_guard(self): | |
| # A 2-statement early-return pattern | |
| # `if x is None: return y\nreturn body` is normalized to a single | |
| # if/else so null narrowing applies to the main body. | |
| def early_return_gt(x): | |
| if x is None: | |
| return None | |
| return x > 0 | |
| def early_return_lt_default(x): | |
| if x is None: | |
| return False | |
| return x < 10 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for name, func, rt, rows, expected in [ | |
| ( | |
| "early_return_gt", | |
| early_return_gt, | |
| BooleanType(), | |
| [(1,), (-1,), (None,)], | |
| [True, False, None], | |
| ), | |
| ( | |
| "early_return_lt_default", | |
| early_return_lt_default, | |
| BooleanType(), | |
| [(5,), (15,), (None,)], | |
| [True, False, False], | |
| ), | |
| ]: | |
| with self.subTest(func=name): | |
| u, warnings_text = self._udf_and_warnings(func, rt) | |
| self.assertTrue(u.transpiled, f"{name} did not transpile: {warnings_text}") | |
| df = self.spark.createDataFrame(rows, "a long") | |
| plan = self._optimized_plan(df.select(u("a"))) | |
| self.assertNotIn("raise_error", plan, f"{name}: plan had raise_error") | |
| self.assertEqual( | |
| expected, | |
| [r[0] for r in df.select(u("a")).collect()], | |
| name, | |
| ) | |
| def test_udf_transpile_null_guard_warning(self): | |
| # When the transpiler still emits a null guard (because | |
| # non-null cannot be proven), it emits a UserWarning at construction time. | |
| import warnings | |
| def unguarded_gt(x): | |
| return x > 0 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| with warnings.catch_warnings(record=True) as caught: | |
| warnings.simplefilter("always") | |
| u = UserDefinedFunction(unguarded_gt, BooleanType()) | |
| null_guard_warnings = [w for w in caught if "NULL guard" in str(w.message)] | |
| self.assertTrue(u.transpiled, "unguarded_gt should transpile (under strict or loose)") | |
| self.assertTrue(null_guard_warnings, "Expected a UserWarning about NULL guard") | |
| self.assertIn("NULL guard", str(null_guard_warnings[0].message)) | |
| def test_udf_transpile_null_narrowing_len(self): | |
| # A guarded len() call should skip the raise_error null guard. | |
| def len_guarded(x): | |
| if x is not None: | |
| return len(x) > 3 | |
| return None | |
| def len_early_return(x): | |
| if x is None: | |
| return None | |
| return len(x) > 3 | |
| with self.sql_conf(_TRANSPILE_ON): | |
| for name, func in [ | |
| ("len_guarded", len_guarded), | |
| ("len_early_return", len_early_return), | |
| ]: | |
| with self.subTest(func=name): | |
| u, warnings_text = self._udf_and_warnings(func, BooleanType()) | |
| self.assertTrue(u.transpiled, f"{name} did not transpile: {warnings_text}") | |
| df = self.spark.createDataFrame([("hi",), ("hello",), (None,)], "a string") | |
| plan = self._optimized_plan(df.select(u("a"))) | |
| self.assertNotIn("raise_error", plan, f"{name}: plan had raise_error") | |
| self.assertEqual( | |
| [False, True, None], | |
| [r[0] for r in df.select(u("a")).collect()], | |
| name, | |
| ) | |
| if __name__ == "__main__": | |
| from pyspark.testing import main | |
| main() |