fix typed choices, make working with different Django 5x choices options

This commit is contained in:
Sergey Fursov 2024-12-26 11:47:49 +03:00
parent 269225085d
commit 7e41825c6d
No known key found for this signature in database
7 changed files with 207 additions and 31 deletions

View File

@ -1,10 +1,11 @@
import sys import sys
from collections.abc import Callable
from pathlib import PurePath from pathlib import PurePath
# For backwards compatibility, we import JSONField to have it available for import via # For backwards compatibility, we import JSONField to have it available for import via
# this compat module (https://github.com/graphql-python/graphene-django/issues/1428). # this compat module (https://github.com/graphql-python/graphene-django/issues/1428).
# Django's JSONField is available in Django 3.2+ (the minimum version we support) # Django's JSONField is available in Django 3.2+ (the minimum version we support)
from django.db.models import JSONField from django.db.models import Choices, JSONField
class MissingType: class MissingType:
@ -42,3 +43,23 @@ except ImportError:
else: else:
ArrayField = MissingType ArrayField = MissingType
try:
from django.utils.choices import normalize_choices
except ImportError:
def normalize_choices(choices):
if isinstance(choices, type) and issubclass(choices, Choices):
choices = choices.choices
if isinstance(choices, Callable):
choices = choices()
# In restframework==3.15.0, choices are not passed
# as OrderedDict anymore, so it's safer to check
# for a dict
if isinstance(choices, dict):
choices = choices.items()
return choices

View File

