Skip to content

Commit 5388ff9

Browse files
committed
implements #1, extend deferred attribute descriptor to always attempt coercion to enum type instance on field set
1 parent 734cbc8 commit 5388ff9

File tree

7 files changed

+95
-26
lines changed

7 files changed

+95
-26
lines changed

README.rst

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ accessible as their enumeration type rather than by-value:**
8080
txt_enum=MyModel.TextEnum.VALUE1,
8181
int_enum=3 # by-value assignment also works
8282
)
83-
instance.refresh_from_db()
8483
8584
assert instance.txt_enum == MyModel.TextEnum('V1')
8685
assert instance.txt_enum.label == 'Value 1'
@@ -129,7 +128,6 @@ possible very rich enumeration fields.
129128
130129
# save by any symmetric value
131130
instance.color = 'FF0000'
132-
instance.full_clean()
133131
134132
# access any enum property right from the model field
135133
assert instance.color.hex == 'ff0000'

django_enum/fields.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,40 @@
2626
PositiveSmallIntegerField,
2727
SmallIntegerField,
2828
)
29+
from django.db.models.query_utils import DeferredAttribute
2930

3031
T = TypeVar('T') # pylint: disable=C0103
3132

3233

3334
def with_typehint(baseclass: Type[T]) -> Type[T]:
3435
"""
35-
Change inheritance to add Field type hints when . This is just more simple
36-
than defining a Protocols - revisit if Django provides Field protocol -
37-
should also just be a way to create a Protocol from a class?
36+
Change inheritance to add Field type hints when type checking is running.
37+
This is just more simple than defining a Protocol - revisit if Django
38+
provides Field protocol - should also just be a way to create a Protocol
39+
from a class?
40+
41+
This is icky but it works - revisit in future.
3842
"""
3943
if TYPE_CHECKING:
4044
return baseclass # pragma: no cover
4145
return object # type: ignore
4246

4347

48+
class ToPythonDeferredAttribute(DeferredAttribute):
49+
"""
50+
Extend DeferredAttribute descriptor to run a field's to_python method on a
51+
value anytime it is set on the model. This is used to ensure a EnumFields
52+
on models are always of their Enum type.
53+
"""
54+
55+
def __set__(self, instance: Model, value: Any):
56+
try:
57+
instance.__dict__[self.field.name] = self.field.to_python(value)
58+
except ValidationError:
59+
# Django core fields allow assignment of any value, we do the same
60+
instance.__dict__[self.field.name] = value
61+
62+
4463
class EnumMixin(
4564
# why can't mypy handle the line below?
4665
with_typehint(Field) # type: ignore
@@ -63,6 +82,8 @@ class EnumMixin(
6382
strict: bool = True
6483
coerce: bool = True
6584

85+
descriptor_class = ToPythonDeferredAttribute
86+
6687
def _coerce_to_value_type(self, value: Any) -> Choices:
6788
"""Coerce the value to the enumerations value type"""
6889
# note if enum type is int and a floating point is passed we could get
@@ -92,7 +113,7 @@ def _try_coerce(self, value: Any, force: bool = False) -> Union[Choices, Any]:
92113
(self.coerce or force)
93114
and self.enum is not None
94115
and not isinstance(value, self.enum)
95-
): # pylint: disable=R0801
116+
):
96117
try:
97118
value = self.enum(value)
98119
except (TypeError, ValueError):

django_enum/filters.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from django_enum.fields import EnumMixin
88
from django_enum.forms import EnumChoiceField
99

10-
1110
try:
1211
from django_filters import ChoiceFilter, Filter, filterset
1312

django_enum/forms.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from django.forms.fields import ChoiceField
77
from django.forms.widgets import Select
88

9-
109
__all__ = ['NonStrictSelect', 'EnumChoiceField']
1110

1211

django_enum/tests/tests.py

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.core import serializers
77
from django.core.exceptions import ValidationError
88
from django.core.management import call_command
9-
from django.db import transaction
9+
from django.db import connection, transaction
1010
from django.db.models import Q
1111
from django.http import QueryDict
1212
from django.test import Client, TestCase
@@ -80,6 +80,22 @@ class EnumTypeMixin:
8080
type at the specific test in question.
8181
"""
8282

83+
fields = [
84+
'small_pos_int',
85+
'small_int',
86+
'pos_int',
87+
'int',
88+
'big_pos_int',
89+
'big_int',
90+
'constant',
91+
'text',
92+
'dj_int_enum',
93+
'dj_text_enum',
94+
'non_strict_int',
95+
'non_strict_text',
96+
'no_coerce',
97+
]
98+
8399
@property
84100
def SmallPosIntEnum(self):
85101
return self.MODEL_CLASS._meta.get_field('small_pos_int').enum
@@ -167,6 +183,53 @@ def test_basic_save(self):
167183
self.assertEqual(self.MODEL_CLASS.objects.filter(**{param: value}).count(), 1)
168184
self.MODEL_CLASS.objects.all().delete()
169185

186+
def test_to_python_deferred_attribute(self):
187+
obj = self.MODEL_CLASS.objects.create(**self.create_params)
188+
with self.assertNumQueries(1):
189+
obj2 = self.MODEL_CLASS.objects.only('id').get(pk=obj.pk)
190+
191+
for field in [
192+
field.name for field in self.MODEL_CLASS._meta.fields
193+
if field.name != 'id'
194+
]:
195+
# each of these should result in a db query
196+
with self.assertNumQueries(1):
197+
self.assertEqual(
198+
getattr(obj, field),
199+
getattr(obj2, field)
200+
)
201+
202+
with self.assertNumQueries(2):
203+
self.assertEqual(
204+
getattr(
205+
self.MODEL_CLASS.objects.defer(field).get(pk=obj.pk),
206+
field
207+
),
208+
getattr(obj, field),
209+
)
210+
211+
# test that all coerced fields are coerced to the Enum type on
212+
# assignment - this also tests symmetric value assignment in the
213+
# derived class
214+
set_tester = self.MODEL_CLASS()
215+
for field, value in self.values_params.items():
216+
setattr(set_tester, field, getattr(value, 'value', value))
217+
if self.MODEL_CLASS._meta.get_field(field).coerce:
218+
try:
219+
self.assertIsInstance(getattr(set_tester, field), self.enum_type(field))
220+
except AssertionError:
221+
self.assertFalse(self.MODEL_CLASS._meta.get_field(field).strict)
222+
self.assertIsInstance(getattr(set_tester, field), self.enum_primitive(field))
223+
else:
224+
self.assertNotIsInstance(getattr(set_tester, field), self.enum_type(field))
225+
self.assertIsInstance(getattr(set_tester, field), self.enum_primitive(field))
226+
227+
# extra verification - save and make sure values are expected
228+
set_tester.save()
229+
set_tester.refresh_from_db()
230+
for field, value in self.values_params.items():
231+
self.assertEqual(getattr(set_tester, field), value)
232+
170233
def test_integer_choices(self):
171234
self.do_test_integer_choices()
172235

@@ -731,7 +794,6 @@ def test_instance(self):
731794
def test_data(self):
732795
form = self.FORM_CLASS(data=self.model_params)
733796
form.full_clean()
734-
print(form.errors)
735797
self.assertTrue(form.is_valid())
736798
for field, value in self.model_params.items():
737799
self.verify_field(form, field, value)
@@ -3075,14 +3137,12 @@ def test_mapboxstyle(self):
30753137

30763138
# uri's are symmetric
30773139
map_obj.style = 'mapbox://styles/mapbox/light-v10'
3078-
map_obj.full_clean()
30793140
self.assertTrue(map_obj.style == Map.MapBoxStyle.LIGHT)
30803141
self.assertTrue(map_obj.style == 3)
30813142
self.assertTrue(map_obj.style == 'light')
30823143

30833144
# so are labels (also case insensitive)
30843145
map_obj.style = 'satellite streets'
3085-
map_obj.full_clean()
30863146
self.assertTrue(map_obj.style == Map.MapBoxStyle.SATELLITE_STREETS)
30873147

30883148
# when used in API calls (coerced to strings) - they "do the right
@@ -3110,7 +3170,6 @@ def test_color(self):
31103170

31113171
# save by any symmetric value
31123172
instance.color = 'FF0000'
3113-
instance.full_clean()
31143173

31153174
# access any property right from the model field
31163175
self.assertTrue(instance.color.hex == 'ff0000')
@@ -3162,8 +3221,7 @@ def test_strict(self):
31623221

31633222
# set to a valid EnumType value
31643223
obj.non_strict = '1'
3165-
obj.full_clean()
3166-
# when accessed from the db or after clean, will be an EnumType instance
3224+
# when accessed will be an EnumType instance
31673225
self.assertTrue(obj.non_strict is StrictExample.EnumType.ONE)
31683226

31693227
# we can also store any string less than or equal to length 10
@@ -3178,7 +3236,6 @@ def test_basic(self):
31783236
txt_enum=MyModel.TextEnum.VALUE1,
31793237
int_enum=3 # by-value assignment also works
31803238
)
3181-
instance.refresh_from_db()
31823239

31833240
self.assertTrue(instance.txt_enum == MyModel.TextEnum('V1'))
31843241
self.assertTrue(instance.txt_enum.label == 'Value 1')
@@ -3193,7 +3250,7 @@ def test_basic(self):
31933250
int_enum=3
31943251
)
31953252

3196-
instance.txt_enum='AA'
3253+
instance.txt_enum = 'AA'
31973254
self.assertRaises(ValidationError, instance.full_clean)
31983255

31993256
def test_no_coerce(self):

doc/source/index.rst

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ accessible as their enumeration type rather than by-value:**
5050
txt_enum=MyModel.TextEnum.VALUE1,
5151
int_enum=3 # by-value assignment also works
5252
)
53-
instance.refresh_from_db()
5453
5554
assert instance.txt_enum == MyModel.TextEnum('V1')
5655
assert instance.txt_enum.label == 'Value 1'
@@ -92,7 +91,6 @@ enum-properties_ which makes possible very rich enumeration fields.
9291
9392
# save by any symmetric value or enum type instance
9493
instance.color = 'FF0000'
95-
instance.full_clean()
9694
assert instance.color.hex == 'ff0000'
9795
instance.save()
9896

doc/source/usage.rst

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ following exceptions:
3737

3838
.. code:: python
3939
40-
# txt_enum fields, when populated from the db, or after full_clean will be
41-
# an instance of the TextEnum type:
40+
# txt_enum fields will always be an instance of the TextEnum type, unless
41+
# set to a value that is not part of the enumeration
4242
4343
assert isinstance(MyModel.objects.first().txt_enum, MyModel.TextEnum)
4444
assert not isinstance(MyModel.objects.first().txt_choices, MyModel.TextEnum)
@@ -108,8 +108,7 @@ data where no ``Enum`` type coercion is possible.
108108
109109
# set to a valid EnumType value
110110
obj.non_strict = '1'
111-
obj.full_clean()
112-
# when accessed from the db or after clean, will be an EnumType instance
111+
# when accessed will be an EnumType instance
113112
assert obj.non_strict is StrictExample.EnumType.ONE
114113
115114
# we can also store any string less than or equal to length 10
@@ -140,9 +139,8 @@ filter by ``Enum`` instance or any symmetric value:
140139
141140
# set to a valid EnumType value
142141
obj.non_strict = '1'
143-
obj.full_clean()
144142
145-
# when accessed from the db or after clean, will be the primitive value
143+
# when accessed will be the primitive value
146144
assert obj.non_strict == '1'
147145
assert isinstance(obj.non_strict, str)
148146
assert not isinstance(obj.non_strict, StrictExample.EnumType)
@@ -200,7 +198,6 @@ values that can be symmetrically mapped back to enumeration values:
200198
201199
# save by any symmetric value
202200
instance.color = 'FF0000'
203-
instance.full_clean()
204201
205202
# access any enum property right from the model field
206203
assert instance.color.hex == 'ff0000'

0 commit comments

Comments
 (0)