Repository: Materials-Consortia/optimade-python-tools

Issue:
NOT filter operation of mongo query for complex expressions
This implementation of  [NOT operation](https://docs.mongodb.com/manual/reference/operator/query/not/) fails in the case when it has been applied for a "complex" expression. Here are some example for the inputs and outputs of a `Transformer`:
```
"NOT a>1" -> {'a': {'$not': {'$gt': 1}}}
"NOT a>1 AND b>1" -> {'$and': [{'a': {'$not': {'$gt': 1}}}, {'b': {'$gt': 1}}]}
"NOT (a>1 AND b>1)" -> {'$and': {'$not': [{'a': {'$gt': 1}}, {'b': {'$gt': 1}}]}}
```
In the case of the last one the transformer gives you a rubish query.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) Report
> Merging [#814](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) (7ce7aff) into [master](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/commit/0f11919a7293c8370f77ad29f4a0a54f9b7661a4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) (0f11919) will **increase** coverage by `0.01%`.
> The diff coverage is `96.29%`.

[![Impacted file tree graph](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814/graphs/tree.svg?width=650&height=150&src=pr&token=UJAtmqkZZO&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia)](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia)

```diff
@@            Coverage Diff             @@
##           master     #814      +/-   ##
==========================================
+ Coverage   92.68%   92.69%   +0.01%     
==========================================
  Files          68       68              
  Lines        3677     3698      +21     
==========================================
+ Hits         3408     3428      +20     
- Misses        269      270       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| project | `92.69% <96.29%> (+0.01%)` | :arrow_up: |
| validator | `92.69% <96.29%> (+0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia) | Coverage Δ | |
|---|---|---|
| [optimade/filtertransformers/mongo.py](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia#diff-b3B0aW1hZGUvZmlsdGVydHJhbnNmb3JtZXJzL21vbmdvLnB5) | `97.37% <96.29%> (-0.22%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia). Last update [0f11919...7ce7aff](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/814?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Materials-Consortia).

JPBergsma: I will go for the squash and merge. 
It is indeed better to address one issue at a time, but, since the change in the instructions is very small, I think it won't be worth the extra time to split them into two PR's.

Patch:
diff --git a/INSTALL.md b/INSTALL.md
index 3097d25c..1e9060b9 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -38,6 +38,9 @@ conda activate optimade
 # Install package and dependencies in editable mode (including "dev" requirements).
 pip install -e ".[dev]"
 
+# Optional: Retrieve the list of OPTIMADE providers. (Without this submodule, some of the tests will fail because "providers.json" cannot be found.)
+git submodule update --init
+
 # Run the tests with pytest
 py.test
 
diff --git a/optimade/filtertransformers/mongo.py b/optimade/filtertransformers/mongo.py
index e70c22d1..36279c43 100755
--- a/optimade/filtertransformers/mongo.py
+++ b/optimade/filtertransformers/mongo.py
@@ -6,7 +6,8 @@ which takes the parsed filter and converts it to a valid pymongo/BSON query.
 
 import copy
 import warnings
-from typing import Dict, Any
+import itertools
+from typing import Dict, List, Any
 from lark import v_args, Token
 from optimade.filtertransformers.base_transformer import BaseTransformer, Quantity
 
@@ -23,6 +24,15 @@ class MongoTransformer(BaseTransformer):
     used by pymongo or mongomock. Uses post-processing functions
     to handle some specific edge-cases for MongoDB.
 
+    Attributes:
+        operator_map: A map from comparison operators
+            to the mongoDB specific versions.
+        inverse_operator_map: A map from operators to their
+            logical inverse.
+        mapper: A resource mapper object that defines the
+            expected fields and acts as a container for
+            various field-related configuration.
+
     """
 
     operator_map = {
@@ -34,6 +44,17 @@ class MongoTransformer(BaseTransformer):
         "=": "$eq",
     }
 
+    inverse_operator_map = {
+        "$lt": "$gte",
+        "$lte": "$gt",
+        "$gt": "$lte",
+        "$gte": "$lt",
+        "$ne": "$eq",
+        "$eq": "$ne",
+        "$in": "$nin",
+        "$nin": "$in",
+    }
+
     def postprocess(self, query: Dict[str, Any]):
         """Used to post-process the nested dictionary of the parsed query."""
         query = self._apply_relationship_filtering(query)
@@ -206,32 +227,97 @@ class MongoTransformer(BaseTransformer):
         # property_zip_addon: ":" property (":" property)*
         raise NotImplementedError("Correlated list queries are not supported.")
 
-    def _recursive_expression_phrase(self, arg):
+    def _recursive_expression_phrase(self, arg: List) -> Dict[str, Any]:
         """Helper function for parsing `expression_phrase`. Recursively sorts out
-        the correct precedence for `$and`, `$or` and `$nor`.
+        the correct precedence for `$not`, `$and` and `$or`.
+
+        Parameters:
+            arg: A list containing the expression to be evaluated and whether it
+                is negated, e.g., `["NOT", expr]` or just `[expr]`.
+
+        Returns:
+             The evaluated filter as a nested dictionary.
 
         """
-        if len(arg) == 1:
-            # without NOT
-            return arg[0]
 
-        # handle the case of {"$not": {"$or": [expr1, expr2]}} using {"$nor": [expr1, expr2]}.
-        if "$or" in arg[1]:
-            return {"$nor": self._recursive_expression_phrase([arg[1]["$or"]])}
+        def handle_not_and(arg: Dict[str, List]) -> Dict[str, List]:
+            """Handle the case of `~(A & B) -> (~A | ~B)`.
+
+            We have to check for the special case in which the "and" was created
+            by a previous NOT, e.g.,
+            `NOT (NOT ({"a": {"$eq": 6}})) -> NOT({"$and": [{"a": {"$ne": 6}},{"a": {"$ne": None}}]})`
+
+            Parameters:
+                arg: A dictionary with key `"$and"` containing a list of expressions.
+
+            Returns:
+                A dictionary with key `"$or"` containing a list of the appropriate negated expressions.
+            """
+
+            expr1 = arg["$and"][0]
+            expr2 = arg["$and"][1]
+            if expr1.keys() == expr2.keys():
+                key = list(expr1.keys())[0]
+                for e, f in itertools.permutations((expr1, expr2)):
+                    if e.get(key) == {"$ne": None}:
+                        return self._recursive_expression_phrase(["NOT", f])
+
+            return {
+                "$or": [
+                    self._recursive_expression_phrase(["NOT", subdict])
+                    for subdict in arg["$and"]
+                ]
+            }
+
+        def handle_not_or(arg: Dict[str, List]) -> Dict[str, List]:
+            """Handle the case of ~(A | B) -> (~A & ~B).
+
+            !!! note
+            Although the MongoDB `$nor` could be used here, it is not convenient as it
+            will also return documents where the filtered field is missing when testing
+            for inequality.
+
+            Parameters:
+                arg: A dictionary with key `"$or"` containing a list of expressions.
+
+            Returns:
+                A dictionary with key `"$and"` that lists the appropriate negated expressions.
+            """
 
-        # handle the case of {"$not": {"$and": [expr1, expr2]}} using per-expression negation,
-        # e.g. {"$and": [{prop1: {"$not": expr1}}, {prop2: {"$not": ~expr2}}]}.
-        # Note that this is not the same as NOT (expr1 AND expr2)!
-        if "$and" in arg[1]:
             return {
                 "$and": [
                     self._recursive_expression_phrase(["NOT", subdict])
-                    for subdict in arg[1]["$and"]
+                    for subdict in arg["$or"]
                 ]
             }
 
-        # simple case of negating one expression, from NOT (expr) to ~expr.
-        return {prop: {"$not": expr} for prop, expr in arg[1].items()}
+        if len(arg) == 1:
+            # without NOT
+            return arg[0]
+
+        if "$or" in arg[1]:
+            return handle_not_or(arg[1])
+
+        if "$and" in arg[1]:
+            return handle_not_and(arg[1])
+
+        prop, expr = next(iter(arg[1].items()))
+        operator, value = next(iter(expr.items()))
+        if operator == "$not":  # Case of double negation e.g. NOT("$not":{ ...})
+            return {prop: value}
+
+        # If the NOT operator occurs at the lowest nesting level,
+        # the expression can be simplified by using the opposite operator and removing the not.
+        if operator in self.inverse_operator_map:
+            filter_ = {prop: {self.inverse_operator_map[operator]: value}}
+            if operator in ("$in", "$eq"):
+                filter_ = {"$and": [filter_, {prop: {"$ne": None}}]}
+            return filter_
+
+        filter_ = {prop: {"$not": expr}}
+        if "#known" in expr:
+            return filter_
+        return {"$and": [filter_, {prop: {"$ne": None}}]}
 
     def _apply_length_operators(self, filter_: dict) -> dict:
         """Check for any invalid pymongo queries that involve applying a


Test patch:
diff --git a/tests/filtertransformers/test_mongo.py b/tests/filtertransformers/test_mongo.py
index a42fad5c..90a54280 100644
--- a/tests/filtertransformers/test_mongo.py
+++ b/tests/filtertransformers/test_mongo.py
@@ -107,27 +107,96 @@ class TestMongoTransformer:
 
     def test_operators(self):
         # Basic boolean operations
-        # TODO: {"a": {"$not": {"$lt": 3}}} can be simplified to {"a": {"$gte": 3}}
-        assert self.transform("NOT a<3") == {"a": {"$not": {"$lt": 3}}}
+        assert self.transform("NOT a<3") == {"a": {"$gte": 3}}
+
+        assert self.transform('NOT ( chemical_formula_hill = "Al")') == {
+            "$and": [
+                {"chemical_formula_hill": {"$ne": "Al"}},
+                {"chemical_formula_hill": {"$ne": None}},
+            ]
+        }
+
+        assert self.transform("NOT( NOT( a=4 OR b= 6))") == {
+            "$or": [{"a": {"$eq": 4}}, {"b": {"$eq": 6}}]
+        }
+
+        assert self.transform("a=4 AND b= 6 AND c=10") == {
+            "$and": [{"a": {"$eq": 4}}, {"b": {"$eq": 6}}, {"c": {"$eq": 10}}]
+        }
+
+        assert self.transform("NOT(a<4 OR a>20 AND NOT(c=10))") == {
+            "$and": [
+                {"a": {"$gte": 4}},
+                {"$or": [{"a": {"$lte": 20}}, {"c": {"$eq": 10}}]},
+            ]
+        }
+
+        assert self.transform("NOT(a>4 AND a<20 AND c=10)") == {
+            "$or": [
+                {"a": {"$lte": 4}},
+                {"a": {"$gte": 20}},
+                {"$and": [{"c": {"$ne": 10}}, {"c": {"$ne": None}}]},
+            ]
+        }
+
+        assert self.transform("NOT(a>4 OR a<20 OR c=10)") == {
+            "$and": [
+                {"a": {"$lte": 4}},
+                {"a": {"$gte": 20}},
+                {"$and": [{"c": {"$ne": 10}}, {"c": {"$ne": None}}]},
+            ]
+        }
+
+        assert self.transform('NOT(NOT(chemical_formula_hill CONTAINS "Al"))') == {
+            "chemical_formula_hill": {"$regex": "Al"}
+        }
+
+        assert self.transform(
+            'NOT(nelements > 3 AND NOT(elements HAS "Ac" AND chemical_formula_hill CONTAINS "Al"))'
+        ) == {
+            "$or": [
+                {"nelements": {"$lte": 3}},
+                {
+                    "$and": [
+                        {"elements": {"$in": ["Ac"]}},
+                        {"chemical_formula_hill": {"$regex": "Al"}},
+                    ]
+                },
+            ]
+        }
 
-        # TODO: {'$not': {'$eq': 'Ti'}} can be simplified to {'$ne': 'Ti'}
         assert self.transform(
             "NOT ( "
             'chemical_formula_hill = "Al" AND chemical_formula_anonymous = "A" OR '
             'chemical_formula_anonymous = "H2O" AND NOT chemical_formula_hill = "Ti" '
             ")"
         ) == {
-            "$nor": [
+            "$and": [
                 {
-                    "$and": [
-                        {"chemical_formula_hill": {"$eq": "Al"}},
-                        {"chemical_formula_anonymous": {"$eq": "A"}},
+                    "$or": [
+                        {
+                            "$and": [
+                                {"chemical_formula_hill": {"$ne": "Al"}},
+                                {"chemical_formula_hill": {"$ne": None}},
+                            ]
+                        },
+                        {
+                            "$and": [
+                                {"chemical_formula_anonymous": {"$ne": "A"}},
+                                {"chemical_formula_anonymous": {"$ne": None}},
+                            ]
+                        },
                     ]
                 },
                 {
-                    "$and": [
-                        {"chemical_formula_anonymous": {"$eq": "H2O"}},
-                        {"chemical_formula_hill": {"$not": {"$eq": "Ti"}}},
+                    "$or": [
+                        {
+                            "$and": [
+                                {"chemical_formula_anonymous": {"$ne": "H2O"}},
+                                {"chemical_formula_anonymous": {"$ne": None}},
+                            ]
+                        },
+                        {"chemical_formula_hill": {"$eq": "Ti"}},
                     ]
                 },
             ]
@@ -158,20 +227,16 @@ class TestMongoTransformer:
                     "$and": [
                         {"nelements": {"$gte": 10}},
                         {
-                            "$nor": [
-                                {
-                                    "$and": [
-                                        {"_exmpl_x": {"$ne": "Some string"}},
-                                        {"_exmpl_x": {"$ne": None}},
-                                    ]
-                                },
-                                {"_exmpl_a": {"$not": {"$eq": 7}}},
+                            "$and": [
+                                {"_exmpl_x": {"$eq": "Some string"}},
+                                {"_exmpl_a": {"$eq": 7}},
                             ]
                         },
                     ]
                 },
             ]
         }
+
         assert self.transform('_exmpl_spacegroup="P2"') == {
             "_exmpl_spacegroup": {"$eq": "P2"}
         }
@@ -210,34 +275,31 @@ class TestMongoTransformer:
             ]
         }
 
-        # OPTIONAL
-        # assert self.transform("((NOT (_exmpl_a>_exmpl_b)) AND _exmpl_x>0)") == {}
-
         assert self.transform("NOT (a>1 AND b>1)") == {
-            "$and": [{"a": {"$not": {"$gt": 1}}}, {"b": {"$not": {"$gt": 1}}}]
+            "$or": [{"a": {"$lte": 1}}, {"b": {"$lte": 1}}]
         }
 
         assert self.transform("NOT (a>1 AND b>1 OR c>1)") == {
-            "$nor": [
-                {"$and": [{"a": {"$gt": 1}}, {"b": {"$gt": 1}}]},
-                {"c": {"$gt": 1}},
+            "$and": [
+                {"$or": [{"a": {"$lte": 1}}, {"b": {"$lte": 1}}]},
+                {"c": {"$lte": 1}},
             ]
         }
 
         assert self.transform("NOT (a>1 AND ( b>1 OR c>1 ))") == {
-            "$and": [
-                {"a": {"$not": {"$gt": 1}}},
-                {"$nor": [{"b": {"$gt": 1}}, {"c": {"$gt": 1}}]},
+            "$or": [
+                {"a": {"$lte": 1}},
+                {"$and": [{"b": {"$lte": 1}}, {"c": {"$lte": 1}}]},
             ]
         }
 
         assert self.transform("NOT (a>1 AND ( b>1 OR (c>1 AND d>1 ) ))") == {
-            "$and": [
-                {"a": {"$not": {"$gt": 1}}},
+            "$or": [
+                {"a": {"$lte": 1}},
                 {
-                    "$nor": [
-                        {"b": {"$gt": 1}},
-                        {"$and": [{"c": {"$gt": 1}}, {"d": {"$gt": 1}}]},
+                    "$and": [
+                        {"b": {"$lte": 1}},
+                        {"$or": [{"c": {"$lte": 1}}, {"d": {"$lte": 1}}]},
                     ]
                 },
             ]
@@ -249,14 +311,37 @@ class TestMongoTransformer:
             "$and": [
                 {"elements": {"$in": ["Ag"]}},
                 {
-                    "$and": [
-                        {"elements": {"$not": {"$in": ["Ir"]}}},
-                        {"elements": {"$not": {"$in": ["Ac"]}}},
+                    "$or": [
+                        {
+                            "$and": [
+                                {"elements": {"$nin": ["Ir"]}},
+                                {"elements": {"$ne": None}},
+                            ]
+                        },
+                        {
+                            "$and": [
+                                {"elements": {"$nin": ["Ac"]}},
+                                {"elements": {"$ne": None}},
+                            ]
+                        },
                     ]
                 },
             ]
         }
 
+        assert self.transform(
+            'NOT(structures.id HAS ALL "dummy/2019", "dijkstra1968")'
+        ) == {
+            "$and": [
+                {
+                    "relationships.structures.data.id": {
+                        "$not": {"$all": ["dummy/2019", "dijkstra1968"]}
+                    }
+                },
+                {"relationships.structures.data.id": {"$ne": None}},
+            ]
+        }
+
         assert self.transform("5 < 7") == {7: {"$gt": 5}}
 
         with pytest.raises(VisitError):
@@ -310,10 +395,10 @@ class TestMongoTransformer:
                             }
                         },
                         {"relationships.structures.data.id": {"$all": ["dummy/2019"]}},
-                    ]
+                    ],
                 },
                 {"relationships.structures.data.id": {"$in": ["dummy/2019"]}},
-            ],
+            ]
         }
 
     def test_other_provider_fields(self, mapper):
