blb-ventures / strawberry-django-plus

Enhanced Strawberry GraphQL integration with Django
MIT License
179 stars 47 forks source link

Django forms for mutations #62

Open rossm6 opened 2 years ago

rossm6 commented 2 years ago

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.

weareua commented 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.

rossm6 commented 2 years ago

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.

bellini666 commented 2 years ago

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 :)

weareua commented 2 years ago

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.

rossm6 commented 2 years ago

@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.

weareua commented 2 years ago

@rossm6 sorry, I didn't even manage to start it yet.

weareua commented 2 years ago

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:

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!

rossm6 commented 2 years ago

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?

weareua commented 2 years ago

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.

rossm6 commented 2 years ago

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])
weareua commented 2 years ago

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

weareua commented 2 years ago

@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:


# 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:  
....