Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/concepts/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ Two things about filters are easy to conflate:

For that reason a profile accepts only the **object** form of a filter from the model. A raw filter string is rejected both by the advertised schema and by the tool itself, because strings bypass the DSL's field validation and have no safe composition with a locked expression.

Structure is only half of it: the nesting guarantee holds only while every filter *value* stays inside its own clause. Text values are escaped at the filter boundary for that reason — unescaped, a value containing a quote or a parenthesis could close its clause and inject query syntax after it, including a `|` that escapes the surrounding AND. Tag values are escaped and numeric values are type-checked. A caller filter that still renders as something able to break out is refused rather than combined.
Structure is only half of it: the nesting guarantee holds only while every filter *value* stays inside its own clause. Text `eq`/`ne` values are handled by the `Text` filter itself, which renders them as a quoted phrase with any `"` or `\` replaced by a space, so a parenthesis or a `|` the value carries is literal text rather than syntax. Text `like` values are patterns, so the library leaves them raw and this boundary escapes them instead — the delimiters that would close the clause are escaped, the pattern metacharacters are not. Tag values have their delimiters escaped and numeric values are type-checked. A caller filter that still renders as something able to break out is refused rather than combined.

Profiles resolve to a built-in call and nothing more, so they inherit the concurrency cap, request timeout, read-only policy, auth scoping, and error mapping already applied to `search-records`.

Expand Down
7 changes: 6 additions & 1 deletion docs/user_guide/02_complex_filtering.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,12 @@
"source": [
"### Text Filters\n",
"\n",
"Text filters are filters that are applied to text fields. These filters are applied to the entire text field. For example, if you have a text field that contains the text \"The quick brown fox jumps over the lazy dog\", a text filter of \"quick\" will match this text field."
"Text filters are filters that are applied to text fields. These filters are applied to the entire text field. For example, if you have a text field that contains the text \"The quick brown fox jumps over the lazy dog\", a text filter of \"quick\" will match this text field.\n",
"\n",
"A value passed to `==` or `!=` is matched as a literal phrase, so a `\"` or a\n",
"`\\` inside it is insignificant rather than interpreted. The `%` operator is the\n",
"opposite: its value is a raw pattern, so pass it only patterns your own code\n",
"composes."
]
},
{
Expand Down
31 changes: 13 additions & 18 deletions redisvl/mcp/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@
from redisvl.schema import IndexSchema
from redisvl.utils.token_escaper import TokenEscaper

# Text values reach the query string unescaped: `Text`'s operator templates are
# `@field:("value")` for eq/ne and `@field:(value)` for like, so a caller value
# containing a quote or a paren can close its own clause and inject arbitrary
# RediSearch syntax after it -- including a `|` that escapes an enclosing AND.
# Tag and numeric values are already escaped or type-checked upstream; text is
# not, so this boundary escapes it before building the expression.
_TEXT_ESCAPER = TokenEscaper()

# `Text` neutralizes its own eq/ne values (see `_PHRASE_UNSAFE` in
# `redisvl.query.filter`), so this boundary must not treat them again -- doubled
# handling mangles ordinary values. `like` below is the exception, because the
# library leaves `%` raw by design.
#
# `like` is the pattern operator, so the metacharacters that give a pattern its
# meaning have to stay live: `*` and `?` for wildcards, `%` for fuzzy matching,
# and a space for the implicit AND between terms. Escaping those does not fail
Expand Down Expand Up @@ -156,11 +153,6 @@ def _parse_tag_expression(field_name: str, op: str, operand: Any) -> FilterExpre
)


def _escape_text(value: str) -> str:
"""Escape a caller-supplied text value so it cannot leave its own clause."""
return _TEXT_ESCAPER.escape(value)


