From 0d7377c1d86f598a1d68d9908e5c9c2eb81048b9 Mon Sep 17 00:00:00 2001 From: amarkdotdev Date: Sun, 28 Jun 2026 19:34:05 +0000 Subject: [PATCH] Collapse double negation in NotFilter `~~filter` now builds the same query as `filter` instead of nesting two `must_not` clauses. NotFilter keeps a reference to the filter it wraps and overrides `__invert__` to return it, so inverting a NotFilter unwraps the negation rather than adding another one. Only NotFilter itself is unwrapped; leaf filters that use `must_not` internally (such as IsNull) are unaffected. Closes #214 --- eland/filter.py | 8 ++++++++ tests/operators/test_operators_pytest.py | 13 ++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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):