Skip to content

Commit 97deb76

Browse files
authored
fix typed choices, make working with different Django 5x choices options (#1539)
* fix typed choices, make working with different Django 5x choices options * remove `graphene_django/compat.py` from ruff exclusions
1 parent 8d4a64a commit 97deb76

File tree

8 files changed

+207
-32
lines changed

8 files changed

+207
-32
lines changed

.ruff.toml

-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ target-version = "py38"
2525
[per-file-ignores]
2626
# Ignore unused imports (F401) in these files
2727
"__init__.py" = ["F401"]
28-
"graphene_django/compat.py" = ["F401"]
2928

3029
[isort]
3130
known-first-party = ["graphene", "graphene-django"]

graphene_django/compat.py

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import sys
2+
from collections.abc import Callable
23
from pathlib import PurePath
34

45
# For backwards compatibility, we import JSONField to have it available for import via
56
# this compat module (https://github.com/graphql-python/graphene-django/issues/1428).
67
# Django's JSONField is available in Django 3.2+ (the minimum version we support)
7-
from django.db.models import JSONField
8+
from django.db.models import Choices, JSONField
89

910

1011
class MissingType:
@@ -42,3 +43,23 @@ def __init__(self, *args, **kwargs):
4243

4344
else:
4445
ArrayField = MissingType
46+
47+
48+
try:
49+
from django.utils.choices import normalize_choices
50+
except ImportError:
51+
52+
def normalize_choices(choices):
53+
if isinstance(choices, type) and issubclass(choices, Choices):
54+
choices = choices.choices
55+
56+
if isinstance(choices, Callable):
57+
choices = choices()
58+
59+
# In restframework==3.15.0, choices are not passed
60+
# as OrderedDict anymore, so it's safer to check
61+
# for a dict
62+
if isinstance(choices, dict):
63+
choices = choices.items()
64+
65+
return choices

graphene_django/converter.py

+21-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import inspect
2-
from collections.abc import Callable
32
from functools import partial, singledispatch, wraps
43

54
from django.db import models
@@ -37,7 +36,7 @@
3736
from graphql import assert_valid_name as assert_name
3837
from graphql.pyutils import register_description
3938

40-
from .compat import ArrayField, HStoreField, RangeField
39+
from .compat import ArrayField, HStoreField, RangeField, normalize_choices
4140
from .fields import DjangoConnectionField, DjangoListField
4241
from .settings import graphene_settings
4342
from .utils.str_converters import to_const
@@ -61,6 +60,24 @@ def wrapped_resolver(*args, **kwargs):
6160
return blank_field_wrapper(resolver)
6261

6362

63+
class EnumValueField(BlankValueField):
64+
def wrap_resolve(self, parent_resolver):
65+
resolver = super().wrap_resolve(parent_resolver)
66+
67+
# create custom resolver
68+
def enum_field_wrapper(func):
69+
@wraps(func)
70+
def wrapped_resolver(*args, **kwargs):
71+
return_value = func(*args, **kwargs)
72+
if isinstance(return_value, models.Choices):
73+
return_value = return_value.value
74+
return return_value
75+
76+
return wrapped_resolver
77+
78+
return enum_field_wrapper(resolver)
79+
80+
6481
def convert_choice_name(name):
6582
name = to_const(force_str(name))
6683
try:
@@ -72,15 +89,7 @@ def convert_choice_name(name):
7289

7390
def get_choices(choices):
7491
converted_names = []
75-
if isinstance(choices, Callable):
76-
choices = choices()
77-
78-
# In restframework==3.15.0, choices are not passed
79-
# as OrderedDict anymore, so it's safer to check
80-
# for a dict
81-
if isinstance(choices, dict):
82-
choices = choices.items()
83-
92+
choices = normalize_choices(choices)
8493
for value, help_text in choices:
8594
if isinstance(help_text, (tuple, list)):
8695
yield from get_choices(help_text)
@@ -157,7 +166,7 @@ def convert_django_field_with_choices(
157166

158167
converted = EnumCls(
159168
description=get_django_field_description(field), required=required
160-
).mount_as(BlankValueField)
169+
).mount_as(EnumValueField)
161170
else:
162171
converted = convert_django_field(field, registry)
163172
if registry is not None:

graphene_django/forms/types.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from graphene.types.inputobjecttype import InputObjectType
44
from graphene.utils.str_converters import to_camel_case
55

6-
from ..converter import BlankValueField
6+
from ..converter import EnumValueField
77
from ..types import ErrorType # noqa Import ErrorType for backwards compatibility
88
from .mutation import fields_for_form
99

@@ -57,11 +57,10 @@ def mutate(_root, _info, data):
5757
if (
5858
object_type
5959
and name in object_type._meta.fields
60-
and isinstance(object_type._meta.fields[name], BlankValueField)
60+
and isinstance(object_type._meta.fields[name], EnumValueField)
6161
):
62-
# Field type BlankValueField here means that field
62+
# Field type EnumValueField here means that field
6363
# with choices have been converted to enum
64-
# (BlankValueField is using only for that task ?)
6564
setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type))
6665
elif (
6766
object_type

graphene_django/tests/models.py

+44
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
1+
import django
12
from django.db import models
23
from django.utils.translation import gettext_lazy as _
34

45
CHOICES = ((1, "this"), (2, _("that")))
56

67

8+
def get_choices_as_class(choices_class):
9+
if django.VERSION >= (5, 0):
10+
return choices_class
11+
else:
12+
return choices_class.choices
13+
14+
15+
def get_choices_as_callable(choices_class):
16+
if django.VERSION >= (5, 0):
17+
18+
def choices():
19+
return choices_class.choices
20+
21+
return choices
22+
else:
23+
return choices_class.choices
24+
25+
26+
class TypedIntChoice(models.IntegerChoices):
27+
CHOICE_THIS = 1
28+
CHOICE_THAT = 2
29+
30+
31+
class TypedStrChoice(models.TextChoices):
32+
CHOICE_THIS = "this"
33+
CHOICE_THAT = "that"
34+
35+
736
class Person(models.Model):
837
name = models.CharField(max_length=30)
938
parent = models.ForeignKey(
@@ -51,6 +80,21 @@ class Reporter(models.Model):
5180
email = models.EmailField()
5281
pets = models.ManyToManyField("self")
5382
a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True)
83+
typed_choice = models.IntegerField(
84+
choices=TypedIntChoice.choices,
85+
null=True,
86+
blank=True,
87+
)
88+
class_choice = models.IntegerField(
89+
choices=get_choices_as_class(TypedIntChoice),
90+
null=True,
91+
blank=True,
92+
)
93+
callable_choice = models.IntegerField(
94+
choices=get_choices_as_callable(TypedStrChoice),
95+
null=True,
96+
blank=True,
97+
)
5498
objects = models.Manager()
5599
doe_objects = DoeReporterManager()
56100
fans = models.ManyToManyField(Person)

graphene_django/tests/test_converter.py

+81-14
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from ..registry import Registry
2727
from ..types import DjangoObjectType
28-
from .models import Article, Film, FilmDetails, Reporter
28+
from .models import Article, Film, FilmDetails, Reporter, TypedIntChoice, TypedStrChoice
2929

3030
# from graphene.core.types.custom_scalars import DateTime, Time, JSONString
3131

@@ -443,35 +443,102 @@ def test_choice_enum_blank_value():
443443
class ReporterType(DjangoObjectType):
444444
class Meta:
445445
model = Reporter
446-
fields = (
447-
"first_name",
448-
"a_choice",
449-
)
446+
fields = ("callable_choice",)
450447

451448
class Query(graphene.ObjectType):
452449
reporter = graphene.Field(ReporterType)
453450

454451
def resolve_reporter(root, info):
455-
return Reporter.objects.first()
452+
# return a model instance with blank choice field value
453+
return Reporter(callable_choice="")
456454

457455
schema = graphene.Schema(query=Query)
458456

459-
# Create model with empty choice option
460-
Reporter.objects.create(
461-
first_name="Bridget", last_name="Jones", email="[email protected]"
462-
)
463-
464457
result = schema.execute(
465458
"""
466459
query {
467460
reporter {
468-
firstName
469-
aChoice
461+
callableChoice
470462
}
471463
}
472464
"""
473465
)
474466
assert not result.errors
475467
assert result.data == {
476-
"reporter": {"firstName": "Bridget", "aChoice": None},
468+
"reporter": {"callableChoice": None},
469+
}
470+
471+
472+
def test_typed_choice_value():
473+
"""Test that typed choices fields are resolved correctly to the enum values"""
474+
475+
class ReporterType(DjangoObjectType):
476+
class Meta:
477+
model = Reporter
478+
fields = ("typed_choice", "class_choice", "callable_choice")
479+
480+
class Query(graphene.ObjectType):
481+
reporter = graphene.Field(ReporterType)
482+
483+
def resolve_reporter(root, info):
484+
# assign choice values to the fields instead of their str or int values
485+
return Reporter(
486+
typed_choice=TypedIntChoice.CHOICE_THIS,
487+
class_choice=TypedIntChoice.CHOICE_THAT,
488+
callable_choice=TypedStrChoice.CHOICE_THIS,
489+
)
490+
491+
class CreateReporter(graphene.Mutation):
492+
reporter = graphene.Field(ReporterType)
493+
494+
def mutate(root, info, **kwargs):
495+
return CreateReporter(
496+
reporter=Reporter(
497+
typed_choice=TypedIntChoice.CHOICE_THIS,
498+
class_choice=TypedIntChoice.CHOICE_THAT,
499+
callable_choice=TypedStrChoice.CHOICE_THIS,
500+
),
501+
)
502+
503+
class Mutation(graphene.ObjectType):
504+
create_reporter = CreateReporter.Field()
505+
506+
schema = graphene.Schema(query=Query, mutation=Mutation)
507+
508+
reporter_fragment = """
509+
fragment reporter on ReporterType {
510+
typedChoice
511+
classChoice
512+
callableChoice
513+
}
514+
"""
515+
516+
expected_reporter = {
517+
"typedChoice": "A_1",
518+
"classChoice": "A_2",
519+
"callableChoice": "THIS",
477520
}
521+
522+
result = schema.execute(
523+
reporter_fragment
524+
+ """
525+
query {
526+
reporter { ...reporter }
527+
}
528+
"""
529+
)
530+
assert not result.errors
531+
assert result.data["reporter"] == expected_reporter
532+
533+
result = schema.execute(
534+
reporter_fragment
535+
+ """
536+
mutation {
537+
createReporter {
538+
reporter { ...reporter }
539+
}
540+
}
541+
"""
542+
)
543+
assert not result.errors
544+
assert result.data["createReporter"]["reporter"] == expected_reporter

graphene_django/tests/test_schema.py

+3
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ class Meta:
4040
"email",
4141
"pets",
4242
"a_choice",
43+
"typed_choice",
44+
"class_choice",
45+
"callable_choice",
4346
"fans",
4447
"reporter_type",
4548
]

