Skip to content

fix(workflows): apply chained expression filters left-to-right#3339

Open
Noor-ul-ain001 wants to merge 1 commit into
github:mainfrom
Noor-ul-ain001:fix/workflow-chained-filters
Open

fix(workflows): apply chained expression filters left-to-right#3339
Noor-ul-ain001 wants to merge 1 commit into
github:mainfrom
Noor-ul-ain001:fix/workflow-chained-filters

Conversation

@Noor-ul-ain001

Copy link
Copy Markdown
Contributor

Summary

Filter chaining is broken in the workflow expression evaluator. _evaluate_simple_expression splits at the first top-level | and treats the entire remainder as one filter, so any expression with more than one filter raises ValueError.

This makes the canonical map use case impossible: map returns a list, and join is the only filter that renders a list to a string — the two are meant to be chained.

from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext

ctx = StepContext(inputs={"rows": [{"name": "a"}, {"name": "b"}]})

# want "a, b"; actual: ValueError: filter 'join' used in an unsupported form
evaluate_expression("{{ inputs.rows | map('name') | join(', ') }}", ctx)

A single filter (| map('name') or | join(', ') alone) works, so chaining is the only broken axis — but it affects every registered filter.

Root cause

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()   # <- whole tail, incl. later `|`
    ...
    filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)  # chokes on the next `|`

For map('name') | join(', '), filter_expr becomes the full string and the greedy (.+) argument capture spans the second filter, so parsing fails.

Fix

  • Split the pipe chain into top-level segments (quote/bracket aware, so a literal | inside a quoted argument such as join(' | ') or an operand like 'a|b' is not treated as a separator).
  • Fold each filter over the running value left-to-right.
  • The single-filter logic is extracted verbatim into a new _apply_filter helper, so all existing strict handling is preserved and now applies to every link in the chain:
    • from_json arity/trailing-token strictness
    • "registered filter used in an unsupported form" vs "unknown filter" messages

Tests

Added to TestExpressions:

  • test_chained_filters_apply_left_to_rightmap → join, a three-link map → join → contains, and default → contains.
  • test_chained_filter_error_in_later_link_raises — a mis-wired filter anywhere in the chain still fails loudly.
  • test_pipe_in_quoted_arg_is_not_a_filter_separator — literal | inside quotes stays intact.

Test-the-test verified: the two chaining tests fail without the source change and pass with it. Full TestExpressions (37 tests) passes; the only failures in tests/test_workflows.py are the pre-existing Windows symlink-guard tests (unrelated, require elevation).

🤖 Generated with Claude Code

The pipe-filter parser in `_evaluate_simple_expression` split the
expression only at the *first* top-level `|` and treated the whole
remainder as a single filter. So a filter chain like
`{{ inputs.rows | map('name') | join(', ') }}` handed
`map('name') | join(', ')` to one filter, where the `(\w+)\((.+)\)`
regex mangled it and raised `ValueError`.

This broke the canonical use of `map`: it returns a list, and `join`
is the only filter that renders a list to a string, so the two are
meant to be chained. Chaining was impossible for every registered
filter.

Split the pipe segments at the top level (quote/bracket aware, so a
literal `|` inside a quoted argument like `join(' | ')` is preserved)
and fold each filter over the running value. The single-filter logic
is extracted verbatim into `_apply_filter`, so all existing strict
handling (`from_json` arity, unsupported-form vs unknown-filter
messages) is unchanged and now applies to every link in the chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Noor-ul-ain001 Noor-ul-ain001 requested a review from mnriem as a code owner July 5, 2026 06:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant