HyukjinKwon commented on code in PR #59216:
URL: https://github.com/apache/spark/pull/59216#discussion_r4260389198


##########
python/pyspark/sql/transpile.py:
##########
@@ -673,60 +890,49 @@ def _convert_chunk(self, params: List[str], body: ast.AST 
| None) -> Column:
                     orelse[0] if orelse else None,
                 )
             case ast.Compare(left, ops, comps):
-                if len(ops) != 1 or len(comps) != 1:
+                # `in` / `not in` are container membership, not a chainable 
value
+                # comparison, and need their own NULL analysis: Python's `in` 
is
+                # equality-based and never raises on a `None` probe. Not 
lowered

Review Comment:
   **Nit (P3):** Python's `in` is not always equality-based, and it can raise 
on a `None` probe. For a str container it is substring containment, and `None 
in "abc"` raises `TypeError: 'in <string>' requires string as left operand, not 
NoneType`. The comment in `test_udf_transpile_comparison_fallbacks` already 
says str `in` is substring containment. Tuples and lists do use equality.
   
   This comment states the semantics a future `in` lowering would follow, so an 
implementation built from it would return a value where Python raises. Consider 
saying that the semantics depend on the container, which is why membership 
needs its own NULL analysis.
   
   **Verification:**
   - **Inspection:** The refusal comment's description of Python `in` matches 
CPython for str and tuple containers.



##########
python/pyspark/sql/transpile.py:
##########
@@ -132,6 +199,45 @@
     from pyspark.sql._typing import DataTypeOrString
 
 
+# The most operators we will lower in one chained comparison (64 operators over
+# 65 operands); longer chains fall back to interpreted Python. For a FLAT chain
+# this is close to linear: each interior operand is lowered once per adjacent
+# pair, and only two options survive because every adjacent pair must share a
+# category, leaving all-numeric and all-string. Measured over three untyped
+# params: 2 options, one `raise_error` guard per operator, ~17 KB of expression
+# per option. Generous next to real code like ``0 <= x <= 100``.
+_MAX_COMPARISON_OPS = 64
+
+# Total comparison links we will lower for one function body, across every
+# nesting level. This is the bound that actually matters, and a per-form 
operator
+# count cannot express it: a comparison's operand may itself be a comparison
+# (both have category "bool", so `(a < b) < (c < d)` type-checks), and a chain
+# re-lowers each interior operand once per adjacent pair. Nesting those two 
facts
+# doubles the work per level while every individual node stays far under
+# `_MAX_COMPARISON_OPS`.
+#
+# Measured before this bound existed, with `L(d) = "((a<b) < L(d-1) < (a<b))"`:
+# UDF construction took 8.0s at d=8, 32.0s at d=10 and 63.4s at d=11 -- exact
+# doubling, from a 249-character body, and extrapolating to hours by d=20. Each
+# link is a py4j round trip on the driver, so that is a wedged driver at
+# `udf(...)` time, which `_transpile_func`'s blanket `except` cannot recover
+# from. Counting links once across the whole lowering turns it into a refusal
+# and a fallback to interpreted Python.
+#
+# 128 leaves flat chains at the cap untouched (64 links) while holding the 
worst
+# nested case to well under a second.
+_MAX_LOWERED_COMPARISONS = 128

Review Comment:
   **Nit (P3):** This budget is charged once per link in `_lower_compare_pair`, 
but each lowering references its operand Columns more than once. 
`_lower_value_compare` uses each operand in both the NULL guard and the 
comparison, and `_lower_eq` uses each one three times. 
`UserDefinedPythonFunction` converts every option with 
`ColumnConversions.expression`, and `ColumnNodeToExpressionConverter` recurses 
into every child with no memoization, so a shared Column DAG is expanded into a 
tree.
   
   The new `case ast.Compare(): return "bool"` arm lets comparisons nest at any 
depth. A right-nested body such as `(x < 0) < ((x < 0) < (...))` is charged 
about 2d+1 links but materializes on the order of 2^d nodes (3^d with `==`). At 
depth 20 (41 links, about 250 characters) that is roughly 4.6e7 nodes. The 
expansion happens on the UDF's first call, outside `_transpile_func`'s fallback 
`except`, so for these shapes the budget does not deliver the refusal and 
fallback this comment promises.
   
   I did not reproduce the JVM hang end to end; the mechanism comes from the 