graphene_django/tests/test_types.py

+33
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def test_django_objecttype_map_correct_fields():
7777
"email",
7878
"pets",
7979
"a_choice",
80+
"typed_choice",
81+
"class_choice",
82+
"callable_choice",
8083
"fans",
8184
"reporter_type",
8285
]
@@ -186,6 +189,9 @@ def test_schema_representation():
186189
email: String!
187190
pets: [Reporter!]!
188191
aChoice: TestsReporterAChoiceChoices
192+
typedChoice: TestsReporterTypedChoiceChoices
193+
classChoice: TestsReporterClassChoiceChoices
194+
callableChoice: TestsReporterCallableChoiceChoices
189195
reporterType: TestsReporterReporterTypeChoices
190196
articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection!
191197
}
@@ -199,6 +205,33 @@ def test_schema_representation():
199205
A_2
200206
}
201207
208+
\"""An enumeration.\"""
209+
enum TestsReporterTypedChoiceChoices {
210+
\"""Choice This\"""
211+
A_1
212+
213+
\"""Choice That\"""
214+
A_2
215+
}
216+
217+
\"""An enumeration.\"""
218+
enum TestsReporterClassChoiceChoices {
219+
\"""Choice This\"""
220+
A_1
221+
222+
\"""Choice That\"""
223+
A_2
224+
}
225+
226+
\"""An enumeration.\"""
227+
enum TestsReporterCallableChoiceChoices {
228+
\"""Choice This\"""
229+
THIS
230+
231+
\"""Choice That\"""
232+
THAT
233+
}
234+
202235
\"""An enumeration.\"""
203236
enum TestsReporterReporterTypeChoices {
204237
\"""Regular\"""

0 commit comments

Comments
 (0)