diff --git a/eland/filter.py b/eland/filter.py index 7cc9698a..1da828d4 100644 --- a/eland/filter.py +++ b/eland/filter.py @@ -90,8 +90,16 @@ def __init__(self, *args: BooleanFilter) -> None: class NotFilter(BooleanFilter): def __init__(self, x: BooleanFilter) -> None: super().__init__() + self._x = x self._filter = {"bool": {"must_not": x.build()}} + def __invert__(self) -> "BooleanFilter": + # Double negation cancels out: ``~~filter`` is equivalent to ``filter``, + # so return the wrapped filter instead of nesting another ``must_not``. + # This only applies to ``NotFilter`` itself; leaf filters that merely + # use ``must_not`` internally (e.g. ``IsNull``) are unaffected. + return self._x + # LeafBooleanFilter class GreaterEqual(BooleanFilter): diff --git a/tests/operators/test_operators_pytest.py b/tests/operators/test_operators_pytest.py index 7fe602ca..371c2f81 100644 --- a/tests/operators/test_operators_pytest.py +++ b/tests/operators/test_operators_pytest.py @@ -129,10 +129,21 @@ def test_not_filter(self): assert exp.build() == {"bool": {"must_not": {"range": {"a": {"gte": 2}}}}} def test_not_not_filter(self): + # Double negation cancels out: ~~filter is equivalent to filter. exp = ~~GreaterEqual("a", 2) + assert exp.build() == {"range": {"a": {"gte": 2}}} + def test_not_not_not_filter(self): + # An odd number of negations collapses to a single negation. + exp = ~~~GreaterEqual("a", 2) + assert exp.build() == {"bool": {"must_not": {"range": {"a": {"gte": 2}}}}} + + def test_not_isnull_not_collapsed(self): + # IsNull uses must_not internally but is not a NotFilter, so inverting + # it must not be treated as a double negation. + exp = ~IsNull("a") assert exp.build() == { - "bool": {"must_not": {"bool": {"must_not": {"range": {"a": {"gte": 2}}}}}} + "bool": {"must_not": {"bool": {"must_not": {"exists": {"field": "a"}}}}} } def test_not_and_filter(self):