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


##########
python/pyspark/sql/transpile.py:
##########
@@ -239,8 +329,129 @@ class CatalystTranspiler(AbstractTranspiler):
     variety = "catalyst"
 
     def __init__(self) -> None:
+        # Category inference depends on the per-variant assumptions set in
+        # ``_transpile_from_ast``; both are reset there per variant.
         self._param_categories: dict[int, str] = {}
         self._category_cache: dict[int, str] = {}
+        # Instance attrs (not class defaults): ``_raise_on_null`` does ``|=`` 
on
+        # ``_pending_null_guards``, which on a class-default set would mutate 
the
+        # class dict and leak state across UDFs. Both reset per variant in
+        # ``_transpile_from_ast``.
+        self._non_null: frozenset = frozenset()
+        self._pending_null_guards: frozenset = frozenset()
+        #: Raising NULL checks the last lowered variant needed; empty when none
+        #: survived. ``_transpile_func`` unions this across kept variants and 
warns
+        #: once per UDF. Assigned, not accumulated, so nothing leaks between 
UDFs.
+        self.null_guards: frozenset = frozenset()
+
+    @contextlib.contextmanager
+    def _narrowed(self, proven_non_null: frozenset) -> Iterator[None]:
+        """Lower the enclosed nodes with ``proven_non_null`` added to what is 
known.
+
+        Save/restore suffices: lowering walks straight down, so a fact's scope 
is
+        exactly the subtree we recurse into.
+        """
+        previous = self._non_null
+        self._non_null = previous | proven_non_null
+        try:
+            yield
+        finally:
+            self._non_null = previous
+
+    def _is_never_null(self, params: List[str], node: ast.AST) -> bool:
+        """Whether ``node`` provably cannot evaluate to NULL here.
+
+        Answers "do we still need a NULL check?" Every arm is an explicit 
proof and
+        the catch-all is ``False``: a missed proof costs a redundant check, a 
wrong
+        one drops a check Python needs. Expressions only.
+        """
+        match node:
+            case ast.Constant(value=value):
+                return value is not None
+            case ast.Name(id=name):
+                return name in params and name in self._non_null
+            case ast.Compare(ops=[op]):
+                # is/is not lower to isNull/isNotNull; ==/!= cover NULL with 
boolean
+                # literals; ordering raises on NULL rather than returning it.
+                return isinstance(
+                    op, (ast.Is, ast.IsNot, ast.Eq, ast.NotEq, ast.Lt, 
ast.LtE, ast.Gt, ast.GtE)
+                )
+            case ast.UnaryOp(op=ast.Not()):
+                # Either inverts a non-NULL operand or coalesces against a 
literal.
+                return True
+            case ast.UnaryOp(operand=operand):
+                return self._is_never_null(params, operand)
+            case ast.BoolOp(values=values):
+                return all(self._is_never_null(params, v) for v in values)
+            case ast.BinOp(left=left, right=right):
+                # Catalyst arithmetic and concat propagate NULL from either 
side.
+                return self._is_never_null(params, left) and 
self._is_never_null(params, right)
+            case ast.Call(func=ast.Name(id="len"), args=[_], keywords=[]):
+                # The lowering raises on NULL or returns ``length``; neither 
is NULL,
+                # so a parent (``len(x) > 0``) must not add a second check. A 
non-string
+                # argument never produces a column -- that variant is dropped 
first.
+                return True
+            case ast.IfExp(test=test, body=body, orelse=orelse):
+                # Each branch is lowered under the facts its test proves, so 
ask
+                # about the branches the same way.
+                when_true, when_false = _null_facts(test)
+                with self._narrowed(when_true):
+                    body_ok = self._is_never_null(params, body)
+                with self._narrowed(when_false):
+                    return body_ok and self._is_never_null(params, orelse)
+            case _:
+                return False
+
+    @staticmethod
+    def _any_null(columns: List[Column]) -> Column:
+        """``columns[0] IS NULL OR ...``; raises on an empty list."""
+        return functools.reduce(operator.or_, (column.isNull() for column in 
columns))
+
+    def _raise_on_null(
+        self,
+        params: List[str],
+        operands: List[Tuple[ast.AST, Column]],
+        label: str,
+        message: str,
+        otherwise: Column,
+    ) -> Column:
+        """``otherwise``, guarded so that a NULL operand raises ``message`` 
instead.
+
+        The only place that emits a ``raise_error``; future lowerings needing 
one
+        (SPARK-55210's arithmetic/unary/concat guards) must come through here 
or they
+        skip the narrowing and the warning. Operands proven non-NULL 
contribute no
+        check, and with none left the guard is dropped -- ``RaiseError`` is
+        ``throwable`` (SPARK-58627), so a plan holding one cannot be pushed 
through a
+        join. ``label`` names the construct plus the parameters the emitted
+        ``isNull`` calls actually test -- ``a is not None and a + b > 0`` names
+        ``b`` alone.
+        """
+        checked = [(node, c) for node, c in operands if not 
self._is_never_null(params, node)]
+        if not checked:
+            return otherwise
+        named = sorted({name for node, _ in checked for name in 
self._checked_names(params, node)})
+        self._pending_null_guards |= {f"{label} on {', '.join(named)}" if 
named else label}
+        guard = self._any_null([c for _, c in checked])
+        return when(guard, raise_error(lit(message))).otherwise(otherwise)
+
+    def _checked_names(self, params: List[str], node: ast.AST) -> set:
+        """The parameters in ``node`` whose possible NULL-ness a guard checks.
+
+        The walk prunes whatever ``_is_never_null`` proves: a parameter an
+        enclosing guard already established (``a is not None and a + b > 0``
+        warns about ``b``, not ``a``) and a sub-expression carrying its own
+        check (``len(...)`` never returns NULL and has its own label), so the
+        label points at the guard the user is still missing.
+        """
+        if self._is_never_null(params, node):
+            return set()
+        if isinstance(node, ast.Name):
+            return {node.id} if node.id in params else set()
+        return {
+            name
+            for child in ast.iter_child_nodes(node)

Review Comment:
   **Nit (P3):** The new pruning handles guards that enclose the operand, but 
not a guard inside it. When `_checked_names` reaches a ternary, it recurses 
into the children under the caller's facts only, so it skips the per-arm 
narrowing that `_is_never_null` applies through `_null_facts(test)`.
   
   Take `lambda x, y: len(x if x is not None else y)` on string columns under 
LongType. The check correctly survives, because a NULL `y` can still reach 
`len`. But the label comes out as "`len` on x, y", so the warning asks the user 
to guard `x`, which the ternary already does. A parameter that appears only in 
the test is named as well, for example `a` in `len(b if a else c)`. Calling 
`CatalystTranspiler()._checked_names(['x', 'y'], <x if x is not None else y>)` 
on this head returns `['x', 'y']`.
   
   Handling `ast.IfExp` the way `_is_never_null` does would fix it: skip the 
test, walk `body` under `self._narrowed(when_true)`, and walk `orelse` under 
`self._narrowed(when_false)`. A ternary-operand case in 
`test_udf_transpile_warns_only_about_the_unproven_parameter` would pin it.
   
   **Verification:**
   - **Regression:** A UDF whose `len` or comparison operand is a 
coalesce-style ternary over two parameters warns about only the else-arm 
parameter and does not name the parameter the ternary already guards.
   - **Behavior:** The existing warning-label expectations for mixed, 
unguarded, and borrowed-`len` operands still hold.
   - **Inspection:** The lowered option for the ternary case still carries 
exactly one raising check, confirming that only the label changed.



##########
python/pyspark/sql/transpile.py:
##########
@@ -213,6 +229,80 @@ def _is_definitely_boolean(node: ast.AST) -> bool:
             return False
 
 
+def _none_check_operand(left: ast.AST, comparator: ast.AST) -> 
Optional[ast.AST]:
+    """The non-``None`` side of an ``x is None`` / ``None is x`` pair, else 
``None``.
+
+    Shared by ``_convert_chunk``'s lowering and ``_null_facts``, which must 
agree on
+    exactly which shapes count: a comparison against anything but the literal 
``None``
+    is an object-identity test with no SQL equivalent.
+    """
+    is_none_left = isinstance(left, ast.Constant) and left.value is None
+    is_none_right = isinstance(comparator, ast.Constant) and comparator.value 
is None
+    if not (is_none_left or is_none_right):
+        return None
+    return comparator if is_none_left else left
+
+
+def _none_check_subject(node: ast.AST) -> Optional[str]:
+    """The parameter name in an ``x is None`` / ``x == None`` test, or 
``None``.
+
+    Either operand order, and the ``==``/``!=`` spellings too. Only a bare 
name is
+    reported -- that is all the nullability environment can key on.
+    """
+    if not isinstance(node, ast.Compare) or len(node.ops) != 1 or 
len(node.comparators) != 1:
+        return None
+    if not isinstance(node.ops[0], (ast.Is, ast.IsNot, ast.Eq, ast.NotEq)):
+        return None
+    subject = _none_check_operand(node.left, node.comparators[0])
+    return subject.id if isinstance(subject, ast.Name) else None
+
+
+def _null_facts(node: ast.AST) -> Tuple[frozenset, frozenset]:
+    """The parameters ``node`` proves non-NULL, as ``(when_true, when_false)``.
+
+    * ``x is not None`` / ``x != None`` prove ``x`` when true; ``is None`` / 
``==``
+      when false.
+    * ``not A`` swaps ``A``'s outcomes.
+    * ``A and B`` true means both held, so their true-facts combine; false 
says only
+      that one failed, so it proves nothing. ``A or B`` is the mirror image.
+    * Anything else proves nothing, which is the safe direction -- a fact we 
miss
+      leaves a check in place, it never drops one we needed.
+
+    Callers use "not FALSE => true-facts, not TRUE => false-facts" rather than 
just
+    "TRUE => true-facts", because ``And``/``Or`` and ``CASE WHEN`` treat NULL 
as
+    not-true, not false -- and a node a fact came FROM cannot itself be NULL.
+
+    TODO (SPARK-55218): with multi-statement bodies, an ``if x is None: return 
...``
+    should add its false-facts to the statements after it.
+    """
+    empty: frozenset = frozenset()
+    # ``_none_check_subject`` takes ``==``/``!=`` too, or `if x != None:` 
would keep
+    # a check and then be told to add the guard it already had.
+    if isinstance(node, ast.Compare) and (subject := 
_none_check_subject(node)) is not None:
+        proven = frozenset({subject})
+        # `x is None` / `x == None` prove nothing when true and non-NULL-ness
+        # when false; `is not` / `!=` are the other way round.
+        if isinstance(node.ops[0], (ast.Is, ast.Eq)):
+            return empty, proven
+        return proven, empty
+    match node:
+        case ast.UnaryOp(op=ast.Not(), operand=operand):
+            when_true, when_false = _null_facts(operand)
+            return when_false, when_true
+        case ast.BoolOp(op=ast.And(), values=values):
+            proven = empty
+            for value in values:
+                proven |= _null_facts(value)[0]
+            return proven, empty
+        case ast.BoolOp(op=ast.Or(), values=values):
+            proven = empty
+            for value in values:
+                proven |= _null_facts(value)[1]
+            return empty, proven
+        case _:

Review Comment:
   **Nit (P3):** A bare truthiness test proves its parameter non-NULL in the 
branch where it held, but this catch-all gives it no facts. `_convert_if_like` 
lowers `if s:` through `_truthiness_col`, which coalesces NULL to False, so the 
body only ever runs for a non-NULL `s`.
   
   Still, in `def f(s): if s: return len(s) else: return 0` (a string column 
under LongType, using the existing bare-`if` truthiness lowering), `len(s)` 
keeps an unreachable `raise_error`. That is the same throwable guard this PR 
sets out to remove. The new warning then says the UDF "still checks for NULL in 
`len` on s" and suggests `if x is not None:`, although `if s:` already excludes 
None. Acting on that advice is awkward too, because `s is not None and s` is 
refused as a non-boolean `and` operand.
   
   A `case ast.Name(id=name): return frozenset({name}), empty` arm looks sound 
for every consumer:
   
   - An if/ternary truthiness test is coalesced to False.
   - `and`/`or` refuse bare-name operands before narrowing.
   - `not` on a bool-category name swaps the fact to the false side, where it 
holds because `coalesce(~b, True)` is FALSE only when `b` is TRUE.
   
   If you'd rather not widen the facts, narrowing the module docstring and the 
warning text to `is None`-style tests would at least keep them accurate.
   
   **Verification:**
   - **Regression:** A string UDF guarded by `if s:` around `len(s)` has no 
raise_error in its optimized plan, returns Python's values for empty, 
non-empty, and None inputs, and does not emit the surviving-check warning.
   - **Behavior:** When the check sits in the arm the truthiness test does not 
prove, the check survives and a None row raises like Python.
   - **Behavior:** The fact table reports truthiness facts for a bare name and 
its negation, and all existing fact-table expectations still hold.



##########
python/pyspark/sql/tests/test_udf_transpile_unit.py:
##########
@@ -2634,6 +2663,529 @@ def bytes_ident(x: bytes):
             self.assertFalse(UserDefinedFunction(bool_add, 
LongType()).transpiled)
             self.assertTrue(UserDefinedFunction(bytes_ident, 
BinaryType()).transpiled)
 
+    def _transpiled_plan(self, func, return_type, schema):
+        """The optimized plan of ``func`` applied to every column of 
``schema``.
+
+        The lowered NULL checks are what these tests are about, so they have 
to be
+        read off the plan: the values a guarded UDF returns are the same 
whether or
+        not the plan carries a check that can never fire.
+
+        Asserting the option was actually APPLIED is what keeps every
+        ``assertNotIn(..., plan)`` built on this from passing for the wrong 
reason
+        -- an interpreted plan contains none of the strings we look for, so a 
UDF
+        that quietly fell back would satisfy them all (see ``_vals``, which 
guards
+        the same way for the same reason).
+        """
+        with self.sql_conf(_TRANSPILE_ON):
+            u = self._transpiled_udf(func, return_type)
+            df = self.spark.createDataFrame([], schema)
+            projected = df.select(u(*df.columns))
+            self.assertEqual(0, self._eval_python_count(projected), str(func))
+            return projected._jdf.queryExecution().optimizedPlan().toString()
+
+    def test_udf_transpile_drops_null_checks_a_guard_already_made(self):
+        # SPARK-58628. Python raises TypeError on `None > 0` where Spark 
returns
+        # NULL, so an ordering comparison carries a check that raises. When 
the UDF
+        # has already tested the parameter the check can never fire, and 
leaving it
+        # in is not free: RaiseError is throwable (SPARK-58627), which stops 
the
+        # optimizer from moving a predicate containing it. Every shape below 
proves
+        # non-NULL-ness, so none of them should lower to a raise -- and all of 
them
+        # must still agree with Python, including on the None row.
+        def if_guard(x):
+            if x is not None:
+                return x > 0
+            else:
+                return None
+
+        def else_of_is_none(x):
+            if x is None:
+                return None
+            else:
+                return x > 0
+
+        def not_is_none(x):
+            if not (x is None):  # noqa: E714  the `not` form is the point
+                return x > 0
+            else:
+                return None
+
+        def reversed_test(x):
+            if None is not x:
+                return x > 0
+            else:
+                return None
+
+        def ne_none_test(x):
+            # The `!= None` spelling of the same guard. PEP 8 prefers `is not 
None`,
+            # but both are common and both must narrow, or the equality form 
keeps a
+            # raising check and gets told to add the guard it already has.
+            if x != None:  # noqa: E711
+                return x > 0
+            else:
+                return None
+
+        def eq_none_test(x):
+            if x == None:  # noqa: E711
+                return None
+            else:
+                return x > 0
+
+        ternary = lambda x: (x > 0) if x is not None else None  # noqa: E731
+        short_circuit_and = lambda x: x is not None and x > 0  # noqa: E731
+        short_circuit_or = lambda x: x is None or x > 0  # noqa: E731
+        # `<=` / `>=` share `_lower_value_compare` but are otherwise only 
exercised
+        # where a guard IS emitted, so cover the narrowed side for them too.
+        lte_guarded = lambda x: (x <= 0) if x is not None else None  # noqa: 
E731
+        gte_guarded = lambda x: (x >= 0) if x is not None else None  # noqa: 
E731
+        lt_guarded = lambda x: (x < 0) if x is not None else None  # noqa: E731
+
+        rows = [(-1,), (0,), (5,), (None,)]
+        cases = [
+            (if_guard, [False, False, True, None]),
+            (else_of_is_none, [False, False, True, None]),
+            (not_is_none, [False, False, True, None]),
+            (reversed_test, [False, False, True, None]),
+            (ne_none_test, [False, False, True, None]),
+            (eq_none_test, [False, False, True, None]),
+            (ternary, [False, False, True, None]),
+            (lte_guarded, [True, True, False, None]),
+            (gte_guarded, [False, True, True, None]),
+            (lt_guarded, [True, False, False, None]),
+            # `and` / `or` return the guard's own result for None, as Python 
does.
+            (short_circuit_and, [False, False, True, False]),
+            (short_circuit_or, [False, False, True, True]),
+        ]
+        for func, expected in cases:
+            with self.subTest(func=getattr(func, "__name__", "lambda")):
+                self.assertEqual(
+                    expected,
+                    [func(v) for (v,) in rows],
+                    "the expectation should be what Python actually does",
+                )
+                self.assertEqual(expected, self._vals(func, BooleanType(), "a 
long", rows))
+                plan = self._transpiled_plan(func, BooleanType(), "a long")
+                self.assertNotIn("raise_error", plan)
+
+    def test_udf_transpile_keeps_the_null_check_it_needs(self):
+        # The other side of the previous test: nothing here proves the 
parameter
+        # non-NULL, so the check has to stay and the UDF has to raise like 
Python.
+        # `x is None and ...` is the interesting one -- an `and` being false 
says
+        # only that SOME operand was false, so it narrows nothing.
+        unguarded = lambda x: x > 0  # noqa: E731
+        wrong_way_and = lambda x: x is None and x > 0  # noqa: E731
+        two_columns = lambda a, b: a > b  # noqa: E731
+
+        for func, schema in ((unguarded, "a long"), (two_columns, "a long, b 
long")):
+            with self.subTest(func="lambda", schema=schema):
+                plan = self._transpiled_plan(func, BooleanType(), schema)
+                self.assertIn("raise_error", plan)
+        boolean = BooleanType()
+        self._raises(unguarded, "a long", [(None,)], "cannot compare null", 
boolean)
+        self._raises(two_columns, "a long, b long", [(1, None)], "cannot 
compare null", boolean)
+        # Each ordering operator names itself in the guard's message and in the
+        # warning's label; only `>` was covered before.
+        lt_zero = lambda x: x < 0  # noqa: E731
+        lte_zero = lambda x: x <= 0  # noqa: E731
+        gte_zero = lambda x: x >= 0  # noqa: E731
+        for func, op_text in ((lt_zero, "`<`"), (lte_zero, "`<=`"), (gte_zero, 
"`>=`")):
+            with self.subTest(op=op_text):
+                with self.sql_conf(_TRANSPILE_ON):
+                    _, warned = self._udf_and_warnings(func, boolean)
+                self.assertIn(f"comparison {op_text} on x", warned)
+                self._raises(func, "a long", [(None,)], f"operator {op_text}", 
boolean)
+        # `x is None and x > 0` is False for every non-None x without 
evaluating the
+        # comparison, and raises for None -- exactly like Python.
+        with self.sql_conf(_TRANSPILE_ON):
+            u = self._transpiled_udf(wrong_way_and, BooleanType())
+            self.assertIn("raise_error", str(u.transpiled[0]))
+            df = self.spark.createDataFrame([(1,)], "a long")
+            self.assertEqual([False], [r[0] for r in 
df.select(u("a")).collect()])
+        self._raises(wrong_way_and, "a long", [(None,)], "cannot compare 
null", boolean)
+
+    def test_udf_transpile_narrows_only_the_proven_parameter(self):
+        # With two parameters the narrowing has to be per-name: guarding `a` 
must
+        # not license dropping the check on `b`. Both guarded, no check at all.
+        one_guarded = lambda a, b: a is not None and a > b  # noqa: E731
+        both_guarded = lambda a, b: a is not None and b is not None and a > b  
# noqa: E731
+
+        # The same two-parameter guards written as an `if` TEST rather than as 
the
+        # expression itself. This is the only shape that asks `_null_facts` 
about a
+        # composite `and` / `or` node instead of about a single comparison, 
and it
+        # is the idiomatic way to guard two columns.
+        def composite_and(a, b):
+            if a is not None and b is not None:
+                return a > b
+            else:
+                return None
+
+        def composite_or(a, b):
+            if a is None or b is None:
+                return None
+            else:
+                return a > b
+
+        def composite_not_or(a, b):
+            if not (a is None or b is None):
+                return a > b
+            else:
+                return None
+
+        schema = "a long, b long"
+        partial = self._transpiled_plan(one_guarded, BooleanType(), schema)
+        self.assertIn("raise_error", partial)
+        # Only `b` is still checked. Note `isnull(a` does not match the 
guard's own
+        # `isnotnull(a...)`, which is the test rather than a check on the 
operand.
+        self.assertIn("isnull(b", partial)
+        self.assertNotIn("isnull(a", partial)
+        rows = [(5, 1), (1, 5), (1, None), (None, 1)]
+        for func in (both_guarded, composite_and, composite_or, 
composite_not_or):
+            with self.subTest(func=getattr(func, "__name__", "lambda")):
+                self.assertNotIn("raise_error", self._transpiled_plan(func, 
BooleanType(), schema))
+                self.assertEqual(
+                    [func(a, b) for a, b in rows],
+                    self._vals(func, BooleanType(), schema, rows),
+                )
+        # Partial narrowing was only plan-asserted, but it creates two runtime
+        # invariants: the surviving check must still fire for a NULL `b`, and 
the
+        # guard on `a` must still short-circuit a NULL `a` to False without 
reaching
+        # the comparison. This is the one live consumer of the claim that 
Catalyst's
+        # `And` short-circuits on false the way Python does.
+        self._raises(one_guarded, schema, [(1, None)], "cannot compare null", 
BooleanType())
+        self.assertEqual([False], self._vals(one_guarded, BooleanType(), 
schema, [(None, None)]))
+        self.assertEqual([False], self._vals(one_guarded, BooleanType(), 
schema, [(None, 1)]))
+
+        # The UNSOUND direction, end to end: a true `or` says only that ONE 
operand
+        # held, so it narrows nothing and the check must stay. Covered here as 
well as
+        # in `test_null_facts_narrow_by_outcome`, because a plausible 
"symmetry" edit
+        # to `_null_facts` would delete a needed raise, and the fact-table 
unit test
+        # sits one remove from the plan that would go wrong.
+        def or_test(a, b):
+            if a is not None or b is not None:
+                return a > 0
+            else:
+                return None
+
+        def not_and_test(a, b):
+            if not (a is None and b is None):
+                return a > 0
+            else:
+                return None
+
+        for func in (or_test, not_and_test):
+            with self.subTest(func=func.__name__):
+                self.assertIn("raise_error", self._transpiled_plan(func, 
BooleanType(), schema))
+                with self.assertRaises(Exception):
+                    func(None, 1)
+                self._raises(func, schema, [(None, 1)], "cannot compare null", 
BooleanType())
+
+    def test_udf_transpile_narrowing_never_drops_a_needed_check(self):
+        # The unsafe direction of the narrowing paths: dropping a check that is
+        # still needed turns Python's TypeError into a silent NULL or False.
+        # Each case keeps its raise on the unproven side, next to the guarded
+        # counterpart that earns the elision.
+        boolean = BooleanType()
+        # A comparison BEFORE its guard: `and` narrows an operand only by the
+        # operands before it, so `x > 0` here knows nothing and must still 
raise
+        # for None, as Python does. Narrowing by LATER operands would drop this
+        # check and quietly return False for None.
+        late_guard = lambda x: x > 0 and x is not None  # noqa: E731
+        self.assertIn("raise_error", self._transpiled_plan(late_guard, 
boolean, "a long"))
+        self._raises(late_guard, "a long", [(None,)], "cannot compare null", 
boolean)
+        self.assertEqual([True, False], self._vals(late_guard, boolean, "a 
long", [(1,), (-1,)]))
+
+        # An arithmetic operand is NULL when either side is, so `x + 1` is
+        # checked until `x` is proven: unguarded it raises like Python, guarded
+        # it loses the check entirely. An `or` in `_is_never_null`'s BinOp arm
+        # would call `x + 1` safe on the strength of the literal alone and
+        # return NULL for a None row.
+        arith = lambda x: x + 1 > 0  # noqa: E731
+        arith_guarded = lambda x: x is not None and x + 1 > 0  # noqa: E731
+        self.assertIn("raise_error", self._transpiled_plan(arith, boolean, "a 
long"))
+        self._raises(arith, "a long", [(None,)], "cannot compare null", 
boolean)
+        self.assertNotIn("raise_error", self._transpiled_plan(arith_guarded, 
boolean, "a long"))
+        rows = [(1,), (-5,), (None,)]
+        self.assertEqual([True, False, False], self._vals(arith_guarded, 
boolean, "a long", rows))
+
+        # A ternary narrows each arm by its OWN outcome: the body knows `x`,
+        # the else does not, so exactly one check survives -- the else's
+        # `y > 0`. Swapping the branch facts checks `x` in the body too (two
+        # raise_error), and the (None, None) row would stop raising.

Review Comment:
   **Nit (P3):** The second half of this claim doesn't hold. If the branch 
facts were swapped, the body `x > 0` would be lowered under the empty 
false-facts and gain a check on `x`. The else arm `y > 0` would be lowered 
under {x}, which says nothing about `y`, so its check stays. A (None, None) row 
takes the else arm and still raises.
   
   So under a swap, the `_raises(branches, ..., [(None, None)], ...)` call 
below and the warning-label assertion both still pass. Only `assertEqual(1, 
plan.count("raise_error"), plan)` catches it. Wording like "Swapping the branch 
facts adds a second raise_error (caught by the count below); the (None, None) 
row still raises through the else arm's own check on `y`" would match what the 
test actually pins.
   
   **Verification:**
   - **Inspection:** The comment's account of which assertion detects a 
swapped-facts regression matches the lowering: the else arm keeps its check on 
`y`, and only the raise_error count changes.



-- 
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