@ -1,5 +1,4 @@
import inspect import inspect
from collections.abc import Callable
from functools import partial, singledispatch, wraps from functools import partial, singledispatch, wraps
from django.db import models from django.db import models
@ -37,7 +36,7 @@ except ImportError:
from graphql import assert_valid_name as assert_name from graphql import assert_valid_name as assert_name
from graphql.pyutils import register_description from graphql.pyutils import register_description
from .compat import ArrayField, HStoreField, RangeField from .compat import ArrayField, HStoreField, RangeField, normalize_choices
from .fields import DjangoConnectionField, DjangoListField from .fields import DjangoConnectionField, DjangoListField
from .settings import graphene_settings from .settings import graphene_settings
from .utils.str_converters import to_const from .utils.str_converters import to_const
@ -61,6 +60,24 @@ class BlankValueField(Field):
return blank_field_wrapper(resolver) return blank_field_wrapper(resolver)
class EnumValueField(BlankValueField):
def wrap_resolve(self, parent_resolver):
resolver = super().wrap_resolve(parent_resolver)
# create custom resolver
def enum_field_wrapper(func):
@wraps(func)
def wrapped_resolver(*args, **kwargs):
return_value = func(*args, **kwargs)
if isinstance(return_value, models.Choices):
return_value = return_value.value
return return_value
return wrapped_resolver
return enum_field_wrapper(resolver)
def convert_choice_name(name): def convert_choice_name(name):
name = to_const(force_str(name)) name = to_const(force_str(name))
try: try:
@ -72,15 +89,7 @@ def convert_choice_name(name):
def get_choices(choices): def get_choices(choices):
converted_names = [] converted_names = []
if isinstance(choices, Callable): choices = normalize_choices(choices)
choices = choices()
# In restframework==3.15.0, choices are not passed
# as OrderedDict anymore, so it's safer to check
# for a dict
if isinstance(choices, dict):
choices = choices.items()
for value, help_text in choices: for value, help_text in choices:
if isinstance(help_text, (tuple, list)): if isinstance(help_text, (tuple, list)):
yield from get_choices(help_text) yield from get_choices(help_text)
@ -157,7 +166,7 @@ def convert_django_field_with_choices(
converted = EnumCls( converted = EnumCls(
description=get_django_field_description(field), required=required description=get_django_field_description(field), required=required
).mount_as(BlankValueField) ).mount_as(EnumValueField)
else: else:
converted = convert_django_field(field, registry) converted = convert_django_field(field, registry)
if registry is not None: if registry is not None:

View File

@ -3,7 +3,7 @@ from graphene import ID
from graphene.types.inputobjecttype import InputObjectType from graphene.types.inputobjecttype import InputObjectType
from graphene.utils.str_converters import to_camel_case from graphene.utils.str_converters import to_camel_case
from ..converter import BlankValueField from ..converter import EnumValueField
from ..types import ErrorType # noqa Import ErrorType for backwards compatibility from ..types import ErrorType # noqa Import ErrorType for backwards compatibility
from .mutation import fields_for_form from .mutation import fields_for_form
@ -57,11 +57,10 @@ class DjangoFormInputObjectType(InputObjectType):
if ( if (
object_type object_type
and name in object_type._meta.fields and name in object_type._meta.fields
and isinstance(object_type._meta.fields[name], BlankValueField) and isinstance(object_type._meta.fields[name], EnumValueField)
): ):
# Field type BlankValueField here means that field # Field type EnumValueField here means that field
# with choices have been converted to enum # with choices have been converted to enum
# (BlankValueField is using only for that task ?)
setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type)) setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type))
elif ( elif (
object_type object_type

View File

@ -1,9 +1,38 @@
import django
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
CHOICES = ((1, "this"), (2, _("that"))) CHOICES = ((1, "this"), (2, _("that")))
def get_choices_as_class(choices_class):
if django.VERSION >= (5, 0):
return choices_class
else:
return choices_class.choices
def get_choices_as_callable(choices_class):
if django.VERSION >= (5, 0):
def choices():
return choices_class.choices
return choices
else:
return choices_class.choices
class TypedIntChoice(models.IntegerChoices):
CHOICE_THIS = 1
CHOICE_THAT = 2
class TypedStrChoice(models.TextChoices):
CHOICE_THIS = "this"
CHOICE_THAT = "that"
class Person(models.Model): class Person(models.Model):
name = models.CharField(max_length=30) name = models.CharField(max_length=30)
parent = models.ForeignKey( parent = models.ForeignKey(
@ -51,6 +80,21 @@ class Reporter(models.Model):
email = models.EmailField() email = models.EmailField()
pets = models.ManyToManyField("self") pets = models.ManyToManyField("self")
a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True) a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True)
typed_choice = models.IntegerField(
choices=TypedIntChoice.choices,
null=True,
blank=True,
)
class_choice = models.IntegerField(
choices=get_choices_as_class(TypedIntChoice),
null=True,
blank=True,
)
callable_choice = models.IntegerField(
choices=get_choices_as_callable(TypedStrChoice),
null=True,
blank=True,
)
objects = models.Manager() objects = models.Manager()
doe_objects = DoeReporterManager() doe_objects = DoeReporterManager()
fans = models.ManyToManyField(Person) fans = models.ManyToManyField(Person)

View File

@ -25,7 +25,7 @@ from ..converter import (
) )
from ..registry import Registry from ..registry import Registry
from ..types import DjangoObjectType from ..types import DjangoObjectType
from .models import Article, Film, FilmDetails, Reporter from .models import Article, Film, FilmDetails, Reporter, TypedIntChoice, TypedStrChoice
# from graphene.core.types.custom_scalars import DateTime, Time, JSONString # from graphene.core.types.custom_scalars import DateTime, Time, JSONString
@ -443,35 +443,102 @@ def test_choice_enum_blank_value():
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
class Meta: class Meta:
model = Reporter model = Reporter
fields = ( fields = ("callable_choice",)
"first_name",
"a_choice",
)
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType) reporter = graphene.Field(ReporterType)
def resolve_reporter(root, info): def resolve_reporter(root, info):
return Reporter.objects.first() # return a model instance with blank choice field value
return Reporter(callable_choice="")
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
# Create model with empty choice option
Reporter.objects.create(
first_name="Bridget", last_name="Jones", email="bridget@example.com"
)
result = schema.execute( result = schema.execute(
""" """
query { query {
reporter { reporter {
firstName callableChoice
aChoice
} }
} }
""" """
) )
assert not result.errors assert not result.errors
assert result.data == { assert result.data == {
"reporter": {"firstName": "Bridget", "aChoice": None}, "reporter": {"callableChoice": None},
} }
def test_typed_choice_value():
"""Test that typed choices fields are resolved correctly to the enum values"""
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
fields = ("typed_choice", "class_choice", "callable_choice")
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
def resolve_reporter(root, info):
# assign choice values to the fields instead of their str or int values
return Reporter(
typed_choice=TypedIntChoice.CHOICE_THIS,
class_choice=TypedIntChoice.CHOICE_THAT,
callable_choice=TypedStrChoice.CHOICE_THIS,
)
class CreateReporter(graphene.Mutation):
reporter = graphene.Field(ReporterType)
def mutate(root, info, **kwargs):
return CreateReporter(
reporter=Reporter(
typed_choice=TypedIntChoice.CHOICE_THIS,
class_choice=TypedIntChoice.CHOICE_THAT,
callable_choice=TypedStrChoice.CHOICE_THIS,
),
)
class Mutation(graphene.ObjectType):
create_reporter = CreateReporter.Field()
schema = graphene.Schema(query=Query, mutation=Mutation)
reporter_fragment = """
fragment reporter on ReporterType {
typedChoice
classChoice
callableChoice
}
"""
expected_reporter = {
"typedChoice": "A_1",
"classChoice": "A_2",
"callableChoice": "THIS",
}
result = schema.execute(
reporter_fragment
+ """
query {
reporter { ...reporter }
}
"""
)
assert not result.errors
assert result.data["reporter"] == expected_reporter
result = schema.execute(
reporter_fragment
+ """
mutation {
createReporter {
reporter { ...reporter }
}
}
"""
)
assert not result.errors
assert result.data["createReporter"]["reporter"] == expected_reporter

