diff --git a/pinecone/__init__.py b/pinecone/__init__.py index 255ce43db..1b13ae999 100644 --- a/pinecone/__init__.py +++ b/pinecone/__init__.py @@ -59,6 +59,7 @@ "UpdateRequest": ("pinecone.db_data.models", "UpdateRequest"), "NamespaceDescription": ("pinecone.core.openapi.db_data.models", "NamespaceDescription"), "ImportErrorMode": ("pinecone.db_data.resources.sync.bulk_import", "ImportErrorMode"), + "FilterBuilder": ("pinecone.db_data.filter_builder", "FilterBuilder"), "VectorDictionaryMissingKeysError": ( "pinecone.db_data.errors", "VectorDictionaryMissingKeysError", diff --git a/pinecone/db_data/filter_builder.py b/pinecone/db_data/filter_builder.py new file mode 100644 index 000000000..a26e03f80 --- /dev/null +++ b/pinecone/db_data/filter_builder.py @@ -0,0 +1,390 @@ +from typing import Dict, List, Union, Any, cast +from .types.query_filter import FilterTypedDict, FieldValue, NumericFieldValue, SimpleFilter + + +class FilterBuilder: + """ + A fluent builder for constructing Pinecone metadata filters. + + The FilterBuilder helps prevent common filter construction errors such as + misspelled operator names or invalid filter structures. It supports all + Pinecone filter operators and provides operator overloading for combining + conditions with AND (``&``) and OR (``|``) logic. + + Examples: + + .. code-block:: python + + # Simple equality filter + filter1 = FilterBuilder().eq("genre", "drama").build() + # Returns: {"genre": "drama"} + + # Multiple conditions with AND using & operator + filter2 = (FilterBuilder().eq("genre", "drama") & + FilterBuilder().gt("year", 2020)).build() + # Returns: {"$and": [{"genre": "drama"}, {"year": {"$gt": 2020}}]} + + # Multiple conditions with OR using | operator + filter3 = (FilterBuilder().eq("genre", "comedy") | + FilterBuilder().eq("genre", "drama")).build() + # Returns: {"$or": [{"genre": "comedy"}, {"genre": "drama"}]} + + # Complex nested conditions + filter4 = ((FilterBuilder().eq("genre", "drama") & + FilterBuilder().gt("year", 2020)) | + (FilterBuilder().eq("genre", "comedy") & + FilterBuilder().lt("year", 2000))).build() + + # Using $exists + filter5 = FilterBuilder().exists("genre", True).build() + # Returns: {"genre": {"$exists": True}} + + """ + + def __init__(self, filter_dict: Union[SimpleFilter, Dict[str, Any], None] = None) -> None: + """ + Initialize a FilterBuilder. + + Args: + filter_dict: Optional initial filter dictionary. Used internally + for combining filters with operators. + """ + self._filter: Union[SimpleFilter, Dict[str, Any], None] = filter_dict + + def eq(self, field: str, value: FieldValue) -> "FilterBuilder": + """ + Add an equality condition. + + Matches records where the specified field equals the given value. + + Args: + field: The metadata field name. + value: The value to match. Can be str, int, float, or bool. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().eq("genre", "drama").build() + # Returns: {"genre": "drama"} + """ + return FilterBuilder({field: value}) + + def ne(self, field: str, value: FieldValue) -> "FilterBuilder": + """ + Add a not-equal condition. + + Matches records where the specified field does not equal the given value. + + Args: + field: The metadata field name. + value: The value to exclude. Can be str, int, float, or bool. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().ne("genre", "drama").build() + # Returns: {"genre": {"$ne": "drama"}} + """ + return FilterBuilder({field: {"$ne": value}}) + + def gt(self, field: str, value: NumericFieldValue) -> "FilterBuilder": + """ + Add a greater-than condition. + + Matches records where the specified numeric field is greater than + the given value. + + Args: + field: The metadata field name. + value: The numeric value to compare against. Must be int or float. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().gt("year", 2020).build() + # Returns: {"year": {"$gt": 2020}} + """ + return FilterBuilder({field: {"$gt": value}}) + + def gte(self, field: str, value: NumericFieldValue) -> "FilterBuilder": + """ + Add a greater-than-or-equal condition. + + Matches records where the specified numeric field is greater than + or equal to the given value. + + Args: + field: The metadata field name. + value: The numeric value to compare against. Must be int or float. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().gte("year", 2020).build() + # Returns: {"year": {"$gte": 2020}} + """ + return FilterBuilder({field: {"$gte": value}}) + + def lt(self, field: str, value: NumericFieldValue) -> "FilterBuilder": + """ + Add a less-than condition. + + Matches records where the specified numeric field is less than + the given value. + + Args: + field: The metadata field name. + value: The numeric value to compare against. Must be int or float. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().lt("year", 2000).build() + # Returns: {"year": {"$lt": 2000}} + """ + return FilterBuilder({field: {"$lt": value}}) + + def lte(self, field: str, value: NumericFieldValue) -> "FilterBuilder": + """ + Add a less-than-or-equal condition. + + Matches records where the specified numeric field is less than + or equal to the given value. + + Args: + field: The metadata field name. + value: The numeric value to compare against. Must be int or float. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().lte("year", 2000).build() + # Returns: {"year": {"$lte": 2000}} + """ + return FilterBuilder({field: {"$lte": value}}) + + def in_(self, field: str, values: List[FieldValue]) -> "FilterBuilder": + """ + Add an in-list condition. + + Matches records where the specified field's value is in the given list. + + Args: + field: The metadata field name. + values: List of values to match against. Each value can be + str, int, float, or bool. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().in_("genre", ["comedy", "drama"]).build() + # Returns: {"genre": {"$in": ["comedy", "drama"]}} + """ + return FilterBuilder({field: {"$in": values}}) + + def nin(self, field: str, values: List[FieldValue]) -> "FilterBuilder": + """ + Add a not-in-list condition. + + Matches records where the specified field's value is not in the + given list. + + Args: + field: The metadata field name. + values: List of values to exclude. Each value can be + str, int, float, or bool. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().nin("genre", ["comedy", "drama"]).build() + # Returns: {"genre": {"$nin": ["comedy", "drama"]}} + """ + return FilterBuilder({field: {"$nin": values}}) + + def exists(self, field: str, exists: bool) -> "FilterBuilder": + """ + Add an exists condition. + + Matches records where the specified field exists (or does not exist) + in the metadata. + + Args: + field: The metadata field name. + exists: True to match records where the field exists, + False to match records where the field does not exist. + + Returns: + A new FilterBuilder instance with this condition added. + + Examples: + + .. code-block:: python + + FilterBuilder().exists("genre", True).build() + # Returns: {"genre": {"$exists": True}} + """ + return FilterBuilder({field: {"$exists": exists}}) + + def __and__(self, other: "FilterBuilder") -> "FilterBuilder": + """ + Combine two FilterBuilder instances with AND logic. + + This method is called when using the ``&`` operator between two + FilterBuilder instances. + + Args: + other: Another FilterBuilder instance to combine with. + + Returns: + A new FilterBuilder instance combining both conditions with AND. + + Examples: + + .. code-block:: python + + (FilterBuilder().eq("genre", "drama") & + FilterBuilder().gt("year", 2020)).build() + # Returns: {"$and": [{"genre": "drama"}, {"year": {"$gt": 2020}}]} + """ + left_condition = self._get_filter_condition() + right_condition = other._get_filter_condition() + + # If both sides are already $and, merge their conditions + left_has_and = isinstance(self._filter, dict) and "$and" in self._filter + right_has_and = isinstance(other._filter, dict) and "$and" in other._filter + + if left_has_and and right_has_and: + left_and_dict = cast(Dict[str, List[Any]], self._filter) + right_and_dict = cast(Dict[str, List[Any]], other._filter) + conditions = left_and_dict["$and"] + right_and_dict["$and"] + return FilterBuilder({"$and": conditions}) + + # If either side is already an $and, merge the conditions + if left_has_and: + and_dict = cast(Dict[str, List[Any]], self._filter) + conditions = and_dict["$and"] + [right_condition] + return FilterBuilder({"$and": conditions}) + if right_has_and: + and_dict = cast(Dict[str, List[Any]], other._filter) + conditions = [left_condition] + and_dict["$and"] + return FilterBuilder({"$and": conditions}) + return FilterBuilder({"$and": [left_condition, right_condition]}) + + def __or__(self, other: "FilterBuilder") -> "FilterBuilder": + """ + Combine two FilterBuilder instances with OR logic. + + This method is called when using the ``|`` operator between two + FilterBuilder instances. + + Args: + other: Another FilterBuilder instance to combine with. + + Returns: + A new FilterBuilder instance combining both conditions with OR. + + Examples: + + .. code-block:: python + + (FilterBuilder().eq("genre", "comedy") | + FilterBuilder().eq("genre", "drama")).build() + # Returns: {"$or": [{"genre": "comedy"}, {"genre": "drama"}]} + """ + left_condition = self._get_filter_condition() + right_condition = other._get_filter_condition() + + # If both sides are already $or, merge their conditions + left_has_or = isinstance(self._filter, dict) and "$or" in self._filter + right_has_or = isinstance(other._filter, dict) and "$or" in other._filter + + if left_has_or and right_has_or: + left_or_dict = cast(Dict[str, List[Any]], self._filter) + right_or_dict = cast(Dict[str, List[Any]], other._filter) + conditions = left_or_dict["$or"] + right_or_dict["$or"] + return FilterBuilder({"$or": conditions}) + + # If either side is already an $or, merge the conditions + if left_has_or: + or_dict = cast(Dict[str, List[Any]], self._filter) + conditions = or_dict["$or"] + [right_condition] + return FilterBuilder({"$or": conditions}) + if right_has_or: + or_dict = cast(Dict[str, List[Any]], other._filter) + conditions = [left_condition] + or_dict["$or"] + return FilterBuilder({"$or": conditions}) + return FilterBuilder({"$or": [left_condition, right_condition]}) + + def _get_filter_condition(self) -> Union[SimpleFilter, Dict[str, Any]]: + """ + Get the filter condition representation of this builder. + + Returns either a SimpleFilter for single conditions, or the full + $and/$or structure for compound filters. This allows nesting + of $and/$or structures even though the type system doesn't + perfectly support it. + + Returns: + A filter condition (SimpleFilter or compound structure). + """ + if self._filter is None: + raise ValueError("FilterBuilder must have at least one condition") + return self._filter + + def build(self) -> FilterTypedDict: + """ + Build and return the final filter dictionary. + + Returns: + A FilterTypedDict that can be used with Pinecone query methods. + Note: The return type may be more permissive than FilterTypedDict + to support nested $and/$or structures that Pinecone accepts. + + Raises: + ValueError: If the builder has no conditions. + + Examples: + + .. code-block:: python + + filter_dict = FilterBuilder().eq("genre", "drama").build() + index.query(vector=embedding, top_k=10, filter=filter_dict) + """ + if self._filter is None: + raise ValueError("FilterBuilder must have at least one condition") + # Type cast to FilterTypedDict - the actual structure may support + # nested $and/$or even though the type system doesn't fully capture it + return self._filter # type: ignore[return-value] diff --git a/tests/unit/data/test_filter_builder.py b/tests/unit/data/test_filter_builder.py new file mode 100644 index 000000000..163139a47 --- /dev/null +++ b/tests/unit/data/test_filter_builder.py @@ -0,0 +1,420 @@ +import pytest +from pinecone.db_data.filter_builder import FilterBuilder + + +class TestFilterBuilderSimpleFilters: + """Test simple single-condition filters.""" + + def test_eq_string(self): + """Test equality filter with string value.""" + result = FilterBuilder().eq("genre", "drama").build() + assert result == {"genre": "drama"} + + def test_eq_int(self): + """Test equality filter with integer value.""" + result = FilterBuilder().eq("year", 2020).build() + assert result == {"year": 2020} + + def test_eq_float(self): + """Test equality filter with float value.""" + result = FilterBuilder().eq("rating", 4.5).build() + assert result == {"rating": 4.5} + + def test_eq_bool(self): + """Test equality filter with boolean value.""" + result = FilterBuilder().eq("active", True).build() + assert result == {"active": True} + + def test_ne_string(self): + """Test not-equal filter with string value.""" + result = FilterBuilder().ne("genre", "comedy").build() + assert result == {"genre": {"$ne": "comedy"}} + + def test_ne_int(self): + """Test not-equal filter with integer value.""" + result = FilterBuilder().ne("year", 2019).build() + assert result == {"year": {"$ne": 2019}} + + def test_gt_int(self): + """Test greater-than filter with integer value.""" + result = FilterBuilder().gt("year", 2020).build() + assert result == {"year": {"$gt": 2020}} + + def test_gt_float(self): + """Test greater-than filter with float value.""" + result = FilterBuilder().gt("rating", 4.0).build() + assert result == {"rating": {"$gt": 4.0}} + + def test_gte_int(self): + """Test greater-than-or-equal filter with integer value.""" + result = FilterBuilder().gte("year", 2020).build() + assert result == {"year": {"$gte": 2020}} + + def test_gte_float(self): + """Test greater-than-or-equal filter with float value.""" + result = FilterBuilder().gte("rating", 4.5).build() + assert result == {"rating": {"$gte": 4.5}} + + def test_lt_int(self): + """Test less-than filter with integer value.""" + result = FilterBuilder().lt("year", 2000).build() + assert result == {"year": {"$lt": 2000}} + + def test_lt_float(self): + """Test less-than filter with float value.""" + result = FilterBuilder().lt("rating", 3.0).build() + assert result == {"rating": {"$lt": 3.0}} + + def test_lte_int(self): + """Test less-than-or-equal filter with integer value.""" + result = FilterBuilder().lte("year", 2000).build() + assert result == {"year": {"$lte": 2000}} + + def test_lte_float(self): + """Test less-than-or-equal filter with float value.""" + result = FilterBuilder().lte("rating", 3.5).build() + assert result == {"rating": {"$lte": 3.5}} + + def test_in_strings(self): + """Test in-list filter with string values.""" + result = FilterBuilder().in_("genre", ["comedy", "drama", "action"]).build() + assert result == {"genre": {"$in": ["comedy", "drama", "action"]}} + + def test_in_ints(self): + """Test in-list filter with integer values.""" + result = FilterBuilder().in_("year", [2019, 2020, 2021]).build() + assert result == {"year": {"$in": [2019, 2020, 2021]}} + + def test_in_mixed(self): + """Test in-list filter with mixed value types.""" + result = FilterBuilder().in_("value", ["string", 42, 3.14, True]).build() + assert result == {"value": {"$in": ["string", 42, 3.14, True]}} + + def test_nin_strings(self): + """Test not-in-list filter with string values.""" + result = FilterBuilder().nin("genre", ["comedy", "drama"]).build() + assert result == {"genre": {"$nin": ["comedy", "drama"]}} + + def test_nin_ints(self): + """Test not-in-list filter with integer values.""" + result = FilterBuilder().nin("year", [2019, 2020]).build() + assert result == {"year": {"$nin": [2019, 2020]}} + + def test_exists_true(self): + """Test exists filter with True.""" + result = FilterBuilder().exists("genre", True).build() + assert result == {"genre": {"$exists": True}} + + def test_exists_false(self): + """Test exists filter with False.""" + result = FilterBuilder().exists("genre", False).build() + assert result == {"genre": {"$exists": False}} + + +class TestFilterBuilderAndOperator: + """Test AND operator overloading.""" + + def test_and_two_conditions(self): + """Test combining two conditions with AND.""" + result = (FilterBuilder().eq("genre", "drama") & FilterBuilder().gt("year", 2020)).build() + assert result == {"$and": [{"genre": "drama"}, {"year": {"$gt": 2020}}]} + + def test_and_three_conditions(self): + """Test combining three conditions with AND.""" + f1 = FilterBuilder().eq("genre", "drama") + f2 = FilterBuilder().gt("year", 2020) + f3 = FilterBuilder().lt("rating", 5.0) + result = ((f1 & f2) & f3).build() + assert result == { + "$and": [{"genre": "drama"}, {"year": {"$gt": 2020}}, {"rating": {"$lt": 5.0}}] + } + + def test_and_merge_existing_and(self): + """Test merging with existing $and structure.""" + f1 = FilterBuilder().eq("genre", "drama") & FilterBuilder().gt("year", 2020) + f2 = FilterBuilder().lt("rating", 5.0) + result = (f1 & f2).build() + assert result == { + "$and": [{"genre": "drama"}, {"year": {"$gt": 2020}}, {"rating": {"$lt": 5.0}}] + } + + def test_and_merge_both_sides(self): + """Test merging when both sides have $and.""" + f1 = FilterBuilder().eq("genre", "drama") & FilterBuilder().gt("year", 2020) + f2 = FilterBuilder().lt("rating", 5.0) & FilterBuilder().exists("active", True) + result = (f1 & f2).build() + assert result == { + "$and": [ + {"genre": "drama"}, + {"year": {"$gt": 2020}}, + {"rating": {"$lt": 5.0}}, + {"active": {"$exists": True}}, + ] + } + + def test_and_chained(self): + """Test chained AND operations.""" + result = ( + FilterBuilder().eq("genre", "drama") + & FilterBuilder().gt("year", 2020) + & FilterBuilder().lt("rating", 5.0) + ).build() + assert result == { + "$and": [{"genre": "drama"}, {"year": {"$gt": 2020}}, {"rating": {"$lt": 5.0}}] + } + + +class TestFilterBuilderOrOperator: + """Test OR operator overloading.""" + + def test_or_two_conditions(self): + """Test combining two conditions with OR.""" + result = ( + FilterBuilder().eq("genre", "comedy") | FilterBuilder().eq("genre", "drama") + ).build() + assert result == {"$or": [{"genre": "comedy"}, {"genre": "drama"}]} + + def test_or_three_conditions(self): + """Test combining three conditions with OR.""" + f1 = FilterBuilder().eq("genre", "comedy") + f2 = FilterBuilder().eq("genre", "drama") + f3 = FilterBuilder().eq("genre", "action") + result = ((f1 | f2) | f3).build() + assert result == {"$or": [{"genre": "comedy"}, {"genre": "drama"}, {"genre": "action"}]} + + def test_or_merge_existing_or(self): + """Test merging with existing $or structure.""" + f1 = FilterBuilder().eq("genre", "comedy") | FilterBuilder().eq("genre", "drama") + f2 = FilterBuilder().eq("genre", "action") + result = (f1 | f2).build() + assert result == {"$or": [{"genre": "comedy"}, {"genre": "drama"}, {"genre": "action"}]} + + def test_or_merge_both_sides(self): + """Test merging when both sides have $or.""" + f1 = FilterBuilder().eq("genre", "comedy") | FilterBuilder().eq("genre", "drama") + f2 = FilterBuilder().eq("genre", "action") | FilterBuilder().eq("genre", "thriller") + result = (f1 | f2).build() + assert result == { + "$or": [ + {"genre": "comedy"}, + {"genre": "drama"}, + {"genre": "action"}, + {"genre": "thriller"}, + ] + } + + def test_or_chained(self): + """Test chained OR operations.""" + result = ( + FilterBuilder().eq("genre", "comedy") + | FilterBuilder().eq("genre", "drama") + | FilterBuilder().eq("genre", "action") + ).build() + assert result == {"$or": [{"genre": "comedy"}, {"genre": "drama"}, {"genre": "action"}]} + + +class TestFilterBuilderComplexNested: + """Test complex nested filter structures.""" + + def test_nested_and_or(self): + """Test nested AND and OR operations.""" + # (genre == "drama" AND year > 2020) OR (genre == "comedy" AND year < 2000) + result = ( + (FilterBuilder().eq("genre", "drama") & FilterBuilder().gt("year", 2020)) + | (FilterBuilder().eq("genre", "comedy") & FilterBuilder().lt("year", 2000)) + ).build() + assert result == { + "$or": [ + {"$and": [{"genre": "drama"}, {"year": {"$gt": 2020}}]}, + {"$and": [{"genre": "comedy"}, {"year": {"$lt": 2000}}]}, + ] + } + + def test_nested_or_and(self): + """Test nested OR and AND operations.""" + # (genre == "drama" OR genre == "comedy") AND year > 2020 + result = ( + (FilterBuilder().eq("genre", "drama") | FilterBuilder().eq("genre", "comedy")) + & FilterBuilder().gt("year", 2020) + ).build() + assert result == { + "$and": [{"$or": [{"genre": "drama"}, {"genre": "comedy"}]}, {"year": {"$gt": 2020}}] + } + + def test_deeply_nested(self): + """Test deeply nested filter structure.""" + # ((a AND b) OR (c AND d)) AND e + a = FilterBuilder().eq("field1", "value1") + b = FilterBuilder().eq("field2", "value2") + c = FilterBuilder().eq("field3", "value3") + d = FilterBuilder().eq("field4", "value4") + e = FilterBuilder().eq("field5", "value5") + + result = (((a & b) | (c & d)) & e).build() + assert result == { + "$and": [ + { + "$or": [ + {"$and": [{"field1": "value1"}, {"field2": "value2"}]}, + {"$and": [{"field3": "value3"}, {"field4": "value4"}]}, + ] + }, + {"field5": "value5"}, + ] + } + + def test_mixed_operators(self): + """Test mixing different operators in nested structure.""" + result = ( + ( + FilterBuilder().eq("genre", "drama") + & FilterBuilder().gt("year", 2020) + & FilterBuilder().in_("tags", ["award-winning", "critically-acclaimed"]) + ) + | ( + FilterBuilder().eq("genre", "comedy") + & FilterBuilder().lt("year", 2000) + & FilterBuilder().exists("rating", True) + ) + ).build() + assert result == { + "$or": [ + { + "$and": [ + {"genre": "drama"}, + {"year": {"$gt": 2020}}, + {"tags": {"$in": ["award-winning", "critically-acclaimed"]}}, + ] + }, + { + "$and": [ + {"genre": "comedy"}, + {"year": {"$lt": 2000}}, + {"rating": {"$exists": True}}, + ] + }, + ] + } + + +class TestFilterBuilderEdgeCases: + """Test edge cases and error conditions.""" + + def test_empty_build_raises_error(self): + """Test that building an empty filter raises ValueError.""" + builder = FilterBuilder() + with pytest.raises(ValueError, match="FilterBuilder must have at least one condition"): + builder.build() + + def test_single_condition(self): + """Test that a single condition works correctly.""" + result = FilterBuilder().eq("genre", "drama").build() + assert result == {"genre": "drama"} + + def test_empty_list_in(self): + """Test in-list with empty list.""" + result = FilterBuilder().in_("genre", []).build() + assert result == {"genre": {"$in": []}} + + def test_empty_list_nin(self): + """Test not-in-list with empty list.""" + result = FilterBuilder().nin("genre", []).build() + assert result == {"genre": {"$nin": []}} + + def test_single_item_list_in(self): + """Test in-list with single item.""" + result = FilterBuilder().in_("genre", ["drama"]).build() + assert result == {"genre": {"$in": ["drama"]}} + + def test_large_list_in(self): + """Test in-list with many items.""" + items = [f"item{i}" for i in range(100)] + result = FilterBuilder().in_("field", items).build() + assert result == {"field": {"$in": items}} + + def test_all_value_types(self): + """Test all supported value types.""" + result = FilterBuilder().eq("str_field", "string").build() + assert result == {"str_field": "string"} + + result = FilterBuilder().eq("int_field", 42).build() + assert result == {"int_field": 42} + + result = FilterBuilder().eq("float_field", 3.14).build() + assert result == {"float_field": 3.14} + + result = FilterBuilder().eq("bool_field", True).build() + assert result == {"bool_field": True} + + def test_numeric_operators_with_float(self): + """Test numeric operators accept float values.""" + result = FilterBuilder().gt("rating", 4.5).build() + assert result == {"rating": {"$gt": 4.5}} + + result = FilterBuilder().gte("rating", 4.5).build() + assert result == {"rating": {"$gte": 4.5}} + + result = FilterBuilder().lt("rating", 3.5).build() + assert result == {"rating": {"$lt": 3.5}} + + result = FilterBuilder().lte("rating", 3.5).build() + assert result == {"rating": {"$lte": 3.5}} + + def test_numeric_operators_with_int(self): + """Test numeric operators accept int values.""" + result = FilterBuilder().gt("year", 2020).build() + assert result == {"year": {"$gt": 2020}} + + result = FilterBuilder().gte("year", 2020).build() + assert result == {"year": {"$gte": 2020}} + + result = FilterBuilder().lt("year", 2000).build() + assert result == {"year": {"$lt": 2000}} + + result = FilterBuilder().lte("year", 2000).build() + assert result == {"year": {"$lte": 2000}} + + +class TestFilterBuilderRealWorldExamples: + """Test real-world filter examples.""" + + def test_movie_search_example(self): + """Example: Find movies that are dramas from 2020 or later, or comedies from before 2000.""" + result = ( + (FilterBuilder().eq("genre", "drama") & FilterBuilder().gte("year", 2020)) + | (FilterBuilder().eq("genre", "comedy") & FilterBuilder().lt("year", 2000)) + ).build() + assert result == { + "$or": [ + {"$and": [{"genre": "drama"}, {"year": {"$gte": 2020}}]}, + {"$and": [{"genre": "comedy"}, {"year": {"$lt": 2000}}]}, + ] + } + + def test_product_search_example(self): + """Example: Find products in certain categories with price range.""" + result = ( + FilterBuilder().in_("category", ["electronics", "computers"]) + & FilterBuilder().gte("price", 100.0) + & FilterBuilder().lte("price", 1000.0) + ).build() + assert result == { + "$and": [ + {"category": {"$in": ["electronics", "computers"]}}, + {"price": {"$gte": 100.0}}, + {"price": {"$lte": 1000.0}}, + ] + } + + def test_exclude_certain_values_example(self): + """Example: Exclude certain values and require existence of a field.""" + result = ( + FilterBuilder().nin("status", ["deleted", "archived"]) + & FilterBuilder().exists("published_at", True) + ).build() + assert result == { + "$and": [ + {"status": {"$nin": ["deleted", "archived"]}}, + {"published_at": {"$exists": True}}, + ] + }