Skip to content

Commit f034946

Browse files
author
Grant McConnaughey
committed
Add Django form-based mutations
1 parent f35e445 commit f034946

File tree

8 files changed

+401
-0
lines changed

8 files changed

+401
-0
lines changed

graphene_django/forms/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField

graphene_django/forms/converter.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from django import forms
2+
from django.core.exceptions import ImproperlyConfigured
3+
from graphene_django.utils import import_single_dispatch
4+
import graphene
5+
6+
7+
singledispatch = import_single_dispatch()
8+
9+
10+
def convert_form_to_input_type(form_class):
11+
form = form_class()
12+
13+
items = {
14+
name: convert_form_field(field)
15+
for name, field in form.fields.items()
16+
}
17+
18+
return type(
19+
'{}Input'.format(form.__class__.__name__),
20+
(graphene.InputObjectType, ),
21+
items
22+
)
23+
24+
25+
@singledispatch
26+
def get_graphene_type_from_form_field(field):
27+
raise ImproperlyConfigured(
28+
"Don't know how to convert the form field %s (%s) "
29+
"to Graphene type" % (field, field.__class__)
30+
)
31+
32+
33+
def convert_form_field(field, is_input=True):
34+
"""
35+
Converts a Django form field to a graphql field and marks the field as
36+
required if we are creating an input type and the field itself is required
37+
"""
38+
39+
graphql_type = get_graphene_type_from_form_field(field)
40+
41+
kwargs = {
42+
'description': field.help_text,
43+
'required': is_input and field.required,
44+
}
45+
46+
# if it is a tuple or a list it means that we are returning
47+
# the graphql type and the child type
48+
if isinstance(graphql_type, (list, tuple)):
49+
kwargs['of_type'] = graphql_type[1]
50+
graphql_type = graphql_type[0]
51+
52+
return graphql_type(**kwargs)
53+
54+
55+
@get_graphene_type_from_form_field.register(forms.CharField)
56+
@get_graphene_type_from_form_field.register(forms.ChoiceField)
57+
def convert_form_field_to_string(field):
58+
return graphene.String
59+
60+
61+
@get_graphene_type_from_form_field.register(forms.IntegerField)
62+
def convert_form_field_to_int(field):
63+
return graphene.Int
64+
65+
66+
@get_graphene_type_from_form_field.register(forms.BooleanField)
67+
def convert_form_field_to_bool(field):
68+
return graphene.Boolean
69+
70+
71+
@get_graphene_type_from_form_field.register(forms.FloatField)
72+
@get_graphene_type_from_form_field.register(forms.DecimalField)
73+
def convert_form_field_to_float(field):
74+
return graphene.Float
75+
76+
77+
@get_graphene_type_from_form_field.register(forms.DateField)
78+
@get_graphene_type_from_form_field.register(forms.DateTimeField)
79+
def convert_form_field_to_datetime(field):
80+
return graphene.types.datetime.DateTime
81+
82+
83+
@get_graphene_type_from_form_field.register(forms.TimeField)
84+
def convert_form_field_to_time(field):
85+
return graphene.types.datetime.Time
86+
87+
88+
@get_graphene_type_from_form_field.register(forms.MultipleChoiceField)
89+
def convert_form_field_to_list_of_string(field):
90+
return (graphene.List, graphene.String)
File renamed without changes.

