Open rossm6 opened 2 years ago
Utilization of django forms is the only thing that keeps me using django-graphene. So I'm with @rossm6 on this feature request.
Since raising this enhancement request I've realised I could just do all the validaton at the model level. Still I think it would be good to support forms also.
Hey guys,
I can see how this can be useful!
The current DjangoInputMutation (and its create_mutation
, update_mutation
and delete_mutation
implementations) does some integration with django in a way that: It will change the return type to be a union of it with OperationInfo
, which will contain any errors raised by ValidationError
, ObjectDoesNotExist
and PermissionDenied
So, this could be done by probably extending on it and creating the input type automatically by introspecting the django form.
This is a low priority for me since I don't actually use django forms in my projects, but I welcome anyone to try to implement this and I'll gladly review it to be a part of strawberry-django-plus
:)
Since raising this enhancement request I've realised I could just do all the validaton at the model level. Still I think it would be good to support forms also.
Surely you can validate data on the model level, but forms allow you to enhance model schema with custom fields and neat validation for them that often gets useful. As for me, when we're talking about building Graph QL server, forms are the best way to validate the mutation attributes, leaving for the resolver only business logic validation. Code becomes more clear and modular.
This is a low priority for me since I don't actually use django forms in my projects, but I welcome anyone to try to implement this and I'll gladly review it to be a part of strawberry-django-plus :)
Thanks! I didn't start with Strawberry yet, but at the moment I'm forced to lock my dependencies because of this issue. It's obvious that you would want to switch to something really maintained, which is Strawberry right now. Forms are the one thing that I'm missing right now, so at least I'll try to poke around and see how I could incorporate them into the existing suite.
@weareua Did you get anywhere with getting forms to work? I was thinking of taking a look soon but the source code is completely new to me so any pointers will be helpful.
@rossm6 sorry, I didn't even manage to start it yet.
Greetings. So finally I had a chance to try it out myself.
As far as I can see, it almost works out of the box for me.
I can apply django form validation by converting input data into django form and executing .is_valid
method on it.
Then using custom ErrorType
graph-ql type I can convert all gathered errors into the server response.
Things that are missing:
id
was provided Both things are not crucial so in general I'm happy with how I can interact with strawberry django integration. Here is my code:
# models.py
from django.db import models
class Fruit(models.Model):
name = models.CharField(max_length=20)
color = models.ForeignKey(
'Color',
related_name='fruits',
on_delete=models.CASCADE
)
amount = models.IntegerField()
class Color(models.Model):
name = models.CharField(max_length=20)
# helpers.py
from django.utils.functional import Promise
from django.utils.encoding import force_str
from strawberry.utils.str_converters import to_camel_case
def isiterable(value):
try:
iter(value)
except TypeError:
return False
return True
def _camelize_django_str(s):
if isinstance(s, Promise):
s = force_str(s)
return to_camel_case(s) if isinstance(s, str) else s
def camelize(data):
if isinstance(data, dict):
return {_camelize_django_str(k): camelize(v) for k, v in data.items()}
if isiterable(data) and not isinstance(data, (str, Promise)):
return [camelize(d) for d in data]
return data
# types.py
from typing import List, Optional
import strawberry
import strawberry_django
from . import models
from .helpers import camelize
@strawberry.type
class ErrorType:
field: str
messages: List[str]
@classmethod
def from_errors(cls, errors):
data = camelize(errors)
return [cls(field=key, messages=value) for key, value in data.items()]
@strawberry_django.type(models.Fruit, pagination=True)
class FruitType:
id: strawberry.auto
name: strawberry.auto
color: "ColorType"
@strawberry_django.field
def upper_name(self) -> str:
return self.name.upper()
@strawberry.interface
class BaseMutationResponseType:
errors: Optional[List[ErrorType]]
@strawberry.type
class FruitMutationResponse(BaseMutationResponseType):
response: Optional[FruitType]
@strawberry_django.type(models.Color, pagination=True)
class ColorType:
id: strawberry.auto
name: strawberry.auto
fruits: List[FruitType]
# input types
@strawberry_django.input(models.Fruit)
class FruitInput:
id: strawberry.auto
name: strawberry.auto
color: strawberry.ID
@strawberry.input
class DeleteFruitInput:
fruit: strawberry.ID
# forms.py
from django import forms
from django.core.exceptions import ValidationError
from .models import Fruit, Color
class FruitModelForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
error_dict = {}
name = cleaned_data.get('name')
color = cleaned_data.get('color')
if name == 'Apricot':
error_dict['name'] = ValidationError('Apricots are not allowed!')
if color.name == 'White':
error_dict['color'] = ValidationError(
'White color is not allowed!')
if error_dict:
raise ValidationError(error_dict)
class Meta:
model = Fruit
fields = ('name', 'color')
class DeleteFruitModelForm(forms.Form):
fruit = forms.ModelChoiceField(queryset=Fruit.objects.all())
# mutations.py
from copy import deepcopy
from .types import (
FruitInput, DeleteFruitInput, ErrorType, FruitMutationResponse)
from .forms import (
FruitModelForm, DeleteFruitModelForm)
from .models import Fruit
def fruit_mutation(
self, info, data: FruitInput) -> FruitMutationResponse:
response = None
errors = []
form = FruitModelForm(data.__dict__)
if form.is_valid():
if not data.id:
fruit = form.save()
response = fruit
else:
try:
fruit = Fruit.objects.get(pk=data.id)
except Fruit.DoesNotExist:
errors.append(ErrorType(
field='id', messages=["Fruit doesn't exist"]))
else:
for key, value in form.cleaned_data.items():
setattr(fruit, key, value)
fruit.save()
response = fruit
else:
errors = ErrorType.from_errors(form.errors)
return FruitMutationResponse(response=response, errors=errors)
def delete_fruit_mutation(
self, info, data: DeleteFruitInput) -> FruitMutationResponse:
response = None
errors = []
form = DeleteFruitModelForm(data.__dict__)
if form.is_valid():
obj = form.cleaned_data.get('fruit')
deleted_obj = deepcopy(obj)
obj.delete()
response = deleted_obj
else:
errors = ErrorType.from_errors(form.errors)
return FruitMutationResponse(response=response, errors=errors)
# schema.py
from typing import List
import strawberry
import strawberry_django
from strawberry_django_plus import gql
from strawberry_django_plus.optimizer import DjangoOptimizerExtension
from app.models import Fruit as FruitModel
from .types import (
ColorType,
FruitType,
FruitMutationResponse,
)
from .mutations import (
fruit_mutation, delete_fruit_mutation)
@strawberry.type
class Query:
fruit: FruitType = strawberry_django.field()
fruits: List[FruitType] = strawberry_django.field()
color: ColorType = strawberry_django.field()
colors: List[ColorType] = strawberry_django.field()
@gql.type
class Mutation:
fruit: FruitMutationResponse = strawberry.mutation(
resolver=fruit_mutation
)
delete_fruit: FruitMutationResponse = strawberry.mutation(
resolver=delete_fruit_mutation
)
schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=[
DjangoOptimizerExtension,
])
By the way, Query optimization extension just works, Thanks a lot!
Thank you very much for this - it will certainly save me some time.
@bellini666 In theory is it possible to create the input_type dynamically from a form (so taking the model and fields from the form) using the technologies already available in strawberry-django, and, or, strawberry-django-plus?
It took me some time to figure things out as well. Unfortunately, strawberry-django tutorials sort of force us to use generic mutation shortcuts. I think that's not the best idea because usually you would want to customize that behavior, and it's not easy to find the example of how we can bring our own mutation resolvers to play. By the way, I updated the example above to show how we can DRY mutation response types using strawberry interfaces.
Here is an example of dynamically creating an input, response and mutation. So this means we can take a model form and create everything. I'll probably look at doing all of this on Friday. Hopefully will have a PR soon!
ExampleInput = strawberry.input(
make_dataclass(
"ExampleInput",
[
("something", str),
],
)
)
ExampleResponse = strawberry.type(make_dataclass("ExampleResponse", [("success", bool)]))
def example_mutation(info, data: ExampleInput) -> ExampleResponse:
return ExampleResponse(success=True)
example = strawberry.mutation(resolver=example_mutation)
Mutation = create_type("Mutation", [example])
Looking further into it, I found that we have to tweak the ModelForm a bit in order to get the existing model instance if there is the id
attribute in the payload. It's quite easy to implement by instantiating ModelForm and tweaking its __init__
method.
Please take a look at the example below:
class StrawberryModelForm(forms.ModelForm):
"""
Looks for the id in the input data and if it's provided
returns existing model instance instead of the new one.
"""
def __init__(self, *args, **kwargs):
pk = None
instance = None
for arg in args:
pk = arg.get('id', None)
# get the first pk and exit
if pk:
break
if pk:
instance = self._meta.model._default_manager.get(pk=pk)
super().__init__(*args, **kwargs)
if instance:
# restore the instance after form initiation
self.instance = instance
class FruitModelForm(StrawberryModelForm):
....
So basically all you need is to instantiate from StrawberryModelForm
instead of forms.ModelForm
and you'll be good to go
@rossm6
I've tried to enhance your idea with automatic types generation. Response types don't bother me as much as input types, especially when we're talking about dozens of Mutations that all are based on forms and which input types in theory could be generated from that forms.
I've done some digging and as results here is the prototype of automatic types generation from the forms.
This approach:
fields
and exclude
Form Meta attributes required
attribute of the form field
# utils.py
import datetime
import decimal
import uuid
from typing import Optional
import strawberry
import django
from dataclasses import make_dataclass, field
form_field_type_map = {
django.forms.fields.BooleanField: bool,
django.forms.fields.CharField: str,
django.forms.fields.DateField: datetime.date,
django.forms.fields.DateTimeField: datetime.datetime,
django.forms.fields.DecimalField: decimal.Decimal,
django.forms.fields.EmailField: str,
django.forms.fields.FilePathField: str,
django.forms.fields.FloatField: float,
django.forms.fields.GenericIPAddressField: str,
django.forms.fields.IntegerField: int,
django.forms.fields.NullBooleanField: Optional[bool],
django.forms.fields.SlugField: str,
django.forms.fields.TimeField: datetime.time,
django.forms.fields.URLField: str,
django.forms.fields.UUIDField: uuid.UUID,
django.forms.models.ModelChoiceField: strawberry.ID,
}
def get_all_fields_from_form(form_class):
form_instance = form_class()
fields = []
items = form_instance.base_fields.items()
# push optional items to the end of the list.
# we will assign them "None" value, so they should be placed after
# attrs with no default values
sorted_items = sorted(items, key=lambda x: x[1].required, reverse=True)
for item in sorted_items:
item_name = item[0]
item_value = item[1]
field_tuple = (item_name, )
# set default value to None for Optional types
if not item_value.required:
field_tuple = field_tuple + (
Optional[form_field_type_map[type(item_value)]],
field(default=None),)
else:
field_tuple = field_tuple + (
form_field_type_map[type(item_value)],)
fields.append(field_tuple)
# ModelForm instances should have id attr even if it's not present
# in model form instance by default
if issubclass(form_class, django.forms.ModelForm):
fields.append(('id', Optional[strawberry.ID], field(default=None)))
return fields
def get_form_input(input_name, form_class):
datacls = make_dataclass(
input_name,
get_all_fields_from_form(form_class),
)
return strawberry.input(datacls)
# forms.py
from django import forms
from .models import Fruit
class DeleteFruitModelForm(forms.Form):
fruit = forms.ModelChoiceField(queryset=Fruit.objects.all())
class FruitModelForm(forms.ModelForm):
class Meta:
model = Fruit
fields = ('name', 'color')
# types.py
from .forms import FruitModelForm, DeleteFruitModelForm
from .utils import get_form_input
GeneratedFruitInput = get_form_input('GeneratedFruitInput', FruitModelForm)
GeneratedDeleteFruitInput = get_form_input(
'GeneratedDeleteFruitInput', DeleteFruitModelForm)
....
# mutations.py
from .types import (
GeneratedFruitInput, GeneratedDeleteFruitInput, FruitMutationResponse)
def fruit_mutation(
self, info, data: GeneratedFruitInput) -> FruitMutationResponse:
....
def delete_fruit_mutation(
self, info, data: GeneratedDeleteFruitInput) -> FruitMutationResponse:
....
This is an enhancement request for supporting django forms like django-graphene does. It would mean taking a django form, model or otherwise, and automatically generating an input type from the form and an output type which gives me the error forms, or, if successful, and a model form, the saved object.