@@ -705,7 +790,7 @@ class TestMongoTransformer:
     def test_precedence(self):
         assert self.transform('NOT a > b OR c = 100 AND f = "C2 H6"') == {
             "$or": [
-                {"a": {"$not": {"$gt": "b"}}},
+                {"a": {"$lte": "b"}},
                 {"$and": [{"c": {"$eq": 100}}, {"f": {"$eq": "C2 H6"}}]},
             ]
         }
@@ -740,7 +825,9 @@ class TestMongoTransformer:
         assert self.transform("key=value") == {"key": {"$eq": "value"}}
         assert self.transform('author=" someone "') == {"author": {"$eq": " someone "}}
         assert self.transform("notice=val") == {"notice": {"$eq": "val"}}
-        assert self.transform("NOTice=val") == {"ice": {"$not": {"$eq": "val"}}}
+        assert self.transform("NOTice=val") == {
+            "$and": [{"ice": {"$ne": "val"}}, {"ice": {"$ne": None}}]
+        }
         assert self.transform(
             "number=0.ANDnumber=.0ANDnumber=0.0ANDnumber=+0AND_n_u_m_b_e_r_=-0AND"
             "number=0e1ANDnumber=0e-1ANDnumber=0e+1"
diff --git a/tests/server/query_params/test_filter.py b/tests/server/query_params/test_filter.py
index 8cf5247c..1788570c 100644
--- a/tests/server/query_params/test_filter.py
+++ b/tests/server/query_params/test_filter.py
@@ -378,8 +378,104 @@ def test_not_or_and_precedence(check_response):
     expected_ids = ["mpf_1", "mpf_200"]
     check_response(request, expected_ids)
 
-    request = '/structures?filter=elements HAS "Ac" AND nelements>1 AND nsites=1'
-    expected_ids = []
+    request = '/structures?filter=elements HAS "Ac" AND nelements>1 AND nsites=3'
+    expected_ids = ["mpf_23"]
+    check_response(request, expected_ids)
+
+
+def test_behaviour_not(check_response, client, check_error_response):
+    request = '/structures?filter=NOT(elements HAS "Ag" AND nelements>1 )'
+    expected_ids = [
+        "mpf_1",
+        "mpf_23",
+        "mpf_30",
+        "mpf_110",
+        "mpf_3803",
+        "mpf_3819",
+        "mpf_200",
+    ]
+    check_response(request, expected_ids)
+
+    request = (
+        '/structures?filter=NOT(references.id HAS ALL "dummy/2019", "dijkstra1968")'
+    )
+    expected_ids = ["mpf_1", "mpf_2", "mpf_3", "mpf_3819"]
+    if CONFIG.database_backend == SupportedBackend.ELASTIC:
+        check_error_response(
+            request,
+            expected_status=501,
+            expected_title="NotImplementedError",
+            expected_detail="Unable to filter on relationships with type 'references'",
+        )
+        pytest.xfail("Elasticsearch backend does not support relationship filtering.")
+    check_response(request, expected_ids)
+
+    request = '/structures?filter=NOT(elements HAS ALL "Ag", "N")'
+    expected_ids = [
+        "mpf_1",
+        "mpf_2",
+        "mpf_3",
+        "mpf_23",
+        "mpf_30",
+        "mpf_110",
+        "mpf_200",
+        "mpf_220",
+        "mpf_446",
+        "mpf_3803",
+        "mpf_3819",
+    ]
+    check_response(request, expected_ids)
+
+    request = '/structures?filter=NOT(elements HAS "Ac" AND nelements>1 AND nsites=1)'
+    expected_ids = [
+        structure["id"] for structure in client.get("/structures").json()["data"]
+    ]
+    check_response(request, expected_ids)
+
+    request = '/structures?filter=NOT(elements HAS "Ac" OR nelements>1 OR nsites>1)'
+    expected_ids = ["mpf_200"]
+    check_response(request, expected_ids)
+
+    request = "/structures?filter=NOT(nsites<4 OR nsites>20 AND NOT(nelements >5))"
+    expected_ids = [
+        "mpf_2",
+        "mpf_3",
+        "mpf_30",
+        "mpf_110",
+        "mpf_220",
+        "mpf_259",
+        "mpf_281",
+        "mpf_446",
+        "mpf_551",
+        "mpf_632",
+        "mpf_3803",
+        "mpf_3819",
+    ]
+    check_response(request, expected_ids)
+
+
+def test_behaviour_double_negation(check_response):
+    request = (
+        '/structures?filter=NOT(NOT(chemical_formula_descriptive STARTS WITH "Ag2" ))'
+    )
+    expected_ids = ["mpf_259", "mpf_272", "mpf_276", "mpf_281"]
+    check_response(request, expected_ids)
+
+    request = '/structures?filter=NOT(NOT(chemical_formula_descriptive CONTAINS "F"))'
+    expected_ids = ["mpf_110", "mpf_3803", "mpf_3819"]
+    check_response(request, expected_ids)
+
+    request = '/structures?filter=NOT(NOT(nelements < 4 OR nsites < 10 OR elements HAS "Fe") )'
+    expected_ids = [
+        "mpf_1",
+        "mpf_2",
+        "mpf_3",
+        "mpf_23",
+        "mpf_30",
+        "mpf_110",
+        "mpf_200",
+        "mpf_3803",
+    ]
     check_response(request, expected_ids)
 
 
@@ -394,7 +490,6 @@ def test_brackets(check_response):
 
 
 def test_filter_on_relationships(check_response, check_error_response):
-
     request = '/structures?filter=references.id HAS "dummy/2019"'
     expected_ids = ["mpf_3819"]
     if CONFIG.database_backend == SupportedBackend.ELASTIC: