diff --git a/apps/api/services/workbook/conditions.py b/apps/api/services/workbook/conditions.py index a0855330..5ca4d97a 100644 --- a/apps/api/services/workbook/conditions.py +++ b/apps/api/services/workbook/conditions.py @@ -11,6 +11,7 @@ - {status} == "active" → only run if status matches - {email} == "" AND {website} != "" → compound conditions - {email} == "" OR {phone} == "" → OR conditions + - AND binds before OR in mixed conditions (parentheses are not supported) - true / false / always / never → literals Column config example: @@ -60,15 +61,12 @@ def evaluate_condition( values = _get_values(row_cells, columns_config) # Parse syntax before resolving cells so their contents remain operand data. - parts = _split_unquoted(condition, r" AND ") - if len(parts) > 1: - return all(_eval_single(p.strip(), values) for p in parts) - - parts = _split_unquoted(condition, r" OR ") - if len(parts) > 1: - return any(_eval_single(p.strip(), values) for p in parts) - - return _eval_single(condition, values) + # OR separates conjunctions: AND binds more tightly. Keep both passes on + # authored syntax so operators inside literals or cell data stay data. + return any( + all(_eval_single(part.strip(), values) for part in _split_unquoted(group, r" AND ")) + for group in _split_unquoted(condition, r" OR ") + ) def _split_unquoted(expr: str, pattern: str, maxsplit: int = 0, flags: int = 0) -> list[str]: diff --git a/tests/test_workbook_condition_precedence.py b/tests/test_workbook_condition_precedence.py new file mode 100644 index 00000000..8ba4145a --- /dev/null +++ b/tests/test_workbook_condition_precedence.py @@ -0,0 +1,28 @@ +"""Mixed column conditions use AND precedence over OR.""" + +import itertools + +import pytest + +from apps.api.services.workbook.conditions import evaluate_condition + + +@pytest.mark.parametrize("a,b,c", list(itertools.product([False, True], repeat=3))) +@pytest.mark.parametrize("expression,order", [ + ('{a} == "yes" OR {b} == "yes" AND {c} == "yes"', "or-and"), + ('{a} == "yes" AND {b} == "yes" OR {c} == "yes"', "and-or"), +]) +def test_mixed_conditions_follow_boolean_truth_table(a, b, c, expression, order): + cells = {key: {"value": "yes" if value else "no"} for key, value in zip("abc", (a, b, c))} + expected = (a or (b and c)) if order == "or-and" else ((a and b) or c) + assert evaluate_condition(expression, cells, []) is expected + + +def test_mixed_conditions_keep_operator_text_inside_literals_and_cells(): + cells = {"name": {"value": "Sales OR Marketing AND Research"}, "score": {"value": 10}} + assert evaluate_condition( + '{name} == "Sales OR Marketing AND Research" OR {score} > 20 AND {score} < 0', cells, [] + ) + assert not evaluate_condition( + '{name} == "Other" OR {score} > 20 AND {name} == "Sales OR Marketing AND Research"', cells, [] + )