converter source and a Python-side node count. Inputs like this are 
pathological and the path is experimental, but they are the input class the 
budget was added for.
   
   **Recommended change:** Charge the comparison budget with the materialized 
(tree) size each comparison lowering emits, counting each operand's expanded 
size once per reference, and refuse once a body's materialized size passes a 
bound; correct the budget comments; and add transpile unit tests for nested 
comparison shapes whose link count grows linearly.
   
   **Why this works:** Measuring what the JVM materializes, rather than how 
many links Python lowers, makes every accepted option bounded in size, so 
pathological nesting is refused at UDF construction and falls back to 
interpreted Python, as the PR intends.
   
   **Scope:** Make the comparison budget measure materialized expression size 
and cover linearly nested comparison shapes in tests.
   
   **Compatibility:** Preserve behavior outside the stated repair boundary.
   
   **Risks:** A size bound set too low could refuse realistic flat chains; it 
must keep the 64-operator flat chain and the existing shallow nested cases 
lowering.
   
   **Constraints:** Keep resetting the budget per input-type variant in 
_transpile_from_ast. Account for any structure the chain-fold repair adds.
   
   **Success:** A right-nested `(x < 0) < (... (x < 0))` body and a `((x == 1) 
== True) == ...` body of about 20 levels are refused at UDF construction and 
fall back to interpreted Python. A flat 64-operator chain and the existing 
shallow nested bodies still lower.
   
   **Verification:**
   - **Regression:** Nested comparison bodies whose link count grows linearly 
but whose materialized size grows exponentially are refused by the budget.
   - **Compatibility:** The existing cap-boundary cases (flat chain at the cap, 
shallow and deep doubling nests) keep their current outcomes.



##########
python/pyspark/sql/tests/test_udf_transpile_unit.py:
##########
@@ -491,39 +506,345 @@ def gte_zero(x):
                     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.
