Skip to content

fix: escape the union operator in tag filter values - #717

Draft
vishal-bala wants to merge 1 commit into
mainfrom
fix/escape-pipe-in-tag-values
Draft

fix: escape the union operator in tag filter values#717
vishal-bala wants to merge 1 commit into
mainfrom
fix/escape-pipe-in-tag-values

Conversation

@vishal-bala

Copy link
Copy Markdown
Collaborator

Motivation

A | inside a tag filter value reaches the query string unescaped, where RediSearch reads it as its union operator. TokenEscaper covers {, }, \, whitespace, ,, * and ?, but neither of its character classes covers |, so a value carrying one widens its own clause into a union rather than matching literally.

Measured on 0.27.0:

Tag("tenant_id") == "acme|victim"  ->  '@tenant_id:{acme|victim}'

That is a match on either value, not on the one asked for. It matters most where a tag value comes from outside the caller's control and is combined with other clauses: under DIALECT 2 | binds looser than the implicit intersection, so a union appearing at the top level of a combined expression stops the surrounding clauses constraining the result at all.

The same gap affects the two full-text tokenizers, which split a raw user query on whitespace, escape each token, and join the results with " | ". A single whitespace-delimited token containing foo|bar currently contributes its own union to the generated query.

Changes

| joins the default escaped set

DEFAULT_ESCAPED_CHARS = r"[,.<>{}\[\]\\\"\':;!@#$%^&*()\-+=~|\/ \?]"

Tag values are escaped element by element before being joined, so the list form that renders a union deliberately is unaffected:

Tag("tenant_id") == "acme|victim"      ->  '@tenant_id:{acme\|victim}'
Tag("tenant_id") == ["acme", "victim"] ->  '@tenant_id:{acme|victim}'

ESCAPED_CHARS_NO_WILDCARD is deliberately left alone

That class backs preserve_wildcards=True, which is what the % operator uses, and Tag.__mod__'s own docstring documents Tag("category") % "elec*|*soft" as multiple wildcard patterns. Escaping | there would turn a documented union into a literal that matches nothing, and it would do so silently. The comment above the class now records that reasoning so the asymmetry is not read as an oversight.

Secondary changes

  • Four escaper tests and one filter test covering the escaped pair, a chain, a leading |, the preserved wildcard union, and the list form.
  • A stale comment in redisvl/mcp/filters.py corrected: it claimed no escaper in RedisVL touches |, which is no longer true of the default path.

Notes

This changes the rendering of an existing public API, so it is worth being precise about who sees a difference. Any caller passing a literal | inside a single Tag equality or inequality value gets a literal match where they previously got a union. A grep of tests/, docs/ and redisvl/ found nothing relying on that behaviour, and the list form remains the documented way to express a tag union. The % operator is unchanged.

Text filters are not covered here and remain unescaped: Text.__str__ interpolates its value straight into @field:("%s") without calling the escaper at all, so adding a character to the escaper's class does not reach them. That is a separate defect with a different fix, since escaping a text value has to keep spaces live for phrase matching. The MCP server already guards its own text boundary in redisvl/mcp/filters.py.

Release Notes

This is a backwards-incompatible change to how tag filter values render. Tag filter values containing a | are now escaped rather than interpreted as RediSearch's union operator, so Tag("field") == "a|b" matches the single literal value a|b where it previously matched documents tagged either a or b. Queries relying on that implicit union will return fewer results, or none, after upgrading.

Passing a list remains the supported way to express a tag union and is unchanged: Tag("field") == ["a", "b"] still matches either value. Code depending on the old behaviour should pass a list. The % pattern operator is also unchanged, and continues to treat | as a union between wildcard patterns.

The previous behaviour meant a tag value drawn from outside the caller's control could widen its own clause. Under DIALECT 2 | binds looser than the implicit intersection, so such a union appearing at the top level of a combined filter stopped the surrounding clauses constraining the result.

Next Steps

  1. Run the full unit suite, since this alters a core rendering path:
uv run pytest tests/unit -q

TokenEscaper covered braces, backslash, whitespace, comma and the
wildcards, but neither character class covered `|`. RediSearch reads it
as a union, so `Tag("f") == "a|b"` matched either value instead of the
literal one, and under DIALECT 2 `|` binds looser than the implicit
intersection -- so a union reaching the top level of a combined filter
stopped the surrounding clauses constraining the result.

Add `|` to DEFAULT_ESCAPED_CHARS. Tag values are escaped element by
element before being joined, so the list form that renders a union
deliberately is unaffected. ESCAPED_CHARS_NO_WILDCARD is left alone
because the `%` operator documents `|` as a union between wildcard
patterns; the comment there now records the asymmetry.

Text filters are not covered: Text.__str__ never calls the escaper.
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