graphene_django/forms/mutation.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from functools import partial
2+
3+
import six
4+
import graphene
5+
from graphene import Field, Argument
6+
from graphene.types.mutation import MutationMeta
7+
from graphene.types.objecttype import ObjectTypeMeta
8+
from graphene.types.options import Options
9+
from graphene.types.utils import get_field_as, merge
10+
from graphene.utils.is_base_type import is_base_type
11+
from graphene_django.registry import get_global_registry
12+
13+
from .converter import convert_form_to_input_type
14+
from .types import ErrorType
15+
16+
17+
class FormMutationMeta(MutationMeta):
18+
def __new__(cls, name, bases, attrs):
19+
if not is_base_type(bases, FormMutationMeta):
20+
return type.__new__(cls, name, bases, attrs)
21+
22+
options = Options(
23+
attrs.pop('Meta', None),
24+
name=name,
25+
description=attrs.pop('__doc__', None),
26+
form_class=None,
27+
input_field_name='input',
28+
local_fields=None,
29+
only_fields=(),
30+
exclude_fields=(),
31+
interfaces=(),
32+
registry=None
33+
)
34+
35+
if not options.form_class:
36+
raise Exception('Missing form_class')
37+
38+
cls = ObjectTypeMeta.__new__(
39+
cls, name, bases, dict(attrs, _meta=options)
40+
)
41+
42+
options.fields = merge(
43+
options.interface_fields, options.base_fields, options.local_fields,
44+
{'errors': get_field_as(cls.errors, Field)}
45+
)
46+
47+
cls.Input = convert_form_to_input_type(options.form_class)
48+
49+
field_kwargs = {cls.options.input_field_name: Argument(cls.Input, required=True)}
50+
cls.Field = partial(
51+
Field,
52+
cls,
53+
resolver=cls.mutate,
54+
**field_kwargs
55+
)
56+
57+
return cls
58+
59+
60+
class BaseFormMutation(graphene.Mutation):
61+
62+
@classmethod
63+
def mutate(cls, root, args, context, info):
64+
form = cls.get_form(root, args, context, info)
65+
66+
if form.is_valid():
67+
return cls.perform_mutate(form, info)
68+
else:
69+
errors = [
70+
ErrorType(field=key, messages=value)
71+
for key, value in form.errors.items()
72+
]
73+
return cls(errors=errors)
74+
75+
@classmethod
76+
def perform_mutate(cls, form, info):
77+
form.save()
78+
return cls(errors=[])
79+
80+
@classmethod
81+
def get_form(cls, root, args, context, info):
82+
form_data = args.get(cls._meta.input_field_name)
83+
kwargs = cls.get_form_kwargs(root, args, context, info)
84+
return cls._meta.form_class(data=form_data, **kwargs)
85+
86+
@classmethod
87+
def get_form_kwargs(cls, root, args, context, info):
88+
return {}
89+
90+
91+
class FormMutation(six.with_metaclass(FormMutationMeta, BaseFormMutation)):
92+
93+
errors = graphene.List(ErrorType)
94+
95+
96+
class ModelFormMutationMeta(MutationMeta):
97+
def __new__(cls, name, bases, attrs):
98+
if not is_base_type(bases, ModelFormMutationMeta):
99+
return type.__new__(cls, name, bases, attrs)
100+
101+
options = Options(
102+
attrs.pop('Meta', None),
103+
name=name,
104+
description=attrs.pop('__doc__', None),
105+
form_class=None,
106+
input_field_name='input',
107+
return_field_name=None,
108+
model=None,
109+
local_fields=None,
110+
only_fields=(),
111+
exclude_fields=(),
112+
interfaces=(),
113+
registry=None
114+
)
115+
116+
if not options.form_class:
117+
raise Exception('Missing form_class')
118+
119+
cls = ObjectTypeMeta.__new__(
120+
cls, name, bases, dict(attrs, _meta=options)
121+
)
122+
123+
options.fields = merge(
124+
options.interface_fields, options.base_fields, options.local_fields,
125+
{'errors': get_field_as(cls.errors, Field)}
126+
)
127+
128+
cls.Input = convert_form_to_input_type(options.form_class)
129+
130+
field_kwargs = {cls.options.input_field_name: Argument(cls.Input, required=True)}
131+
cls.Field = partial(
132+
Field,
133+
cls,
134+
resolver=cls.mutate,
135+
**field_kwargs
136+
)
137+
138+
cls.model = options.model or options.form_class.Meta.model
139+
cls.return_field_name = cls._meta.return_field_name or cls.model._meta.model_name
140+
141+
registry = get_global_registry()
142+
model_type = registry.get_type_for_model(cls.model)
143+
144+
options.fields[cls.return_field_name] = graphene.Field(model_type)
145+
146+
return cls
147+
148+
149+
class ModelFormMutation(six.with_metaclass(ModelFormMutationMeta, BaseFormMutation)):
150+
151+
errors = graphene.List(ErrorType)
152+
153+
@classmethod
154+
def perform_mutate(cls, form, info):
155+
obj = form.save()
156+
kwargs = {cls.return_field_name: obj}
157+
return cls(errors=[], **kwargs)

graphene_django/forms/tests/__init__.py