+    def test_udf_transpile_chain_short_circuits(self):
+        # THE test for the chained lowering. Python's chain short-circuits: 
when
+        # ``a < b`` is false, ``b < c`` is never evaluated, so a NULL ``c`` 
cannot
+        # raise. `&` short-circuits at evaluation time too, so the fold 
matches.
+        # Nullable *columns* are required: a literal `None` has no column
+        # category, so `1 < 0 < None` just falls back.
+        #
+        # Both of `And`'s two laziness implementations are exercised: 
`doGenCode`
+        # under the default FALLBACK factory mode, and `eval` under NO_CODEGEN.
+        # `spark.sql.codegen.wholeStage=false` is NOT enough for the latter -- 
it
+        # disables whole-stage fusion, but expressions are still compiled 
through
+        # the codegen factory, so `doGenCode` runs either way. Survival 
through the
+        # OPTIMIZER is separate; see test_udf_transpile_boolop_short_circuits.
+        def chained(a, b, c):
+            return a < b < c
+
+        schema = "a long, b long, c long"
+        for factory_mode in ("FALLBACK", "NO_CODEGEN"):
+            conf = dict(_TRANSPILE_ON)
+            conf["spark.sql.codegen.factoryMode"] = factory_mode
+            with self.subTest(factory_mode=factory_mode), self.sql_conf(conf):
+                pudf = UserDefinedFunction(chained, BooleanType())
+                self.assertTrue(pudf.transpiled, "3-column chain should 
transpile")
+                df = self.spark.createDataFrame([(1, 0, None)], schema)
+                [row] = df.select(pudf("a", "b", "c")).collect()
+                self.assertEqual(
+                    False,
+                    row[0],
+                    "first link is false, so the NULL second link must not be 
evaluated",
+                )
+                # ... but when the first link IS true the second link is 
reached
+                # and its NULL guard fires, exactly as Python raises TypeError.
+                df = self.spark.createDataFrame([(0, 1, None)], schema)
+                with self.assertRaises(Exception) as ctx:
+                    df.select(pudf("a", "b", "c")).collect()
+                self.assertIn("cannot compare NULL", str(ctx.exception))
+
+    def test_udf_transpile_boolop_short_circuits(self):
+        # The `and` / `or` counterpart of the chain short-circuit: a NULL 
guard in
+        # the second operand must not fire when the first already decided the
+        # result.
+        #
+        # This is the regression test for SPARK-58627. Before `RaiseError`
+        # overrode `Expression.throwable`, `splitConjunctivePredicates` 
decomposed
+        # the `And` and `PushPredicateThroughJoin` pushed the guard to one 
side of
+        # the join, where it fired on rows Python short-circuits past and 
raised
+        # USER_RAISED_EXCEPTION instead of returning False. Keep it: the 
override
+        # is what makes the `&` / `|` fold survive the optimizer, so this is 
the
+        # test that notices if it regresses.
+        #
+        # The join case disables AQE and broadcast on purpose: otherwise 
whether
+        # the pushed-down guard fires races AQE eliminating the empty join, 
and the
+        # test would pass for the wrong reason.
+        def both_positive(a, c):
+            return a > 0 and c > 0
+
+        def either_positive(a, c):
+            return a > 0 or c > 0
+
+        with self.sql_conf(_TRANSPILE_ON):
+            and_udf = UserDefinedFunction(both_positive, BooleanType())
+            or_udf = UserDefinedFunction(either_positive, BooleanType())
+            self.assertTrue(and_udf.transpiled, "and over compares should 
transpile")
+            self.assertTrue(or_udf.transpiled, "or over compares should 
transpile")
+
+            # Plain select: the second operand is NULL but the first already
+            # decided the result, so Python's answer must come back unraised.
+            df = self.spark.createDataFrame([(-5, None)], "a long, c long")
+            self.assertEqual([False], [r[0] for r in df.select(and_udf("a", 
"c")).collect()])
+            df = self.spark.createDataFrame([(5, None)], "a long, c long")
+            self.assertEqual([True], [r[0] for r in df.select(or_udf("a", 
"c")).collect()])
+
+        # Same thing across a join, where the NULL operand lives entirely on 
the
+        # right side. Deterministic plan: no AQE, no broadcast, so the 
pushed-down
+        # guard either exists or does not, rather than racing the join.
+        deterministic = dict(_TRANSPILE_ON)
+        deterministic["spark.sql.adaptive.enabled"] = False
+        deterministic["spark.sql.autoBroadcastJoinThreshold"] = -1
+        with self.sql_conf(deterministic):
+            and_udf = UserDefinedFunction(both_positive, BooleanType())
+            left = self.spark.createDataFrame([(1, -5)], "k long, a long")
+            right = self.spark.createDataFrame([(1, None)], "k long, c long")
+            joined = left.join(right, "k")
+            self.assertEqual([False], [r[0] for r in 
joined.select(and_udf("a", "c")).collect()])
+
+            # Filtering must return Python's answer rather than raising. This 
is
+            # the assertion SPARK-58627 fixed; see above.
+            filtered = joined.filter(and_udf("a", "c"))

Review Comment:
   **Nit (P3):** The join block asserts only `[False]` for the select and `[]` 
for this filter. Interpreted Python returns the same values. If the JVM 
discards the transpiled options for the joined query, every assertion still 
passes, and the pushed-down-guard regression this test exists to catch goes 
unnoticed. The block also rebuilds `and_udf` without re-asserting `transpiled`.
   
   The file's `_vals` helper already explains why `transpiled` alone is not 
enough, and it checks `self._eval_python_count(...) == 0`. The same check here, 
for both the joined select and the filter, would make this test fail when the 
fallback runs.
   
   **Verification:**
   - **Regression:** The joined select and filter each assert zero EvalPython 
nodes alongside their result checks.



##########
python/pyspark/sql/tests/test_udf_transpile_unit.py:
##########
@@ -491,39 +506,345 @@ def gte_zero(x):
                     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.
+    def test_udf_transpile_chain_short_circuits(self):
+        # THE test for the chained lowering. Python's chain short-circuits: 
when
+        # ``a < b`` is false, ``b < c`` is never evaluated, so a NULL ``c`` 
cannot
+        # raise. `&` short-circuits at evaluation time too, so the fold 
matches.
+        # Nullable *columns* are required: a literal `None` has no column
+        # category, so `1 < 0 < None` just falls back.
+        #
+        # Both of `And`'s two laziness implementations are exercised: 
`doGenCode`
+        # under the default FALLBACK factory mode, and `eval` under NO_CODEGEN.
+        # `spark.sql.codegen.wholeStage=false` is NOT enough for the latter -- 
it
+        # disables whole-stage fusion, but expressions are still compiled 
through
+        # the codegen factory, so `doGenCode` runs either way. Survival 
through the
+        # OPTIMIZER is separate; see test_udf_transpile_boolop_short_circuits.
+        def chained(a, b, c):
+            return a < b < c
+
+        schema = "a long, b long, c long"
+        for factory_mode in ("FALLBACK", "NO_CODEGEN"):
+            conf = dict(_TRANSPILE_ON)
+            conf["spark.sql.codegen.factoryMode"] = factory_mode
+            with self.subTest(factory_mode=factory_mode), self.sql_conf(conf):
+                pudf = UserDefinedFunction(chained, BooleanType())
+                self.assertTrue(pudf.transpiled, "3-column chain should 
transpile")
+                df = self.spark.createDataFrame([(1, 0, None)], schema)
+                [row] = df.select(pudf("a", "b", "c")).collect()
+                self.assertEqual(
+                    False,
+                    row[0],
+                    "first link is false, so the NULL second link must not be 
evaluated",
+                )
+                # ... but when the first link IS true the second link is 
reached
+                # and its NULL guard fires, exactly as Python raises TypeError.
+                df = self.spark.createDataFrame([(0, 1, None)], schema)
+                with self.assertRaises(Exception) as ctx:
+                    df.select(pudf("a", "b", "c")).collect()
+                self.assertIn("cannot compare NULL", str(ctx.exception))
+
+    def test_udf_transpile_boolop_short_circuits(self):
+        # The `and` / `or` counterpart of the chain short-circuit: a NULL 
guard in
+        # the second operand must not fire when the first already decided the
+        # result.
+        #
+        # This is the regression test for SPARK-58627. Before `RaiseError`
+        # overrode `Expression.throwable`, `splitConjunctivePredicates` 
decomposed
+        # the `And` and `PushPredicateThroughJoin` pushed the guard to one 
side of
+        # the join, where it fired on rows Python short-circuits past and 
raised
+        # USER_RAISED_EXCEPTION instead of returning False. Keep it: the 
override
+        # is what makes the `&` / `|` fold survive the optimizer, so this is 
the
+        # test that notices if it regresses.
+        #
+        # The join case disables AQE and broadcast on purpose: otherwise 
whether
+        # the pushed-down guard fires races AQE eliminating the empty join, 
and the
+        # test would pass for the wrong reason.
+        def both_positive(a, c):
+            return a > 0 and c > 0
+
+        def either_positive(a, c):
+            return a > 0 or c > 0
+
+        with self.sql_conf(_TRANSPILE_ON):
+            and_udf = UserDefinedFunction(both_positive, BooleanType())
+            or_udf = UserDefinedFunction(either_positive, BooleanType())
+            self.assertTrue(and_udf.transpiled, "and over compares should 
transpile")
+            self.assertTrue(or_udf.transpiled, "or over compares should 
transpile")
+
+            # Plain select: the second operand is NULL but the first already
+            # decided the result, so Python's answer must come back unraised.
+            df = self.spark.createDataFrame([(-5, None)], "a long, c long")
+            self.assertEqual([False], [r[0] for r in df.select(and_udf("a", 
"c")).collect()])
+            df = self.spark.createDataFrame([(5, None)], "a long, c long")
+            self.assertEqual([True], [r[0] for r in df.select(or_udf("a", 
"c")).collect()])
+
+        # Same thing across a join, where the NULL operand lives entirely on 
the
+        # right side. Deterministic plan: no AQE, no broadcast, so the 
pushed-down
+        # guard either exists or does not, rather than racing the join.
+        deterministic = dict(_TRANSPILE_ON)
+        deterministic["spark.sql.adaptive.enabled"] = False
+        deterministic["spark.sql.autoBroadcastJoinThreshold"] = -1
+        with self.sql_conf(deterministic):
+            and_udf = UserDefinedFunction(both_positive, BooleanType())
+            left = self.spark.createDataFrame([(1, -5)], "k long, a long")
+            right = self.spark.createDataFrame([(1, None)], "k long, c long")
+            joined = left.join(right, "k")
+            self.assertEqual([False], [r[0] for r in 
joined.select(and_udf("a", "c")).collect()])
+
+            # Filtering must return Python's answer rather than raising. This 
is
+            # the assertion SPARK-58627 fixed; see above.
+            filtered = joined.filter(and_udf("a", "c"))
+            self.assertEqual([], filtered.select("k").collect())
+
+    def test_udf_transpile_chain_operator_mix_and_eq_nulls(self):
+        # Mixed operators in one chain, and `x == y == z` over every NULL 
pattern.
+        # Equality never raises in Python (`None == None` is True, `None == 0` 
is
+        # False), so every pattern has a defined answer to match exactly.
+        B = BooleanType()
+        chained_eq = lambda a, b, c: a == b == c  # noqa: E731
+
+        # (a, b, c, expected) -- every NULL pattern plus value cases.
+        eq_cases = [
+            (None, None, None, True),
+            (None, None, 1, False),
+            (None, 1, None, False),
+            (None, 1, 2, False),
+            (1, None, None, False),
+            (1, None, 2, False),
+            (1, 2, None, False),
+            (1, 1, None, False),
+            (1, 1, 1, True),
+            (1, 1, 2, False),
+            (1, 2, 3, False),
+        ]
+        self.assertEqual(
+            self._vals(chained_eq, B, "a long, b long, c long", [c[:3] for c 
in eq_cases]),
+            [c[3] for c in eq_cases],
+            "a == b == c over the NULL patterns",
+        )
+
+        # Operator mixes, on non-NULL rows so the ordering guards stay quiet.
+        lt_eq = lambda a, b, c: a < b == c  # noqa: E731
+        eq_lt = lambda a, b, c: a == b < c  # noqa: E731
+        gt_gte = lambda a, b, c: a > b >= c  # noqa: E731
+        lte_lte = lambda a, b, c: a <= b <= c  # noqa: E731
+        four_ops = lambda a, b, c, d, e: a < b < c < d < e  # noqa: E731
+        for label, func, rows, expected in [
+            ("a < b == c", lt_eq, [(1, 2, 2), (1, 2, 3), (2, 1, 1)], [True, 
False, False]),
+            ("a == b < c", eq_lt, [(1, 1, 2), (1, 1, 0), (1, 2, 3)], [True, 
False, False]),
+            ("a > b >= c", gt_gte, [(3, 2, 2), (3, 2, 5), (1, 2, 2)], [True, 
False, False]),
+            ("a <= b <= c", lte_lte, [(1, 1, 2), (1, 2, 2), (2, 1, 3)], [True, 
True, False]),
+        ]:
+            with self.subTest(func=label):
+                self.assertEqual(
+                    self._vals(func, B, "a long, b long, c long", rows), 
expected, label
+                )
+        self.assertEqual(
+            self._vals(
+                four_ops,
+                B,
+                "a long, b long, c long, d long, e long",
+                [(1, 2, 3, 4, 5), (1, 2, 3, 5, 4)],
+            ),
+            [True, False],
+            "a chain of four operators is well under the cap and must 
transpile",
+        )
+
+    def test_udf_transpile_comparison_fallbacks(self):
+        # Comparison forms the transpiler must REFUSE. Each case asserts both 
that
+        # nothing transpiled and that a query using the UDF still returns 
Python's
+        # answer. The second half matters because an option that survives is
+        # type-checked by CheckAnalysis as a child of TranspiledPythonUDF: a
+        # half-refused form would fail the whole query rather than fall back, 
so
+        # "did not transpile" alone is not enough to prove the fallback works.
         import warnings as _warnings
 
-        from pyspark.sql.types import StructField, StructType
+        # `in` / `not in` are refused wholesale (SPARK-56925), so these cases 
all

Review Comment:
   **Nit (P3):** This comment, and the one in 
`test_udf_transpile_lowering_caps` ("refused outright, deferred to 
SPARK-56925"), name SPARK-56925 as the follow-up for `in` / `not in`. In this 
tree SPARK-56925 is the already-landed bare `if x` truthiness work: 
`transpile.py` cites it for `_truthiness_col`, and 
`test_udf_transpile_bare_if_truthiness` says those bodies now transpile under 
it. Please point both comments at the ticket that tracks membership support 
(the PR description says it is deferred to its own ticket), or drop the 
reference.
   
   **Verification:**
   - **Inspection:** Both comments reference the membership follow-up rather 
than the truthiness ticket.



##########
python/pyspark/sql/transpile.py:
##########
@@ -673,60 +890,49 @@ def _convert_chunk(self, params: List[str], body: ast.AST 
| None) -> Column:
                     orelse[0] if orelse else None,
                 )
             case ast.Compare(left, ops, comps):
-                if len(ops) != 1 or len(comps) != 1:
+                # `in` / `not in` are container membership, not a chainable 
value
+                # comparison, and need their own NULL analysis: Python's `in` 
is
+                # equality-based and never raises on a `None` probe. Not 
lowered
+                # yet, so refuse them and fall back.
+                if any(isinstance(o, (ast.In, ast.NotIn)) for o in ops):
                     raise UnsupportedOperationException(
-                        "chained comparisons (e.g. `a < b < c`) are not 
supported by the transpiler"
+                        "`in` / `not in` is not supported by the transpiler; 
the "
+                        "UDF falls back to interpreted Python"
                     )
-                comp = comps[0]
-                match ops[0]:
-                    case ast.Is() | ast.IsNot():
-                        # Only lower `x is None` / `None is x` (and their
-                        # `is not` variants) to isNull/isNotNull. For any
-                        # other comparator (e.g. `x is 0`, `x is y`) Python
-                        # performs an object-identity check that has no SQL
-                        # equivalent, so we must fall back to interpreted
-                        # Python rather than silently emitting a null check.
-                        is_none_left = isinstance(left, ast.Constant) and 
left.value is None
-                        is_none_right = isinstance(comp, ast.Constant) and 
comp.value is None
-                        if not (is_none_left or is_none_right):
-                            raise UnsupportedOperationException(
-                                "`is`/`is not` is only supported when one "
-                                "operand is the literal None; other identity "
-                                "checks (e.g. `x is 0`, `x is y`) cannot be "
-                                "lowered to SQL and the UDF falls back to "
-                                "interpreted Python"
-                            )
-                        subject_node = comp if is_none_left else left
-                        subject_col = self._convert_chunk(params, subject_node)
-                        if isinstance(ops[0], ast.Is):
-                            return subject_col.isNull()
-                        else:
-                            return subject_col.isNotNull()
-                    case ast.Eq():
-                        return self._lower_eq(params, left, comp, equal=True)
-                    case ast.NotEq():
-                        return self._lower_eq(params, left, comp, equal=False)
-                    case ast.Lt():
-                        return self._lower_value_compare(
-                            params, left, comp, lambda l, r: l < r, "<"
-                        )
-                    case ast.LtE():
-                        return self._lower_value_compare(
-                            params, left, comp, lambda l, r: l <= r, "<="
-                        )
-                    case ast.Gt():
-                        return self._lower_value_compare(
-                            params, left, comp, lambda l, r: l > r, ">"
-                        )
-                    case ast.GtE():
-                        return self._lower_value_compare(
-                            params, left, comp, lambda l, r: l >= r, ">="
-                        )
-                    case _:
-                        raise UnsupportedOperationException(
-                            f"comparison operator {type(ops[0]).__name__} "
-                            "is not supported by the transpiler"
-                        )
+                if len(ops) > _MAX_COMPARISON_OPS:
+                    raise UnsupportedOperationException(
+                        f"chained comparison has {len(ops)} operators; the "
+                        f"transpiler lowers at most {_MAX_COMPARISON_OPS}, so 
the "
+                        "UDF falls back to interpreted Python"
+                    )
+                # Python evaluates `a OP1 b OP2 c` as `(a OP1 b) and (b OP2 c)`
+                # with `b` evaluated once, so the fold below re-lowers each
+                # interior operand once per adjacent pair. Safe for the operand
+                # grammar we accept, all of it deterministic and side-effect 
free.
+                # It does mean the body READS a parameter once per adjacent 
pair,
+                # which SPARK-58626 is what makes safe: `_udf_param_N` is a
+                # reference, and `ConvertToCatalyst` gives an argument read 
more
+                # than once one column per row -- or declines to lower the call
+                # where no column fits -- so a non-deterministic argument is 
drawn
+                # once and every read agrees. While each read still carried 
its own
+                # copy of the argument, `50 < x < 60` over `rand(7) * 100` was 
~30%
+                # true where Python is ~10%.
+                #
+                # Folding into nested CASE WHENs instead -- atomic to predicate
+                # splitting -- was rejected: it costs pushdown for every
+                # `and` / `or` UDF, including those over non-nullable columns 
that
+                # need no guard at all.
+                #
+                # See the module docstring for the short-circuit and NULL-guard
+                # semantics this fold relies on.
+                nodes = [left, *comps]
+                return functools.reduce(

Review Comment:
   **Non-blocking (P2):** Before this PR every chained comparison fell back to 
interpreted Python, so it always returned Python's answer. This fold turns a 
chain into a splittable `And` of NULL-guarded links. That is only correct while 
every later link stays behind the links that gate it, and the optimizer passes 
that run after `ConvertToCatalyst` do not keep that order:
   
   - The Aggregate arm of `PushDownPredicates` splits conjuncts and pushes the 
deterministic ones that reference only grouping keys, explicitly including 
throwable ones (Optimizer.scala:2672-2696). Take `udf(lambda sa, b, c: sa < b < 
c)` applied as `groupBy("b", "c").agg(sum("a").alias("sa")).filter(...)`: 
`link(b < c)` runs below the Aggregate. A group with `b = 1, c = NULL, sa = 5` 
then raises `cannot compare NULL` where Python returns False.
   - `BooleanSimplification` common-factor elimination and subexpression 
elimination hoist a shared link, so `(a < b < c) or (d < b < c)` on `(5, 1, 
None, 7)` raises. Absorption drops a guard instead: `(a < b < c) or (b < c)` on 
`(None, 0, 1)` returns True where Python raises.
   - File-source partition filtering 
(`DataSourceUtils.getPartitionFiltersAndDataFilters`) has no `throwable` check, 
so a later link over a partition column runs during partition pruning.
   
   The module docstring records these as OPEN for the `and` spelling. That 
spelling already transpiled before this PR, but chains did not, so this PR is 
what exposes chains to them. The comment above rejects nested CASE WHENs 
because they "cost pushdown for every `and` / `or` UDF". That cost does not 
apply to chains, which had no lowering to lose. The issue only affects users 
who enable the experimental flag with ANSI on. The evaluation-time 
short-circuit itself is correct.
   
   **Recommended change:** Fold chain links with a sequential CASE WHEN (`CASE 
WHEN NOT link1 THEN false ... WHEN NOT link(n-1) THEN false ELSE linkN END`) 
instead of `&`, update the module docstring and the chain-arm comment to 
describe that fold, and add transpile unit tests for the optimizer shapes that 
break the `&` fold.
   
   **Why this works:** Each later link sits in a CASE WHEN branch that runs 
only after every earlier link is true. Every link is total (never NULL), so 
`NOT link` is never NULL and the sequence mirrors Python's short-circuit 
exactly. splitConjunctivePredicates matches only And, BooleanSimplification's 
patterns match And/Or, and subexpression elimination recurses only into a CASE 
WHEN's first condition, so no rule can move a later guard ahead of the links 
that gate it. When pushdown moves the whole predicate, it moves as one unit 
evaluated on the same key values Python sees.
   
   **Scope:** Replace the chain's `&` fold with a sequential CASE WHEN and pin 
the optimizer shapes that broke it.
   
   **Compatibility:** Chained comparisons still transpile to native expressions 
and keep Python's evaluation-time short-circuit semantics.
   
   **Risks:** A chain predicate no longer splits for partial pushdown and moves 
only as a whole; this is no regression versus the merge target, where chains 
did not transpile.
   
   **Constraints:** Each link from _lower_compare_pair must stay total (never 
NULL), since `NOT link` must be non-NULL for the CASE WHEN to mirror Python. 
The comparison budget must keep charging every lowered link and any structure 
the new fold adds.
   
   **Success:** A chain UDF used as a filter over groupBy/agg whose later link 
references only grouping keys returns Python's answer for groups with a NULL 
key instead of raising. `(a < b < c) or (d < b < c)` on (5, 1, None, 7) returns 
False with transpilation on, matching Python. `(a < b < c) or (b < c)` on 
(None, 0, 1) raises like Python instead of returning True. Chains still lower 
with no EvalPython node and keep their existing evaluation-time short-circuit, 
NULL-pattern, cap and NaN behavior.
   
   **Verification:**
   - **Regression:** A chain UDF filtered over an Aggregate on its grouping 
keys, with a NULL grouping value whose earlier link is false, returns Python's 
answer and runs fully lowered.
   - **Regression:** Disjunctions of chains that share a link return Python's 
answer for the common-factor and absorption repros recorded in the module 
docstring.
   - **Compatibility:** Existing chain coverage (short-circuit under both 
codegen factory modes, every `==` NULL pattern, operator mixes, cap boundaries, 
EvalPython elision, NaN pins and the hypothesis chain templates) keeps passing.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to