From f4c1e711cc55492e9e2cae37f420d6e4be4c23f6 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Tue, 27 Oct 2015 23:16:15 -0700 Subject: [PATCH] Improved arguments received by proxying keys to snake_case. Added relay mutations --- graphene/core/fields.py | 4 +- graphene/relay/__init__.py | 3 +- graphene/relay/types.py | 31 ++++++++++- graphene/utils.py | 69 ++++++++++++++++++++++++ tests/relay/test_relay_mutations.py | 84 +++++++++++++++++++++++++++++ tests/utils/test_utils.py | 36 +++++++++++++ 6 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 tests/relay/test_relay_mutations.py create mode 100644 tests/utils/test_utils.py diff --git a/graphene/core/fields.py b/graphene/core/fields.py index 275b08f3..86df286c 100644 --- a/graphene/core/fields.py +++ b/graphene/core/fields.py @@ -13,7 +13,7 @@ from graphql.core.type import ( GraphQLFloat, GraphQLInputObjectField, ) -from graphene.utils import to_camel_case +from graphene.utils import to_camel_case, ProxySnakeDict from graphene.core.types import BaseObjectType, InputObjectType from graphene.core.scalars import GraphQLSkipField @@ -59,7 +59,7 @@ class Field(object): schema = info and getattr(info.schema, 'graphene_schema', None) resolve_fn = self.get_resolve_fn(schema) if resolve_fn: - return resolve_fn(instance, args, info) + return resolve_fn(instance, ProxySnakeDict(args), info) else: return getattr(instance, self.field_name, self.get_default()) diff --git a/graphene/relay/__init__.py b/graphene/relay/__init__.py index 49117bd8..ccfe86f2 100644 --- a/graphene/relay/__init__.py +++ b/graphene/relay/__init__.py @@ -8,7 +8,8 @@ from graphene.relay.types import ( Node, PageInfo, Edge, - Connection + Connection, + ClientIDMutation ) from graphene.relay.utils import is_node diff --git a/graphene/relay/types.py b/graphene/relay/types.py index 90d3b635..3b3177e4 100644 --- a/graphene/relay/types.py +++ b/graphene/relay/types.py @@ -2,7 +2,7 @@ from graphql_relay.node.node import ( to_global_id ) -from graphene.core.types import Interface, ObjectType +from graphene.core.types import Interface, ObjectType, Mutation, InputObjectType from graphene.core.fields import BooleanField, StringField, ListField, Field from graphene.relay.fields import GlobalIDField from graphene.utils import memoize @@ -84,3 +84,32 @@ class BaseNode(object): class Node(BaseNode, Interface): '''An object with an ID''' id = GlobalIDField() + + +class MutationInputType(InputObjectType): + client_mutation_id = StringField(required=True) + + +class ClientIDMutation(Mutation): + client_mutation_id = StringField(required=True) + + @classmethod + def _prepare_class(cls): + input_type = getattr(cls, 'input_type', None) + if input_type: + assert hasattr(cls, 'mutate_and_get_payload'), 'You have to implement mutate_and_get_payload' + new_input_inner_type = type('{}InnerInput'.format(cls._meta.type_name), (MutationInputType, input_type, ), {}) + items = { + 'input': Field(new_input_inner_type) + } + assert issubclass(new_input_inner_type, InputObjectType) + input_type = type('{}Input'.format(cls._meta.type_name), (ObjectType, ), items) + setattr(cls, 'input_type', input_type) + + @classmethod + def mutate(cls, instance, args, info): + input = args.get('input') + payload = cls.mutate_and_get_payload(input, info) + client_mutation_id = input.get('client_mutation_id') + setattr(payload, 'client_mutation_id', client_mutation_id) + return payload diff --git a/graphene/utils.py b/graphene/utils.py index f551f57f..00aadf6e 100644 --- a/graphene/utils.py +++ b/graphene/utils.py @@ -1,3 +1,5 @@ +import collections +import re from functools import wraps @@ -42,6 +44,73 @@ def to_camel_case(snake_str): return components[0] + "".join(x.title() for x in components[1:]) +# From this response in Stackoverflow +# http://stackoverflow.com/a/1176023/1072990 +def to_snake_case(name): + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() + + +class ProxySnakeDict(collections.MutableMapping): + __slots__ = ('data') + + def __init__(self, data): + self.data = data + + def __contains__(self, key): + return key in self.data or to_camel_case(key) in self.data + + def get(self, key, default=None): + try: + return self.__getitem__(key) + except KeyError: + return default + + def __iter__(self): + return self.iterkeys() + + def __len__(self): + return len(self.data) + + def __delitem__(self): + raise TypeError('ProxySnakeDict does not support item deletion') + + def __setitem__(self): + raise TypeError('ProxySnakeDict does not support item assignment') + + def __getitem__(self, key): + if key in self.data: + item = self.data[key] + else: + camel_key = to_camel_case(key) + if camel_key in self.data: + item = self.data[camel_key] + else: + raise KeyError(key, camel_key) + + if isinstance(item, dict): + return ProxySnakeDict(item) + return item + + def keys(self): + return list(self.iterkeys()) + + def items(self): + return list(self.iteritems()) + + def iterkeys(self): + for k in self.data.keys(): + yield to_snake_case(k) + return + + def iteritems(self): + for k in self.iterkeys(): + yield k, self[k] + + def __repr__(self): + return dict(self.iteritems()).__repr__() + + class LazyMap(object): def __init__(self, origin, _map, state=None): self._origin = origin diff --git a/tests/relay/test_relay_mutations.py b/tests/relay/test_relay_mutations.py new file mode 100644 index 00000000..52ff2390 --- /dev/null +++ b/tests/relay/test_relay_mutations.py @@ -0,0 +1,84 @@ +from graphql.core.type import ( + GraphQLInputObjectField +) + +import graphene +from graphene import relay +from graphene.core.types import InputObjectType +from graphene.core.schema import Schema + +my_id = 0 + + +class Query(graphene.ObjectType): + base = graphene.StringField() + + +class ChangeNumber(relay.ClientIDMutation): + '''Result mutation''' + class Input: + to = graphene.IntField() + + result = graphene.StringField() + + @classmethod + def mutate_and_get_payload(cls, input, info): + global my_id + my_id = input.get('to', my_id + 1) + return ChangeNumber(result=my_id) + + +class MyResultMutation(graphene.ObjectType): + change_number = graphene.Field(ChangeNumber) + + +schema = Schema(query=Query, mutation=MyResultMutation) + + +def test_mutation_input(): + assert ChangeNumber.input_type + assert ChangeNumber.input_type._meta.type_name == 'ChangeNumberInput' + assert list(ChangeNumber.input_type._meta.fields_map.keys()) == ['input'] + _input = ChangeNumber.input_type._meta.fields_map['input'] + inner_type = _input.get_object_type(schema) + client_mutation_id_field = inner_type._meta.fields_map['client_mutation_id'] + assert issubclass(inner_type, InputObjectType) + assert isinstance(client_mutation_id_field, graphene.StringField) + assert client_mutation_id_field.object_type == inner_type + assert isinstance(client_mutation_id_field.internal_field(schema), GraphQLInputObjectField) + + +def test_execute_mutations(): + query = ''' + mutation M{ + first: changeNumber(input: {clientMutationId: "mutation1"}) { + clientMutationId + result + }, + second: changeNumber(input: {clientMutationId: "mutation2"}) { + clientMutationId + result + } + third: changeNumber(input: {clientMutationId: "mutation3", to: 5}) { + result + clientMutationId + } + } + ''' + expected = { + 'first': { + 'clientMutationId': 'mutation1', + 'result': '1', + }, + 'second': { + 'clientMutationId': 'mutation2', + 'result': '2', + }, + 'third': { + 'clientMutationId': 'mutation3', + 'result': '5', + } + } + result = schema.execute(query, root=object()) + assert not result.errors + assert result.data == expected diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py new file mode 100644 index 00000000..351166d1 --- /dev/null +++ b/tests/utils/test_utils.py @@ -0,0 +1,36 @@ +from graphene.utils import ProxySnakeDict, to_snake_case + + +def test_snake_case(): + assert to_snake_case('snakesOnAPlane') == 'snakes_on_a_plane' + assert to_snake_case('SnakesOnAPlane') == 'snakes_on_a_plane' + assert to_snake_case('snakes_on_a_plane') == 'snakes_on_a_plane' + assert to_snake_case('IPhoneHysteria') == 'i_phone_hysteria' + assert to_snake_case('iPhoneHysteria') == 'i_phone_hysteria' + + +def test_proxy_snake_dict(): + my_data = {'one': 1, 'two': 2, 'none': None, 'threeOrFor': 3, 'inside': {'otherCamelCase': 3}} + p = ProxySnakeDict(my_data) + assert 'one' in p + assert 'two' in p + assert 'threeOrFor' in p + assert 'none' in p + assert p['none'] is None + assert p.get('none') is None + assert p.get('none_existent') is None + assert 'three_or_for' in p + assert p.get('three_or_for') == 3 + assert 'inside' in p + assert 'other_camel_case' in p['inside'] + + +def test_proxy_snake_dict_as_kwargs(): + my_data = {'myData': 1} + p = ProxySnakeDict(my_data) + + def func(**kwargs): + return kwargs.get('my_data') + assert func(**p) == 1 + +