Whitespace-only changes.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import copy
2+
3+
from django import forms
4+
from py.test import raises
5+
6+
import graphene
7+
8+
from ..converter import convert_form_field
9+
10+
11+
def _get_type(form_field, **kwargs):
12+
# prevents the following error:
13+
# AssertionError: The `source` argument is not meaningful when applied to a `child=` field.
14+
# Remove `source=` from the field declaration.
15+
# since we are reusing the same child in when testing the required attribute
16+
17+
if 'child' in kwargs:
18+
kwargs['child'] = copy.deepcopy(kwargs['child'])
19+
20+
field = form_field(**kwargs)
21+
22+
return convert_form_field(field)
23+
24+
25+
def assert_conversion(form_field, graphene_field, **kwargs):
26+
graphene_type = _get_type(form_field, help_text='Custom Help Text', **kwargs)
27+
assert isinstance(graphene_type, graphene_field)
28+
29+
graphene_type_required = _get_type(
30+
form_field, help_text='Custom Help Text', required=True, **kwargs
31+
)
32+
assert isinstance(graphene_type_required, graphene_field)
33+
34+
return graphene_type
35+
36+
37+
def test_should_unknown_form_field_raise_exception():
38+
with raises(Exception) as excinfo:
39+
convert_form_field(None)
40+
assert 'Don\'t know how to convert the form field' in str(excinfo.value)
41+
42+
43+
def test_should_charfield_convert_string():
44+
assert_conversion(forms.CharField, graphene.String)
45+
46+
47+
def test_should_timefield_convert_time():
48+
assert_conversion(forms.TimeField, graphene.types.datetime.Time)
49+
50+
51+
def test_should_email_convert_string():
52+
assert_conversion(forms.EmailField, graphene.String)
53+
54+
55+
def test_should_slug_convert_string():
56+
assert_conversion(forms.SlugField, graphene.String)
57+
58+
59+
def test_should_url_convert_string():
60+
assert_conversion(forms.URLField, graphene.String)
61+
62+
63+
def test_should_choicefield_convert_string():
64+
assert_conversion(forms.ChoiceField, graphene.String, choices=[])
65+
66+
67+
def test_should_regexfield_convert_string():
68+
assert_conversion(forms.RegexField, graphene.String, regex='[0-9]+')
69+
70+
71+
def test_should_uuidfield_convert_string():
72+
assert_conversion(forms.UUIDField, graphene.String)
73+
74+
75+
def test_should_integer_convert_int():
76+
assert_conversion(forms.IntegerField, graphene.Int)
77+
78+
79+
def test_should_boolean_convert_boolean():
80+
assert_conversion(forms.BooleanField, graphene.Boolean)
81+
82+
83+
def test_should_float_convert_float():
84+
assert_conversion(forms.FloatField, graphene.Float)
85+
86+
87+
def test_should_decimal_convert_float():
88+
assert_conversion(forms.DecimalField, graphene.Float, max_digits=4, decimal_places=2)
89+
90+
91+
def test_should_filepath_convert_string():
92+
assert_conversion(forms.FilePathField, graphene.String, path='/')
93+
94+
95+
def test_should_multiplechoicefield_convert_to_list_of_string():
96+
field = assert_conversion(forms.MultipleChoiceField, graphene.List, choices=[1, 2, 3])
97+
98+
assert field.of_type == graphene.String
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from django import forms
2+
from py.test import raises
3+
4+
from graphene_django.tests.models import Pet
5+
from ..mutation import FormMutation, ModelFormMutation
6+
7+
8+
class MyForm(forms.Form):
9+
text = forms.CharField()
10+
11+
12+
class PetForm(forms.ModelForm):
13+
14+
class Meta:
15+
model = Pet
16+
fields = ('name',)
17+
18+
19+
def test_needs_form_class():
20+
with raises(Exception) as exc:
21+
class MyMutation(FormMutation):
22+
pass
23+
24+
assert exc.value.args[0] == 'Missing form_class'
25+
26+
27+
def test_has_fields():
28+
class MyMutation(FormMutation):
29+
class Meta:
30+
form_class = MyForm
31+
32+
assert 'errors' in MyMutation._meta.fields
33+
34+
35+
def test_has_input_fields():
36+
class MyMutation(FormMutation):
37+
class Meta:
38+
form_class = MyForm
39+
40+
assert 'text' in MyMutation.Input._meta.fields
41+
42+
43+
def test_model_form():
44+
class PetMutation(ModelFormMutation):
45+
class Meta:
46+
form_class = PetForm
47+
48+
assert PetMutation.model == Pet
49+
assert PetMutation.return_field_name == 'pet'

0 commit comments

Comments
 (0)