View File

@ -40,6 +40,9 @@ def test_should_map_fields_correctly():
"email", "email",
"pets", "pets",
"a_choice", "a_choice",
"typed_choice",
"class_choice",
"callable_choice",
"fans", "fans",
"reporter_type", "reporter_type",
] ]

View File

@ -77,6 +77,9 @@ def test_django_objecttype_map_correct_fields():
"email", "email",
"pets", "pets",
"a_choice", "a_choice",
"typed_choice",
"class_choice",
"callable_choice",
"fans", "fans",
"reporter_type", "reporter_type",
] ]
@ -186,6 +189,9 @@ def test_schema_representation():
email: String! email: String!
pets: [Reporter!]! pets: [Reporter!]!
aChoice: TestsReporterAChoiceChoices aChoice: TestsReporterAChoiceChoices
typedChoice: TestsReporterTypedChoiceChoices
classChoice: TestsReporterClassChoiceChoices
callableChoice: TestsReporterCallableChoiceChoices
reporterType: TestsReporterReporterTypeChoices reporterType: TestsReporterReporterTypeChoices
articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection! articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection!
} }
@ -199,6 +205,33 @@ def test_schema_representation():
A_2 A_2
} }
\"""An enumeration.\"""
enum TestsReporterTypedChoiceChoices {
\"""Choice This\"""
A_1
\"""Choice That\"""
A_2
}
\"""An enumeration.\"""
enum TestsReporterClassChoiceChoices {
\"""Choice This\"""
A_1
\"""Choice That\"""
A_2
}
\"""An enumeration.\"""
enum TestsReporterCallableChoiceChoices {
\"""Choice This\"""
THIS
\"""Choice That\"""
THAT
}
\"""An enumeration.\""" \"""An enumeration.\"""
enum TestsReporterReporterTypeChoices { enum TestsReporterReporterTypeChoices {
\"""Regular\""" \"""Regular\"""