Repository: glotzerlab/signac
Pull request #218 (2019-08-09 14:56:58)

Make lists containing dicts hashable.
Fixes issue #217.

codecov[bot]: # [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=h1) Report
> Merging [#218](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=desc) into [master](https://codecov.io/gh/glotzerlab/signac/commit/b100fc03020b915baf0d6abe5dd250c035cf0f26?src=pr&el=desc) will **increase** coverage by `0.03%`.
> The diff coverage is `90%`.

[![Impacted file tree graph](https://codecov.io/gh/glotzerlab/signac/pull/218/graphs/tree.svg?width=650&token=FghbjTyzgM&height=150&src=pr)](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #218      +/-   ##
==========================================
+ Coverage   65.12%   65.15%   +0.03%     
==========================================
  Files          37       37              
  Lines        5594     5599       +5     
==========================================
+ Hits         3643     3648       +5     
  Misses       1951     1951
```


| [Impacted Files](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [signac/contrib/collection.py](https://codecov.io/gh/glotzerlab/signac/pull/218/diff?src=pr&el=tree#diff-c2lnbmFjL2NvbnRyaWIvY29sbGVjdGlvbi5weQ==) | `88.76% <90%> (+0.1%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=footer). Last update [b100fc0...818e2dd](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

codecov[bot]: # [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=h1) Report
> Merging [#218](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=desc) into [master](https://codecov.io/gh/glotzerlab/signac/commit/b100fc03020b915baf0d6abe5dd250c035cf0f26?src=pr&el=desc) will **increase** coverage by `0.03%`.
> The diff coverage is `90%`.

[![Impacted file tree graph](https://codecov.io/gh/glotzerlab/signac/pull/218/graphs/tree.svg?width=650&token=FghbjTyzgM&height=150&src=pr)](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #218      +/-   ##
==========================================
+ Coverage   65.12%   65.15%   +0.03%     
==========================================
  Files          37       37              
  Lines        5594     5599       +5     
==========================================
+ Hits         3643     3648       +5     
  Misses       1951     1951
```


| [Impacted Files](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [signac/contrib/collection.py](https://codecov.io/gh/glotzerlab/signac/pull/218/diff?src=pr&el=tree#diff-c2lnbmFjL2NvbnRyaWIvY29sbGVjdGlvbi5weQ==) | `88.76% <90%> (+0.1%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=footer). Last update [b100fc0...818e2dd](https://codecov.io/gh/glotzerlab/signac/pull/218?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/signac/contrib/collection.py b/signac/contrib/collection.py
index 5b970dc9..da356ec9 100644
--- a/signac/contrib/collection.py
+++ b/signac/contrib/collection.py
@@ -66,9 +66,16 @@ def _flatten(container):
             yield i
 
 
-def _to_tuples(l):
+class _hashable_dict(dict):
+    def __hash__(self):
+        return hash(tuple(sorted(self.items())))
+
+
+def _to_hashable(l):
     if type(l) is list:
-        return tuple(_to_tuples(_) for _ in l)
+        return tuple(_to_hashable(_) for _ in l)
+    elif type(l) is dict:
+        return _hashable_dict(l)
     else:
         return l
 
@@ -79,7 +86,7 @@ class _DictPlaceholder(object):
 
 def _encode_tree(x):
     if type(x) is list:
-        return _to_tuples(x)
+        return _to_hashable(x)
     else:
         return x
 
@@ -190,7 +197,7 @@ def _build_index(docs, key, primary_key):
             if type(v) is dict:
                 continue
             elif type(v) is list:   # performance
-                index[_to_tuples(v)].add(doc[primary_key])
+                index[_to_hashable(v)].add(doc[primary_key])
             else:
                 index[v].add(doc[primary_key])
 
@@ -208,7 +215,7 @@ def _build_index(docs, key, primary_key):
                     "for a recipe on how to replace dots in existing keys.")
                 # inlined for performance
                 if type(v) is list:     # performance
-                    index[_to_tuples(v)].add(doc[primary_key])
+                    index[_to_hashable(v)].add(doc[primary_key])
                 else:
                     index[v].add(doc[primary_key])
     return index


Test patch:
diff --git a/tests/test_collection.py b/tests/test_collection.py
index 366739f7..0c213bd9 100644
--- a/tests/test_collection.py
+++ b/tests/test_collection.py
@@ -289,6 +289,17 @@ class CollectionTest(unittest.TestCase):
         del self.c[docs[0]['_id']]
         self.assertEqual(len(self.c.find({'a': 0})), 0)
 
+    def test_find_list(self):
+        self.assertEqual(len(self.c.find()), 0)
+        self.assertEqual(list(self.c.find()), [])
+        self.assertEqual(len(self.c.find({'a': []})), 0)
+        self.c.insert_one({'a': []})
+        self.assertEqual(len(self.c.find()), 1)
+        self.assertEqual(len(self.c.find({'a': []})), 1)
+        for v in (None, 1, '1', {'b': 1}):
+            self.c.insert_one({'a': [v]})
+            self.assertEqual(len(self.c.find({'a': [v]})), 1)
+
     def test_find_int_float(self):
         id_float = self.c.insert_one({'a': float(1.0)})
         id_int = self.c.insert_one({'a': 1})