diff --git a/src/dpmcore/dpm_xl/ast/constructor.py b/src/dpmcore/dpm_xl/ast/constructor.py index b96bd61..394f346 100644 --- a/src/dpmcore/dpm_xl/ast/constructor.py +++ b/src/dpmcore/dpm_xl/ast/constructor.py @@ -119,7 +119,11 @@ def visitExprWithSelection( ) -> WithExpression: ctx_list = list(ctx.getChildren()) partial_selection: VarID = self._visit(ctx_list[1]) - expression: AST = self._visit(ctx_list[3]) + # Body expression is always the last child. When the optional + # [WHERE expression] block is present the token count grows by 4, + # so ctx_list[3] would land on the WHERE terminal rather than the + # body. ctx_list[-1] is correct in both cases. + expression: AST = self._visit(ctx_list[-1]) return WithExpression( partial_selection=partial_selection, expression=expression ) diff --git a/src/dpmcore/dpm_xl/operators/base.py b/src/dpmcore/dpm_xl/operators/base.py index ed9d0a8..7c25a1c 100644 --- a/src/dpmcore/dpm_xl/operators/base.py +++ b/src/dpmcore/dpm_xl/operators/base.py @@ -297,9 +297,12 @@ def validate_types( interval_allowed = getattr(cls, "interval_allowed", False) if isinstance(left_type, Mixed) or isinstance(right_type, Mixed): if result_dataframe is None: - raise Exception( - "Mixed type promotion requires a result dataframe" - ) + # Semantic-only mode: no row data available for per-row type + # resolution. Return the operator's fixed return type when + # known; otherwise Mixed (the column stays unresolved). + return ( + return_type if return_type is not None else Mixed() + ), None final_type, result_dataframe = ( binary_implicit_type_promotion_with_mixed_types( result_dataframe=result_dataframe, diff --git a/src/dpmcore/dpm_xl/types/promotion.py b/src/dpmcore/dpm_xl/types/promotion.py index 2a99ee8..720ce92 100644 --- a/src/dpmcore/dpm_xl/types/promotion.py +++ b/src/dpmcore/dpm_xl/types/promotion.py @@ -221,30 +221,45 @@ def binary_implicit_type_promotion_with_mixed_types( ) elif isinstance(left_type, Mixed): - # Series.apply stubs don't know ScalarType is a valid cell value - result_dataframe["data_type"] = result_dataframe["data_type"].apply( - lambda x: binary_implicit_type_promotion( # type: ignore[arg-type,return-value] - x, - right_type, - op_type_to_check, - return_type, - interval_allowed, - error_info, + if return_type is not None: + # Operator has a fixed return type (e.g. Boolean for comparisons). + # Per-row promotion would raise 3-1 for rows whose actual type + # cannot be promoted to the RHS type (e.g. Number vs Item), but + # at runtime such comparisons simply return false/null rather than + # an error. Assign the known return type directly. + result_dataframe["data_type"] = return_type # type: ignore[call-overload] + else: + # Series.apply stubs don't know ScalarType is a valid cell value + result_dataframe["data_type"] = result_dataframe[ + "data_type" + ].apply( + lambda x: binary_implicit_type_promotion( # type: ignore[arg-type,return-value] + x, + right_type, + op_type_to_check, + return_type, + interval_allowed, + error_info, + ) ) - ) elif isinstance(right_type, Mixed): - # Series.apply stubs don't know ScalarType is a valid cell value - result_dataframe["data_type"] = result_dataframe["data_type"].apply( - lambda x: binary_implicit_type_promotion( # type: ignore[arg-type,return-value] - left_type, - x, - op_type_to_check, - return_type, - interval_allowed, - error_info, + if return_type is not None: + result_dataframe["data_type"] = return_type # type: ignore[call-overload] + else: + # Series.apply stubs don't know ScalarType is a valid cell value + result_dataframe["data_type"] = result_dataframe[ + "data_type" + ].apply( + lambda x: binary_implicit_type_promotion( # type: ignore[arg-type,return-value] + left_type, + x, + op_type_to_check, + return_type, + interval_allowed, + error_info, + ) ) - ) if return_type: return return_type, result_dataframe diff --git a/tests/unit/ast/test_with_where_clause.py b/tests/unit/ast/test_with_where_clause.py new file mode 100644 index 0000000..3b9ef18 --- /dev/null +++ b/tests/unit/ast/test_with_where_clause.py @@ -0,0 +1,28 @@ +"""Regression test for ``with { } [ where ]: body`` expression parsing. + +Bug: ``visitWithExpression`` hard-coded ``ctx_list[3]`` as the body +expression. When the optional ``[ WHERE expression ]`` block is present, +ctx_list[3] is the WHERE terminal node, not the body. Visiting a terminal +node returns None, so ``WithExpression.expression`` was None, and the +semantic analyzer then called ``self.visit(None)`` which raised +``NotImplementedError: No visit_NoneType method``. + +The fix uses ``ctx_list[-1]`` which is always the body expression. +""" + +from dpmcore.dpm_xl.ast.nodes import Start, WithExpression +from dpmcore.services.syntax import SyntaxService + + +def test_with_where_clause_body_is_not_none() -> None: + """Body expression of ``with { } [ where ]: body`` must not be None.""" + expression = ( + "with {tR_04.00.a, c*, default: 0, interval: true}" + " [where qPYB = [eba_qIA:qx2090]]:" + " {r0100} >= 0" + ) + ast = SyntaxService().parse(expression) + assert isinstance(ast, Start) + with_expr = ast.children[0] + assert isinstance(with_expr, WithExpression) + assert with_expr.expression is not None diff --git a/tests/unit/dpm_xl/test_operator_invariants.py b/tests/unit/dpm_xl/test_operator_invariants.py index 92cd86c..bff940d 100644 --- a/tests/unit/dpm_xl/test_operator_invariants.py +++ b/tests/unit/dpm_xl/test_operator_invariants.py @@ -32,8 +32,11 @@ ScalarSet, ) from dpmcore.dpm_xl.types.scalar import ( # noqa: E402 + Boolean, Integer, Item, + Mixed, + Number, ScalarFactory, ScalarType, String, @@ -174,3 +177,45 @@ def test_in_declares_accepts_scalar_set_rhs() -> None: def test_equal_does_not_accept_scalar_set_rhs() -> None: assert Equal.accepts_scalar_set_rhs is False + + +class TestMixedTypeOperands: + """Mixed-type cells must not crash or raise 3-1 in Boolean operators.""" + + def test_in_mixed_scalar_vs_item_set_returns_boolean(self) -> None: + """In operator: Mixed LHS + Item ScalarSet must return Boolean, not crash. + + Regression: ``validate_types`` raised ``Exception("Mixed type promotion + requires a result dataframe")`` when result_dataframe was None (the + Scalar+ScalarSet path in validate_structures always returns None). + """ + left = _make_scalar(Mixed, "mixed_cell") + right = _make_scalar_set(Item) + result = In.validate(left, right) + assert isinstance(result, Scalar) + assert isinstance(result.type, Boolean) + + def test_equal_mixed_scalar_vs_item_returns_boolean(self) -> None: + """Equal operator: Mixed LHS + Item Scalar must return Boolean, not raise 3-1. + + Regression: ``binary_implicit_type_promotion_with_mixed_types`` iterated + over rows; when a row had Number type, comparing Number to Item raised + SemanticError("3-1") even though the operator return type is Boolean. + """ + import pandas as pd + + from dpmcore.dpm_xl.types.promotion import ( + binary_implicit_type_promotion_with_mixed_types, + ) + + df = pd.DataFrame({"data_type": [Number(), Item()]}) + final_type, _result_df = ( + binary_implicit_type_promotion_with_mixed_types( + result_dataframe=df, + left_type=Mixed(), + right_type=Item(), + op_type_to_check=None, + return_type=Boolean(), + ) + ) + assert isinstance(final_type, Boolean)