diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 6ea3a5f494..6257930a5a 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -230,11 +230,13 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: if expr[:1] in ("'", '"') and expr.find(expr[0], 1) == len(expr) - 1: return expr[1:-1] - # Handle pipe filters - if "|" in expr: - parts = expr.split("|", 1) - value = _evaluate_simple_expression(parts[0].strip(), namespace) - filter_expr = parts[1].strip() + # Handle pipe filters. Detect the pipe at the top level only, so a literal + # '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is + # not mistaken for a filter separator — mirroring the operator parsing below. + pipe_idx = _find_top_level(expr, "|") + if pipe_idx != -1: + value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace) + filter_expr = expr[pipe_idx + 1:].strip() # `from_json` is strict: it takes no arguments and tolerates no # trailing tokens. Match on the leading filter name and require the diff --git a/tests/test_workflows.py b/tests/test_workflows.py index cee02c46ba..7751feee83 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -322,6 +322,27 @@ def test_operator_splitting_is_quote_aware(self): assert evaluate_expression("{{ inputs.a == 9 or inputs.b == 2 }}", plain) is True assert evaluate_expression("{{ inputs.missing | default('a and b') }}", plain) == "a and b" + def test_pipe_detection_is_quote_aware(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + # A literal '|' inside a quoted operand must not be treated as a filter + # pipe: the comparison applies to the whole string. + ctx = StepContext(inputs={"x": "a|b"}) + assert evaluate_expression("{{ inputs.x == 'a|b' }}", ctx) is True + assert evaluate_expression("{{ inputs.x == 'a|b' }}", StepContext(inputs={"x": "z"})) is False + # membership against a literal containing a pipe + assert evaluate_expression("{{ 'a|b' in inputs.s }}", StepContext(inputs={"s": "x a|b y"})) is True + # a single quoted literal containing pipes is preserved + assert evaluate_expression("{{ 'a|b|c' }}", StepContext()) == "a|b|c" + + # Regression: real filters still work, including a pipe inside a filter arg. + ctx2 = StepContext(inputs={"items": ["a", "b"], "s": "xabz"}) + assert evaluate_expression("{{ inputs.missing | default('y') }}", ctx2) == "y" + assert evaluate_expression('{{ inputs.items | join("-") }}', ctx2) == "a-b" + assert evaluate_expression("{{ inputs.s | contains('ab') }}", ctx2) is True + assert evaluate_expression("{{ inputs.missing | default('a|b') }}", ctx2) == "a|b" + def test_filter_default(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext