Repository: graphql-python/graphene
Pull request #786 (2018-07-01 19:34:32)

Deduplicator
Inspired by https://github.com/graphql-python/graphene/issues/783 and https://github.com/graphql-python/graphene/issues/782 I've implemented https://github.com/gajus/graphql-deduplicator in python.

### Usage:

```python
# You'll want to make sure that the query is not an introspection 
# query and that the client definitely wants the data to be deduplicated

result = schema.execute(query)
result.data = deflate(result)

# json encode result and serve to the client
```


Patch:
diff --git a/docs/types/mutations.rst b/docs/types/mutations.rst
index 6949008..70c80d0 100644
--- a/docs/types/mutations.rst
+++ b/docs/types/mutations.rst
@@ -76,7 +76,7 @@ We should receive:
     {
         "createPerson": {
             "person" : {
-                "name": "Peter"
+                name: "Peter"
             },
             "ok": true
         }
@@ -116,7 +116,7 @@ Note that  **name** and **age** are part of **person_data** now
 
 Using the above mutation your new query would look like this:
 
-.. code::
+.. code:: json
 
     mutation myFirstMutation {
         createPerson(personData: {name:"Peter", age: 24}) {
@@ -143,41 +143,3 @@ as complex of input data as you need
         name = graphene.String()
         latlng = graphene.InputField(LatLngInput)
 
-Output type example
--------------------
-To return an existing ObjectType instead of a mutation-specific type, set the **Output** attribute to the desired ObjectType:
-
-.. code:: python
-
-    import graphene
-
-    class CreatePerson(graphene.Mutation):
-        class Arguments:
-            name = graphene.String()
-
-        Output = Person
-
-        def mutate(self, info, name):
-            return Person(name=name)
-
-Then, if we query (``schema.execute(query_str)``) the following:
-
-.. code::
-
-    mutation myFirstMutation {
-        createPerson(name:"Peter") {
-            name
-            __typename
-        }
-    }
-
-We should receive:
-
-.. code:: json
-
-    {
-        "createPerson": {
-            "name": "Peter",
-            "__typename": "Person"
-        }
-    }
diff --git a/graphene/utils/deduplicator.py b/graphene/utils/deduplicator.py
new file mode 100644
index 0000000..fd5682b
--- /dev/null
+++ b/graphene/utils/deduplicator.py
@@ -0,0 +1,38 @@
+from collections import Mapping, OrderedDict
+
+
+def deflate(node, index=None, path=None):
+    if index is None:
+        index = {}
+    if path is None:
+        path = []
+
+    if node and 'id' in node and '__typename' in node:
+        route = ','.join(path)
+        cache_key = ':'.join([route, str(node['__typename']), str(node['id'])])
+
+        if index.get(cache_key) is True:
+            return {
+                '__typename': node['__typename'],
+                'id': node['id'],
+            }
+        else:
+            index[cache_key] = True
+
+    field_names = node.keys()
+    result = OrderedDict()
+
+    for field_name in field_names:
+        value = node[field_name]
+
+        new_path = path + [field_name]
+        if isinstance(value, (list, tuple)):
+            result[field_name] = [
+                deflate(child, index, new_path) for child in value
+            ]
+        elif isinstance(value, Mapping):
+            result[field_name] = deflate(value, index, new_path)
+        else:
+            result[field_name] = value
+
+    return result


Test patch:
diff --git a/graphene/utils/tests/test_deduplicator.py b/graphene/utils/tests/test_deduplicator.py
new file mode 100644
index 0000000..d73fbfb
--- /dev/null
+++ b/graphene/utils/tests/test_deduplicator.py
@@ -0,0 +1,257 @@
+import datetime
+import graphene
+from graphene import relay
+from graphene.types.resolver import dict_resolver
+
+from ..deduplicator import deflate
+
+
+def test_does_not_modify_object_without_typename_and_id():
+    response = {
+        'foo': 'bar',
+    }
+
+    deflated_response = deflate(response)
+    assert deflated_response == {
+        'foo': 'bar',
+    }
+
+
+def test_does_not_modify_first_instance_of_an_object():
+    response = {
+        'data': [
+            {
+                '__typename': 'foo',
+                'id': 1,
+                'name': 'foo'
+            },
+            {
+                '__typename': 'foo',
+                'id': 1,
+                'name': 'foo'
+            }
+        ]
+    }
+
+    deflated_response = deflate(response)
+
+    assert deflated_response == {
+        'data': [
+            {
+                '__typename': 'foo',
+                'id': 1,
+                'name': 'foo'
+            },
+            {
+                '__typename': 'foo',
+                'id': 1
+            }
+        ]
+    }
+
+
+def test_does_not_modify_first_instance_of_an_object_nested():
+    response = {
+        'data': [
+            {
+                '__typename': 'foo',
+                'bar1': {
+                    '__typename': 'bar',
+                    'id': 1,
+                    'name': 'bar'
+                },
+                'bar2': {
+                    '__typename': 'bar',
+                    'id': 1,
+                    'name': 'bar'
+                },
+                'id': 1
+            },
+            {
+                '__typename': 'foo',
+                'bar1': {
+                    '__typename': 'bar',
+                    'id': 1,
+                    'name': 'bar'
+                },
+                'bar2': {
+                    '__typename': 'bar',
+                    'id': 1,
+                    'name': 'bar'
+                },
+                'id': 2
+            }
+        ]
+    }
+
+    deflated_response = deflate(response)
+
+    assert deflated_response == {
+        'data': [
+            {
+                '__typename': 'foo',
+                'bar1': {
+                    '__typename': 'bar',
+                    'id': 1,
+                    'name': 'bar'
+                },
+                'bar2': {
+                    '__typename': 'bar',
+                    'id': 1,
+                    'name': 'bar'
+                },
+                'id': 1
+            },
+            {
+                '__typename': 'foo',
+                'bar1': {
+                    '__typename': 'bar',
+                    'id': 1
+                },
+                'bar2': {
+                    '__typename': 'bar',
+                    'id': 1
+                },
+                'id': 2
+            }
+        ]
+    }
+
+
+def test_does_not_modify_input():
+    response = {
+        'data': [
+            {
+                '__typename': 'foo',
+                'id': 1,
+                'name': 'foo'
+            },
+            {
+                '__typename': 'foo',
+                'id': 1,
+                'name': 'foo'
+            }
+        ]
+    }
+
+    deflate(response)
+
+    assert response == {
+        'data': [
+            {
+                '__typename': 'foo',
+                'id': 1,
+                'name': 'foo'
+            },
+            {
+                '__typename': 'foo',
+                'id': 1,
+                'name': 'foo'
+            }
+        ]
+    }
+
+
+TEST_DATA = {
+    'events': [
+        {
+            'id': '568',
+            'date': datetime.date(2017, 5, 19),
+            'movie': '1198359',
+        },
+        {
+            'id': '234',
+            'date': datetime.date(2017, 5, 20),
+            'movie': '1198359',
+        },
+    ],
+    'movies': {
+        '1198359': {
+            'name': 'King Arthur: Legend of the Sword',
+            'synopsis': (
+                "When the child Arthur's father is murdered, Vortigern, "
+                "Arthur's uncle, seizes the crown. Robbed of his birthright and "
+                "with no idea who he truly is..."
+            ),
+        },
+    },
+}
+
+
+def test_example_end_to_end():
+    class Movie(graphene.ObjectType):
+        class Meta:
+            interfaces = (relay.Node,)
+            default_resolver = dict_resolver
+
+        name = graphene.String(required=True)
+        synopsis = graphene.String(required=True)
+
+    class Event(graphene.ObjectType):
+        class Meta:
+            interfaces = (relay.Node,)
+            default_resolver = dict_resolver
+
+        movie = graphene.Field(Movie, required=True)
+        date = graphene.types.datetime.Date(required=True)
+
+        def resolve_movie(event, info):
+            return TEST_DATA['movies'][event['movie']]
+
+    class Query(graphene.ObjectType):
+        events = graphene.List(
+            graphene.NonNull(Event),
+            required=True
+        )
+
+        def resolve_events(_, info):
+            return TEST_DATA['events']
+
+    schema = graphene.Schema(query=Query)
+    query = """\
+        {
+            events {
+                __typename
+                id
+                date
+                movie {
+                    __typename
+                    id
+                    name
+                    synopsis
+                }
+            }
+        }
+    """
+    result = schema.execute(query)
+    assert not result.errors
+
+    result.data = deflate(result.data)
+    assert result.data == {
+        'events': [
+            {
+                '__typename': 'Event',
+                'id': 'RXZlbnQ6NTY4',
+                'date': '2017-05-19',
+                'movie': {
+                    '__typename': 'Movie',
+                    'id': 'TW92aWU6Tm9uZQ==',
+                    'name': 'King Arthur: Legend of the Sword',
+                    'synopsis': (
+                        "When the child Arthur's father is murdered, Vortigern, "
+                        "Arthur's uncle, seizes the crown. Robbed of his birthright and "
+                        "with no idea who he truly is..."
+                    ),
+                },
+            },
+            {
+                '__typename': 'Event',
+                'id': 'RXZlbnQ6MjM0',
+                'date': '2017-05-20',
+                'movie': {
+                    '__typename': 'Movie',
+                    'id': 'TW92aWU6Tm9uZQ==',
+                },
+            },
+        ],
+    }