From 4577b465845c2289acda10b8ab99380b4018b930 Mon Sep 17 00:00:00 2001 From: Tyler Hutcherson Date: Sun, 19 Nov 2023 22:15:45 -0500 Subject: [PATCH] IMPROVEMENT: Minor redis improvements (#13381) - **Description:** - Fixes a `key_prefix` bug where passing it in on `Redis.from_existing(...)` did not work properly. Updates doc strings accordingly. - Updates Redis filter classes logic with best practices on typing, string formatting, and handling "empty" filters. - Fixes a bug that would prevent multiple tag filters from being applied together in some scenarios. - Added a whole new filter unit testing module. Also updated code formatting for a number of modules that were failing the `make` commands. - **Issue:** N/A - **Dependencies:** N/A - **Tag maintainer:** @baskaryan - **Twitter handle:** @tchutch94 --- libs/langchain/langchain/utilities/redis.py | 8 +- .../langchain/vectorstores/redis/base.py | 60 +++--- .../langchain/vectorstores/redis/filters.py | 178 +++++++++------- .../vectorstores/redis/test_filters.py | 193 ++++++++++++++++++ 4 files changed, 342 insertions(+), 97 deletions(-) create mode 100644 libs/langchain/tests/unit_tests/vectorstores/redis/test_filters.py diff --git a/libs/langchain/langchain/utilities/redis.py b/libs/langchain/langchain/utilities/redis.py index 605a611967c23..63156272c62b8 100644 --- a/libs/langchain/langchain/utilities/redis.py +++ b/libs/langchain/langchain/utilities/redis.py @@ -28,7 +28,7 @@ class TokenEscaper: # Characters that RediSearch requires us to escape during queries. # Source: https://redis.io/docs/stack/search/reference/escaping/#the-rules-of-text-field-tokenization - DEFAULT_ESCAPED_CHARS = r"[,.<>{}\[\]\\\"\':;!@#$%^&*()\-+=~\/]" + DEFAULT_ESCAPED_CHARS = r"[,.<>{}\[\]\\\"\':;!@#$%^&*()\-+=~\/ ]" def __init__(self, escape_chars_re: Optional[Pattern] = None): if escape_chars_re: @@ -37,6 +37,12 @@ def __init__(self, escape_chars_re: Optional[Pattern] = None): self.escaped_chars_re = re.compile(self.DEFAULT_ESCAPED_CHARS) def escape(self, value: str) -> str: + if not isinstance(value, str): + raise TypeError( + "Value must be a string object for token escaping." + f"Got type {type(value)}" + ) + def escape_symbol(match: re.Match) -> str: value = match.group(0) return f"\\{value}" diff --git a/libs/langchain/langchain/vectorstores/redis/base.py b/libs/langchain/langchain/vectorstores/redis/base.py index d35e676472418..940b3c9ce1ac2 100644 --- a/libs/langchain/langchain/vectorstores/redis/base.py +++ b/libs/langchain/langchain/vectorstores/redis/base.py @@ -60,9 +60,9 @@ def check_index_exists(client: RedisType, index_name: str) -> bool: try: client.ft(index_name).info() except: # noqa: E722 - logger.info("Index does not exist") + logger.debug("Index does not exist") return False - logger.info("Index already exists") + logger.debug("Index already exists") return True @@ -155,9 +155,12 @@ class Redis(VectorStore): .. code-block:: python - rds = Redis.from_existing_index( + # must pass in schema and key_prefix from another index + existing_rds = Redis.from_existing_index( embeddings, # an Embeddings object index_name="my-index", + schema=rds.schema, # schema dumped from another index + key_prefix=rds.key_prefix, # key prefix from another index redis_url="redis://localhost:6379", ) @@ -249,7 +252,7 @@ def __init__( key_prefix: Optional[str] = None, **kwargs: Any, ): - """Initialize with necessary components.""" + """Initialize Redis vector store with necessary components.""" self._check_deprecated_kwargs(kwargs) try: # TODO use importlib to check if redis is installed @@ -401,6 +404,7 @@ def from_texts_return_keys( index_schema = generated_schema # Create instance + # init the class -- if Redis is unavailable, will throw exception instance = cls( redis_url, index_name, @@ -495,6 +499,7 @@ def from_existing_index( embedding: Embeddings, index_name: str, schema: Union[Dict[str, str], str, os.PathLike], + key_prefix: Optional[str] = None, **kwargs: Any, ) -> Redis: """Connect to an existing Redis index. @@ -504,11 +509,16 @@ def from_existing_index( from langchain.vectorstores import Redis from langchain.embeddings import OpenAIEmbeddings + embeddings = OpenAIEmbeddings() - redisearch = Redis.from_existing_index( + + # must pass in schema and key_prefix from another index + existing_rds = Redis.from_existing_index( embeddings, index_name="my-index", - redis_url="redis://username:password@localhost:6379" + schema=rds.schema, # schema dumped from another index + key_prefix=rds.key_prefix, # key prefix from another index + redis_url="redis://username:password@localhost:6379", ) Args: @@ -516,8 +526,9 @@ def from_existing_index( for embedding queries. index_name (str): Name of the index to connect to. schema (Union[Dict[str, str], str, os.PathLike]): Schema of the index - and the vector schema. Can be a dict, or path to yaml file - + and the vector schema. Can be a dict, or path to yaml file. + key_prefix (Optional[str]): Prefix to use for all keys in Redis associated + with this index. **kwargs (Any): Additional keyword arguments to pass to the Redis client. Returns: @@ -528,29 +539,32 @@ def from_existing_index( ImportError: If the redis python package is not installed. """ redis_url = get_from_dict_or_env(kwargs, "redis_url", "REDIS_URL") - try: - # We need to first remove redis_url from kwargs, - # otherwise passing it to Redis will result in an error. - if "redis_url" in kwargs: - kwargs.pop("redis_url") - client = get_client(redis_url=redis_url, **kwargs) - # check if redis has redisearch module installed - check_redis_module_exist(client, REDIS_REQUIRED_MODULES) - # ensure that the index already exists - assert check_index_exists( - client, index_name - ), f"Index {index_name} does not exist" - except Exception as e: - raise ValueError(f"Redis failed to connect: {e}") + # We need to first remove redis_url from kwargs, + # otherwise passing it to Redis will result in an error. + if "redis_url" in kwargs: + kwargs.pop("redis_url") - return cls( + # Create instance + # init the class -- if Redis is unavailable, will throw exception + instance = cls( redis_url, index_name, embedding, index_schema=schema, + key_prefix=key_prefix, **kwargs, ) + # Check for existence of the declared index + if not check_index_exists(instance.client, index_name): + # Will only raise if the running Redis server does not + # have a record of this particular index + raise ValueError( + f"Redis failed to connect: Index {index_name} does not exist." + ) + + return instance + @property def schema(self) -> Dict[str, List[Any]]: """Return the schema of the index.""" diff --git a/libs/langchain/langchain/vectorstores/redis/filters.py b/libs/langchain/langchain/vectorstores/redis/filters.py index f8c6de2943b32..1def8e76f140b 100644 --- a/libs/langchain/langchain/vectorstores/redis/filters.py +++ b/libs/langchain/langchain/vectorstores/redis/filters.py @@ -1,7 +1,6 @@ from enum import Enum from functools import wraps -from numbers import Number -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union from langchain.utilities.redis import TokenEscaper @@ -57,7 +56,7 @@ def equals(self, other: "RedisFilterField") -> bool: return self._field == other._field and self._value == other._value def _set_value( - self, val: Any, val_type: type, operator: RedisFilterOperator + self, val: Any, val_type: Tuple[Any], operator: RedisFilterOperator ) -> None: # check that the operator is supported by this class if operator not in self.OPERATORS: @@ -108,15 +107,15 @@ class RedisTag(RedisFilterField): RedisFilterOperator.NE: "!=", RedisFilterOperator.IN: "==", } - OPERATOR_MAP: Dict[RedisFilterOperator, str] = { RedisFilterOperator.EQ: "@%s:{%s}", RedisFilterOperator.NE: "(-@%s:{%s})", RedisFilterOperator.IN: "@%s:{%s}", } + SUPPORTED_VAL_TYPES = (list, set, tuple, str, type(None)) def __init__(self, field: str): - """Create a RedisTag FilterField + """Create a RedisTag FilterField. Args: field (str): The name of the RedisTag field in the index to be queried @@ -125,21 +124,33 @@ def __init__(self, field: str): super().__init__(field) def _set_tag_value( - self, other: Union[List[str], str], operator: RedisFilterOperator + self, + other: Union[List[str], Set[str], Tuple[str], str], + operator: RedisFilterOperator, ) -> None: - if isinstance(other, list): - if not all(isinstance(tag, str) for tag in other): - raise ValueError("All tags must be strings") - else: + if isinstance(other, (list, set, tuple)): + try: + # "if val" clause removes non-truthy values from list + other = [str(val) for val in other if val] + except ValueError: + raise ValueError("All tags within collection must be strings") + # above to catch the "" case + elif not other: + other = [] + elif isinstance(other, str): other = [other] - self._set_value(other, list, operator) + + self._set_value(other, self.SUPPORTED_VAL_TYPES, operator) # type: ignore @check_operator_misuse - def __eq__(self, other: Union[List[str], str]) -> "RedisFilterExpression": - """Create a RedisTag equality filter expression + def __eq__( + self, other: Union[List[str], Set[str], Tuple[str], str] + ) -> "RedisFilterExpression": + """Create a RedisTag equality filter expression. Args: - other (Union[List[str], str]): The tag(s) to filter on. + other (Union[List[str], Set[str], Tuple[str], str]): + The tag(s) to filter on. Example: >>> from langchain.vectorstores.redis import RedisTag @@ -149,11 +160,14 @@ def __eq__(self, other: Union[List[str], str]) -> "RedisFilterExpression": return RedisFilterExpression(str(self)) @check_operator_misuse - def __ne__(self, other: Union[List[str], str]) -> "RedisFilterExpression": - """Create a RedisTag inequality filter expression + def __ne__( + self, other: Union[List[str], Set[str], Tuple[str], str] + ) -> "RedisFilterExpression": + """Create a RedisTag inequality filter expression. Args: - other (Union[List[str], str]): The tag(s) to filter on. + other (Union[List[str], Set[str], Tuple[str], str]): + The tag(s) to filter on. Example: >>> from langchain.vectorstores.redis import RedisTag @@ -167,12 +181,10 @@ def _formatted_tag_value(self) -> str: return "|".join([self.escaper.escape(tag) for tag in self._value]) def __str__(self) -> str: + """Return the query syntax for a RedisTag filter expression.""" if not self._value: - raise ValueError( - f"Operator must be used before calling __str__. Operators are " - f"{self.OPERATORS.values()}" - ) - """Return the Redis Query syntax for a RedisTag filter expression""" + return "*" + return self.OPERATOR_MAP[self._operator] % ( self._field, self._formatted_tag_value, @@ -191,21 +203,19 @@ class RedisNum(RedisFilterField): RedisFilterOperator.GE: ">=", } OPERATOR_MAP: Dict[RedisFilterOperator, str] = { - RedisFilterOperator.EQ: "@%s:[%f %f]", - RedisFilterOperator.NE: "(-@%s:[%f %f])", - RedisFilterOperator.GT: "@%s:[(%f +inf]", - RedisFilterOperator.LT: "@%s:[-inf (%f]", - RedisFilterOperator.GE: "@%s:[%f +inf]", - RedisFilterOperator.LE: "@%s:[-inf %f]", + RedisFilterOperator.EQ: "@%s:[%s %s]", + RedisFilterOperator.NE: "(-@%s:[%s %s])", + RedisFilterOperator.GT: "@%s:[(%s +inf]", + RedisFilterOperator.LT: "@%s:[-inf (%s]", + RedisFilterOperator.GE: "@%s:[%s +inf]", + RedisFilterOperator.LE: "@%s:[-inf %s]", } + SUPPORTED_VAL_TYPES = (int, float, type(None)) def __str__(self) -> str: - """Return the Redis Query syntax for a Numeric filter expression""" + """Return the query syntax for a RedisNum filter expression.""" if not self._value: - raise ValueError( - f"Operator must be used before calling __str__. Operators are " - f"{self.OPERATORS.values()}" - ) + return "*" if ( self._operator == RedisFilterOperator.EQ @@ -221,102 +231,103 @@ def __str__(self) -> str: @check_operator_misuse def __eq__(self, other: Union[int, float]) -> "RedisFilterExpression": - """Create a Numeric equality filter expression + """Create a Numeric equality filter expression. Args: - other (Number): The value to filter on. + other (Union[int, float]): The value to filter on. Example: >>> from langchain.vectorstores.redis import RedisNum >>> filter = RedisNum("zipcode") == 90210 """ - self._set_value(other, Number, RedisFilterOperator.EQ) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.EQ) # type: ignore return RedisFilterExpression(str(self)) @check_operator_misuse def __ne__(self, other: Union[int, float]) -> "RedisFilterExpression": - """Create a Numeric inequality filter expression + """Create a Numeric inequality filter expression. Args: - other (Number): The value to filter on. + other (Union[int, float]): The value to filter on. Example: >>> from langchain.vectorstores.redis import RedisNum >>> filter = RedisNum("zipcode") != 90210 """ - self._set_value(other, Number, RedisFilterOperator.NE) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.NE) # type: ignore return RedisFilterExpression(str(self)) def __gt__(self, other: Union[int, float]) -> "RedisFilterExpression": - """Create a RedisNumeric greater than filter expression + """Create a Numeric greater than filter expression. Args: - other (Number): The value to filter on. + other (Union[int, float]): The value to filter on. Example: >>> from langchain.vectorstores.redis import RedisNum >>> filter = RedisNum("age") > 18 """ - self._set_value(other, Number, RedisFilterOperator.GT) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.GT) # type: ignore return RedisFilterExpression(str(self)) def __lt__(self, other: Union[int, float]) -> "RedisFilterExpression": - """Create a Numeric less than filter expression + """Create a Numeric less than filter expression. Args: - other (Number): The value to filter on. + other (Union[int, float]): The value to filter on. Example: >>> from langchain.vectorstores.redis import RedisNum >>> filter = RedisNum("age") < 18 """ - self._set_value(other, Number, RedisFilterOperator.LT) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.LT) # type: ignore return RedisFilterExpression(str(self)) def __ge__(self, other: Union[int, float]) -> "RedisFilterExpression": - """Create a Numeric greater than or equal to filter expression + """Create a Numeric greater than or equal to filter expression. Args: - other (Number): The value to filter on. + other (Union[int, float]): The value to filter on. Example: >>> from langchain.vectorstores.redis import RedisNum >>> filter = RedisNum("age") >= 18 """ - self._set_value(other, Number, RedisFilterOperator.GE) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.GE) # type: ignore return RedisFilterExpression(str(self)) def __le__(self, other: Union[int, float]) -> "RedisFilterExpression": - """Create a Numeric less than or equal to filter expression + """Create a Numeric less than or equal to filter expression. Args: - other (Number): The value to filter on. + other (Union[int, float]): The value to filter on. Example: >>> from langchain.vectorstores.redis import RedisNum >>> filter = RedisNum("age") <= 18 """ - self._set_value(other, Number, RedisFilterOperator.LE) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.LE) # type: ignore return RedisFilterExpression(str(self)) class RedisText(RedisFilterField): """A RedisFilterField representing a text field in a Redis index.""" - OPERATORS = { + OPERATORS: Dict[RedisFilterOperator, str] = { RedisFilterOperator.EQ: "==", RedisFilterOperator.NE: "!=", RedisFilterOperator.LIKE: "%", } - OPERATOR_MAP = { - RedisFilterOperator.EQ: '@%s:"%s"', + OPERATOR_MAP: Dict[RedisFilterOperator, str] = { + RedisFilterOperator.EQ: '@%s:("%s")', RedisFilterOperator.NE: '(-@%s:"%s")', - RedisFilterOperator.LIKE: "@%s:%s", + RedisFilterOperator.LIKE: "@%s:(%s)", } + SUPPORTED_VAL_TYPES = (str, type(None)) @check_operator_misuse def __eq__(self, other: str) -> "RedisFilterExpression": - """Create a RedisText equality filter expression + """Create a RedisText equality (exact match) filter expression. Args: other (str): The text value to filter on. @@ -325,12 +336,12 @@ def __eq__(self, other: str) -> "RedisFilterExpression": >>> from langchain.vectorstores.redis import RedisText >>> filter = RedisText("job") == "engineer" """ - self._set_value(other, str, RedisFilterOperator.EQ) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.EQ) # type: ignore return RedisFilterExpression(str(self)) @check_operator_misuse def __ne__(self, other: str) -> "RedisFilterExpression": - """Create a RedisText inequality filter expression + """Create a RedisText inequality filter expression. Args: other (str): The text value to filter on. @@ -339,33 +350,34 @@ def __ne__(self, other: str) -> "RedisFilterExpression": >>> from langchain.vectorstores.redis import RedisText >>> filter = RedisText("job") != "engineer" """ - self._set_value(other, str, RedisFilterOperator.NE) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.NE) # type: ignore return RedisFilterExpression(str(self)) def __mod__(self, other: str) -> "RedisFilterExpression": - """Create a RedisText like filter expression + """Create a RedisText "LIKE" filter expression. Args: other (str): The text value to filter on. Example: >>> from langchain.vectorstores.redis import RedisText - >>> filter = RedisText("job") % "engineer" + >>> filter = RedisText("job") % "engine*" # suffix wild card match + >>> filter = RedisText("job") % "%%engine%%" # fuzzy match w/ LD + >>> filter = RedisText("job") % "engineer|doctor" # contains either term + >>> filter = RedisText("job") % "engineer doctor" # contains both terms """ - self._set_value(other, str, RedisFilterOperator.LIKE) + self._set_value(other, self.SUPPORTED_VAL_TYPES, RedisFilterOperator.LIKE) # type: ignore return RedisFilterExpression(str(self)) def __str__(self) -> str: + """Return the query syntax for a RedisText filter expression.""" if not self._value: - raise ValueError( - f"Operator must be used before calling __str__. Operators are " - f"{self.OPERATORS.values()}" - ) + return "*" - try: - return self.OPERATOR_MAP[self._operator] % (self._field, self._value) - except KeyError: - raise Exception("Invalid operator") + return self.OPERATOR_MAP[self._operator] % ( + self._field, + self._value, + ) class RedisFilterExpression: @@ -413,16 +425,36 @@ def __or__(self, other: "RedisFilterExpression") -> "RedisFilterExpression": operator=RedisFilterOperator.OR, left=self, right=other ) + @staticmethod + def format_expression( + left: "RedisFilterExpression", right: "RedisFilterExpression", operator_str: str + ) -> str: + _left, _right = str(left), str(right) + if _left == _right == "*": + return _left + if _left == "*" != _right: + return _right + if _right == "*" != _left: + return _left + return f"({_left}{operator_str}{_right})" + def __str__(self) -> str: # top level check that allows recursive calls to __str__ if not self._filter and not self._operator: raise ValueError("Improperly initialized RedisFilterExpression") - # allow for single filter expression without operators as last - # expression in the chain might not have an operator + # if there's an operator, combine expressions accordingly if self._operator: + if not isinstance(self._left, RedisFilterExpression) or not isinstance( + self._right, RedisFilterExpression + ): + raise TypeError( + "Improper combination of filters." + "Both left and right should be type FilterExpression" + ) + operator_str = " | " if self._operator == RedisFilterOperator.OR else " " - return f"({str(self._left)}{operator_str}{str(self._right)})" + return self.format_expression(self._left, self._right, operator_str) # check that base case, the filter is set if not self._filter: diff --git a/libs/langchain/tests/unit_tests/vectorstores/redis/test_filters.py b/libs/langchain/tests/unit_tests/vectorstores/redis/test_filters.py new file mode 100644 index 0000000000000..c12c900f4aa65 --- /dev/null +++ b/libs/langchain/tests/unit_tests/vectorstores/redis/test_filters.py @@ -0,0 +1,193 @@ +from typing import Any + +import pytest + +from langchain.vectorstores.redis import ( + RedisNum as Num, +) +from langchain.vectorstores.redis import ( + RedisTag as Tag, +) +from langchain.vectorstores.redis import ( + RedisText as Text, +) + + +# Test cases for various tag scenarios +@pytest.mark.parametrize( + "operation,tags,expected", + [ + # Testing single tags + ("==", "simpletag", "@tag_field:{simpletag}"), + ( + "==", + "tag with space", + "@tag_field:{tag\\ with\\ space}", + ), # Escaping spaces within quotes + ( + "==", + "special$char", + "@tag_field:{special\\$char}", + ), # Escaping a special character + ("!=", "negated", "(-@tag_field:{negated})"), + # Testing multiple tags + ("==", ["tag1", "tag2"], "@tag_field:{tag1|tag2}"), + ( + "==", + ["alpha", "beta with space", "gamma$special"], + "@tag_field:{alpha|beta\\ with\\ space|gamma\\$special}", + ), # Multiple tags with spaces and special chars + ("!=", ["tagA", "tagB"], "(-@tag_field:{tagA|tagB})"), + # Complex tag scenarios with special characters + ("==", "weird:tag", "@tag_field:{weird\\:tag}"), # Tags with colon + ("==", "tag&another", "@tag_field:{tag\\&another}"), # Tags with ampersand + # Escaping various special characters within tags + ("==", "tag/with/slashes", "@tag_field:{tag\\/with\\/slashes}"), + ( + "==", + ["hyphen-tag", "under_score", "dot.tag"], + "@tag_field:{hyphen\\-tag|under_score|dot\\.tag}", + ), + # ...additional unique cases as desired... + ], +) +def test_tag_filter_varied(operation: str, tags: str, expected: str) -> None: + if operation == "==": + tf = Tag("tag_field") == tags + elif operation == "!=": + tf = Tag("tag_field") != tags + else: + raise ValueError(f"Unsupported operation: {operation}") + + # Verify the string representation matches the expected RediSearch query part + assert str(tf) == expected + + +@pytest.mark.parametrize( + "value, expected", + [ + (None, "*"), + ([], "*"), + ("", "*"), + ([None], "*"), + ([None, "tag"], "@tag_field:{tag}"), + ], + ids=[ + "none", + "empty_list", + "empty_string", + "list_with_none", + "list_with_none_and_tag", + ], +) +def test_nullable_tags(value: Any, expected: str) -> None: + tag = Tag("tag_field") + assert str(tag == value) == expected + + +@pytest.mark.parametrize( + "operation, value, expected", + [ + ("__eq__", 5, "@numeric_field:[5 5]"), + ("__ne__", 5, "(-@numeric_field:[5 5])"), + ("__gt__", 5, "@numeric_field:[(5 +inf]"), + ("__ge__", 5, "@numeric_field:[5 +inf]"), + ("__lt__", 5.55, "@numeric_field:[-inf (5.55]"), + ("__le__", 5, "@numeric_field:[-inf 5]"), + ("__le__", None, "*"), + ("__eq__", None, "*"), + ("__ne__", None, "*"), + ], + ids=["eq", "ne", "gt", "ge", "lt", "le", "le_none", "eq_none", "ne_none"], +) +def test_numeric_filter(operation: str, value: Any, expected: str) -> None: + nf = Num("numeric_field") + assert str(getattr(nf, operation)(value)) == expected + + +@pytest.mark.parametrize( + "operation, value, expected", + [ + ("__eq__", "text", '@text_field:("text")'), + ("__ne__", "text", '(-@text_field:"text")'), + ("__eq__", "", "*"), + ("__ne__", "", "*"), + ("__eq__", None, "*"), + ("__ne__", None, "*"), + ("__mod__", "text", "@text_field:(text)"), + ("__mod__", "tex*", "@text_field:(tex*)"), + ("__mod__", "%text%", "@text_field:(%text%)"), + ("__mod__", "", "*"), + ("__mod__", None, "*"), + ], + ids=[ + "eq", + "ne", + "eq-empty", + "ne-empty", + "eq-none", + "ne-none", + "like", + "like_wildcard", + "like_full", + "like_empty", + "like_none", + ], +) +def test_text_filter(operation: str, value: Any, expected: str) -> None: + txt_f = getattr(Text("text_field"), operation)(value) + assert str(txt_f) == expected + + +def test_filters_combination() -> None: + tf1 = Tag("tag_field") == ["tag1", "tag2"] + tf2 = Tag("tag_field") == "tag3" + combined = tf1 & tf2 + assert str(combined) == "(@tag_field:{tag1|tag2} @tag_field:{tag3})" + + combined = tf1 | tf2 + assert str(combined) == "(@tag_field:{tag1|tag2} | @tag_field:{tag3})" + + tf1 = Tag("tag_field") == [] + assert str(tf1) == "*" + assert str(tf1 & tf2) == str(tf2) + assert str(tf1 | tf2) == str(tf2) + + # test combining filters with None values and empty strings + tf1 = Tag("tag_field") == None # noqa: E711 + tf2 = Tag("tag_field") == "" + assert str(tf1 & tf2) == "*" + + tf1 = Tag("tag_field") == None # noqa: E711 + tf2 = Tag("tag_field") == "tag" + assert str(tf1 & tf2) == str(tf2) + + tf1 = Tag("tag_field") == None # noqa: E711 + tf2 = Tag("tag_field") == ["tag1", "tag2"] + assert str(tf1 & tf2) == str(tf2) + + tf1 = Tag("tag_field") == None # noqa: E711 + tf2 = Tag("tag_field") != None # noqa: E711 + assert str(tf1 & tf2) == "*" + + tf1 = Tag("tag_field") == "" + tf2 = Tag("tag_field") == "tag" + tf3 = Tag("tag_field") == ["tag1", "tag2"] + assert str(tf1 & tf2 & tf3) == str(tf2 & tf3) + + # test none filters for Tag Num Text + tf1 = Tag("tag_field") == None # noqa: E711 + tf2 = Num("num_field") == None # noqa: E711 + tf3 = Text("text_field") == None # noqa: E711 + assert str(tf1 & tf2 & tf3) == "*" + + tf1 = Tag("tag_field") != None # noqa: E711 + tf2 = Num("num_field") != None # noqa: E711 + tf3 = Text("text_field") != None # noqa: E711 + assert str(tf1 & tf2 & tf3) == "*" + + # test combinations of real and None filters + tf1 = Tag("tag_field") == "tag" + tf2 = Num("num_field") == None # noqa: E711 + tf3 = Text("text_field") == None # noqa: E711 + assert str(tf1 & tf2 & tf3) == str(tf1)