def _escape_like_pattern(value: str) -> str:
"""Escape a `like` pattern, leaving its pattern metacharacters intact."""
return _LIKE_ESCAPER.escape(value)
Expand All @@ -169,17 +161,20 @@ def _escape_like_pattern(value: str) -> str:
def _parse_text_expression(field_name: str, op: str, operand: Any) -> FilterExpression:
field = Text(field_name)
if op == "eq":
return field == _escape_text(_require_string(operand, field_name, op))
return field == _require_string(operand, field_name, op)
if op == "ne":
return field != _escape_text(_require_string(operand, field_name, op))
return field != _require_string(operand, field_name, op)
if op == "like":
# An exact-match value is a literal, but a `like` value is a pattern, so
# the two need different escaping -- see `_LIKE_ESCAPED_CHARS`.
# An exact-match value is a literal that `Text` neutralizes itself, but a
# `like` value is a pattern the library leaves raw -- see
# `_LIKE_ESCAPED_CHARS`.
return field % _escape_like_pattern(_require_string(operand, field_name, op))
if op == "in":
# A fresh builder per item: each `==` mutates the instance, so reusing one
# only works while `FilterExpression` captures the rendering eagerly.
return _combine_or(
[
field == _escape_text(item)
Text(field_name) == item
for item in _require_string_list(operand, field_name, op)
]
)
Expand Down
77 changes: 52 additions & 25 deletions redisvl/mcp/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,37 +370,42 @@ def _build_fallback_hybrid_kwargs(
}


def _find_range_end(rendered: str, position: int) -> int | None:
"""Return the index of the `]` closing a numeric range, or None if unclosed."""
while position < len(rendered):
def _find_unescaped(
rendered: str, position: int, character: str, end: int | None = None
) -> int | None:
"""Return the index of the next unescaped `character` before `end`, or None.

`end` bounds the scan. Without it a caller inside a span pays for the whole
remaining string, which makes the enclosing walk quadratic in the number of
spans.
"""
limit = len(rendered) if end is None else min(end, len(rendered))
while position < limit:
if rendered[position] == "\\":
position += 2
continue
if rendered[position] == "]":
if rendered[position] == character:
return position
position += 1
return None


def _span_holds_unescaped_pipe(rendered: str, start: int, end: int) -> bool:
"""Report whether `rendered[start:end]` holds a `|` that is not escaped."""
position = start
while position < end:
if rendered[position] == "\\":
position += 2
continue
if rendered[position] == "|":
return True
position += 1
return False


def _reject_escapable_filter(caller: FilterExpression) -> None:
"""Refuse a caller filter whose rendering could break out of a locked AND.

A well-formed expression built from escaped values cannot escape, so anything
this rejects means a value reached the query string unescaped. It is a
backstop, not the primary defense -- see ``merge_locked_filter``.
Outside a quoted phrase every value reaches the rendering escaped or
type-checked -- by the DSL, or at this server's filter boundary for ``like``
patterns, which the library leaves raw. So a rejection means one of those
failed. It is a backstop, not the primary defense -- see
``merge_locked_filter`` -- and it runs only when a lock exists, so an
unlocked tool relies on those two layers alone.

The quoted-phrase skip below assumes a rendering's quotes arrive in
delimiting pairs, which holds for every value the DSL can render: ``==`` and
``!=`` quote the value and take any quote it carried out first, and ``Tag``
and ``like`` values arrive with theirs escaped, outside any phrase. If that
ever stops holding, the skip finds no closing quote and refuses the filter,
so the failure is loud rather than silent.
"""
# Braces scope as much as parens do: a tag clause holds its alternatives in
# braces, so `@category:{sports|health}` is one scoped clause rather than a
Expand All @@ -425,6 +430,26 @@ def _reject_escapable_filter(caller: FilterExpression) -> None:
position += 2
continue

if character == '"':
# A quoted phrase is a literal, so nothing inside it is structure and
# a `|` inside it is a separator rather than a union. Unlike the range
# span below, the whole phrase can therefore be skipped.
#
# The scan deliberately does not treat `\` as escaping the closing
# quote, because RediSearch does not either: `@f:("x\")` closes the
# phrase and yields the term `x\`. Honouring the escape would read
# that as unterminated and refuse an ordinary value ending in a
# backslash, a Windows path among them. Nothing is given up, since a
# value cannot contribute a quote of its own -- `Text` replaces it,
# and `Tag` and `like` escape theirs outside any phrase.
end = rendered.find('"', position + 1)
if end == -1:
# Unterminated phrase: the remainder is unparsable.
escaped = True
break
position = end + 1
continue
Comment thread
vishal-bala marked this conversation as resolved.

if character == "[":
# A numeric range is bounds, not structure: an exclusive bound
# renders as `[(5 +inf]`, where `(` is a marker rather than a group.
Expand All @@ -434,14 +459,14 @@ def _reject_escapable_filter(caller: FilterExpression) -> None:
# range holds numbers, so a `|` in here means a value reached the
# query string raw, which is exactly the case this backstop exists
# to catch; skipping past it would let a union hide behind brackets.
end = _find_range_end(rendered, position + 1)
if end is None:
span_end = _find_unescaped(rendered, position + 1, "]")
if span_end is None:
escaped = True
break
if _span_holds_unescaped_pipe(rendered, position + 1, end):
if _find_unescaped(rendered, position + 1, "|", span_end) is not None:
escaped = True
break
position = end + 1
position = span_end + 1
continue

if character in openers:
Expand Down Expand Up @@ -485,7 +510,9 @@ def merge_locked_filter(
The locked expression always applies, so a caller can only narrow within it
and never widen past it. That rests on two things: the caller's expression
rendering nested inside the AND, and every value staying inside its own
clause (which ``_parse_text_expression`` escaping provides).
clause. ``Text`` removes the quote that delimits a phrase, ``Tag`` escapes
the braces that delimit a tag clause, numeric values are type-checked, and
``like`` patterns are escaped at the filter boundary.
"""
if locked is None:
return caller
Expand Down
128 changes: 112 additions & 16 deletions redisvl/query/filter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import datetime
import math
import numbers
import re
from enum import Enum
from functools import wraps
Expand Down Expand Up @@ -353,10 +355,9 @@ class Num(FilterField):
FilterOperator.LT: "@%s:[-inf (%s]",
FilterOperator.GE: "@%s:[%s +inf]",
FilterOperator.LE: "@%s:[-inf %s]",
FilterOperator.BETWEEN: "@%s:[%s %s]",
}

SUPPORTED_VAL_TYPES = (int, float, tuple, type(None))
SUPPORTED_VAL_TYPES = (int, float, type(None))

def __eq__(self, other: int | float) -> "FilterExpression":
"""Create a Numeric equality filter expression.
Expand Down Expand Up @@ -462,8 +463,41 @@ def _validate_inclusive_string(inclusive: str) -> Inclusive:
f"Invalid inclusive value must be: {[i.value for i in Inclusive]}"
)

@classmethod
def _coerce_numeric(cls, value: Any, name: str = "value") -> int | float:
"""Return a numeric filter value as a plain int or float.

Coercion rather than the type check is the guard: every numeric value is
formatted into the query string, so a subclass overriding __str__ would
satisfy isinstance and inject syntax. int() and float() return builtins
regardless, which strips the override.
"""
if isinstance(value, numbers.Integral):
return int(value)
if isinstance(value, numbers.Real):
coerced = float(value)
if math.isnan(coerced):
# Renders `@field:[nan ...]`, which RediSearch rejects outright.
raise ValueError(f"{cls.__name__} {name} cannot be NaN")
return coerced
raise TypeError(
f"{cls.__name__} {name} must be an int, a float, or another "
f"numbers.Real; got {type(value).__name__}"
)

def _set_value(
self,
val: Any,
val_type: type | tuple[type, ...],
operator: FilterOperator,
):
"""Type-check as usual, then coerce, so no operator formats a subclass."""
super()._set_value(val, val_type, operator)
if self._value is not None:
self._value = self._coerce_numeric(self._value)

def _format_inclusive_between(
self, inclusive: Inclusive, start: int, end: int
self, inclusive: Inclusive, start: int | float, end: int | float
) -> str:
if inclusive.value == Inclusive.BOTH.value:
return f"@{self._field}:[{start} {end}]"
Expand All @@ -480,24 +514,44 @@ def _format_inclusive_between(
raise ValueError(f"Inclusive value not found")

def between(
self, start: int, end: int, inclusive: str = "both"
self, start: int | float, end: int | float, inclusive: str = "both"
) -> "FilterExpression":
"""Operator for searching values between two numeric values."""
inclusive = self._validate_inclusive_string(inclusive)
expression = self._format_inclusive_between(inclusive, start, end)
"""Operator for searching values between two numeric values.

return FilterExpression(expression)
Args:
start (Union[int, float]): The lower bound of the range.
end (Union[int, float]): The upper bound of the range.
inclusive (str, optional): Which bounds to include: "both",
"neither", "left" or "right". Defaults to "both".

Raises:
TypeError: If either bound is not an ``int``, a ``float``, or
another ``numbers.Real``. numpy scalars qualify; ``Decimal``
and ``str`` do not.
ValueError: If either bound is NaN, or if ``inclusive`` is not one
of the four accepted values.

.. code-block:: python

from redisvl.query.filter import Num

f = Num("age").between(18, 65)
f = Num("age").between(18, 65, inclusive="neither")

"""
# between() is the one operator that never reaches _set_value.
checked_start = self._coerce_numeric(start, "start")
checked_end = self._coerce_numeric(end, "end")
inclusive_value = self._validate_inclusive_string(inclusive)

return FilterExpression(
self._format_inclusive_between(inclusive_value, checked_start, checked_end)
)

def __str__(self) -> str:
"""Return the Redis Query string for the Numeric filter"""
if self._value is None:
return "*"
if self._operator == FilterOperator.BETWEEN:
return self.OPERATOR_MAP[self._operator] % (
self._field,
self._value[0],
self._value[1],
)
if self._operator == FilterOperator.EQ or self._operator == FilterOperator.NE:
return self.OPERATOR_MAP[self._operator] % (
self._field,
Expand All @@ -508,8 +562,30 @@ def __str__(self) -> str:
return self.OPERATOR_MAP[self._operator] % (self._field, self._value)


# A double quote is the only character that can terminate a quoted phrase, so it
# is the only one that needs replacing. (A trailing backslash does not terminate
# one either -- `@f:("x\")` parses as the term `x\`.)
#
# Replaced rather than escaped, because escaping is symmetric: a backslash joins
# the separator into the term, so `@f:("say \"hi\" now")` asks for a term with a
# quote in it, and RedisVL writes documents unescaped. On `==` that matches
# nothing, and on `!=` the unmatchable phrase makes the negation match
# everything. A space is what the tokenizer left at that position anyway.
_PHRASE_UNSAFE = re.compile(r'"')


class Text(FilterField):
"""A Text is a FilterField representing a text field in a Redis index."""
"""A Text is a FilterField representing a text field in a Redis index.

Note:
``==`` and ``!=`` match the value as a quoted phrase. Any ``"`` in the
value becomes a space first, so the value cannot close that phrase; a
quote already separates tokens at index time, so this matches the same
documents that escaping it never could. A value of nothing but quotes
therefore becomes an empty phrase, which ``==`` matches no document
against. ``%`` is the pattern operator and interpolates its value
untouched.
"""

OPERATORS: dict[FilterOperator, str] = {
FilterOperator.EQ: "==",
Expand All @@ -523,6 +599,12 @@ class Text(FilterField):
}
SUPPORTED_VAL_TYPES = (str, type(None))

# `%` is the pattern operator: its value is raw by design, which is what
# makes `*`, `%%` and `|` work. Listing the exception rather than the rule
# means a new operator -- or a subclass adding one -- is contained unless it
# opts out here.
_RAW_VALUE_OPERATORS = frozenset({FilterOperator.LIKE})

@check_operator_misuse
def __eq__(self, other: str) -> "FilterExpression":
"""Create a Text equality filter expression. These expressions yield
Expand Down Expand Up @@ -578,6 +660,13 @@ def __mod__(self, other: str) -> "FilterExpression":
f = Text("job") % "engineer|doctor" # contains either term in field
f = Text("job") % "engineer doctor" # contains both terms in field

Note:
The value is interpolated raw, which is what makes ``*``, ``%%`` and
``|`` work. A value carrying a ``)`` therefore closes this clause and
has its remainder parsed as query syntax, past any surrounding
filter. Pass only patterns your own code composes; for a value you
did not construct, use ``==``, which matches it as a literal phrase.

"""
self._set_value(other, self.SUPPORTED_VAL_TYPES, FilterOperator.LIKE)
return FilterExpression(str(self))
Expand All @@ -587,9 +676,16 @@ def __str__(self) -> str:
if not self._value:
return "*"

value = self._value
if self._operator not in self._RAW_VALUE_OPERATORS:
# Substituting a space never empties the value, so the phrase always
# holds at least one character and never trips the `INDEXEMPTY`
# error that a literal `@field:("")` raises.
value = _PHRASE_UNSAFE.sub(" ", value)

return self.OPERATOR_MAP[self._operator] % (
self._field,
self._value,
value,
)


Expand Down
Loading
Loading