Skip to content

Commit 4f0ea3f

Browse files
committed
add Eccentric Enums docs and a test for custom enum types
1 parent 41c4b08 commit 4f0ea3f

File tree

8 files changed

+295
-3
lines changed

8 files changed

+295
-3
lines changed

django_enum/tests/djenum/enums.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import date, datetime, time, timedelta
22
from decimal import Decimal
33
from enum import Enum, IntEnum, IntFlag
4+
from pathlib import Path
45

56
from django.db.models import IntegerChoices, TextChoices
67
from django.db.models.enums import Choices
@@ -326,3 +327,49 @@ class MultiWithNone(Enum):
326327
VAL2 = '2.0'
327328
VAL3 = 3.0
328329
VAL4 = Decimal('4.5')
330+
331+
332+
class PathEnum(Enum):
333+
334+
USR = Path('/usr')
335+
USR_LOCAL = Path('/usr/local')
336+
USR_LOCAL_BIN = Path('/usr/local/bin')
337+
338+
339+
class StrProps:
340+
"""
341+
Wrap a string with some properties.
342+
"""
343+
344+
_str = ''
345+
346+
def __init__(self, string):
347+
self._str = string
348+
349+
def __str__(self):
350+
return self._str
351+
352+
@property
353+
def upper(self):
354+
return self._str.upper()
355+
356+
@property
357+
def lower(self):
358+
return self._str.lower()
359+
360+
def __eq__(self, other):
361+
if isinstance(other, str):
362+
return self._str == other
363+
if other is not None:
364+
return self._str == other._str
365+
return False
366+
367+
def deconstruct(self):
368+
return 'django_enum.tests.djenum.enums.StrProps', (self._str,), {}
369+
370+
371+
class StrPropsEnum(Enum):
372+
373+
STR1 = StrProps('str1')
374+
STR2 = StrProps('str2')
375+
STR3 = StrProps('str3')

django_enum/tests/djenum/migrations/0001_initial.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
# Generated by Django 4.2.4 on 2023-08-08 01:51
1+
# Generated by Django 3.2.20 on 2023-08-08 16:45
22

33
import datetime
4+
import pathlib
45
from decimal import Decimal
56

67
import django_enum.fields
8+
import django_enum.tests.djenum.enums
79
from django.db import migrations, models
810

911

@@ -30,6 +32,14 @@ class Migration(migrations.Migration):
3032
('non_strict_int', django_enum.fields.EnumPositiveSmallIntegerField(blank=True, choices=[(0, 'Value 1'), (2, 'Value 2'), (32767, 'Value 32767')], default=5, null=True)),
3133
],
3234
),
35+
migrations.CreateModel(
36+
name='CustomPrimitiveTestModel',
37+
fields=[
38+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
39+
('path', django_enum.fields.EnumCharField(choices=[(pathlib.PurePosixPath('/usr'), 'USR'), (pathlib.PurePosixPath('/usr/local'), 'USR_LOCAL'), (pathlib.PurePosixPath('/usr/local/bin'), 'USR_LOCAL_BIN')], max_length=14)),
40+
('str_props', django_enum.fields.EnumCharField(choices=[(django_enum.tests.djenum.enums.StrProps('str1'), 'STR1'), (django_enum.tests.djenum.enums.StrProps('str2'), 'STR2'), (django_enum.tests.djenum.enums.StrProps('str3'), 'STR3')], max_length=4)),
41+
],
42+
),
3343
migrations.CreateModel(
3444
name='EmptyEnumValueTester',
3545
fields=[
@@ -196,7 +206,7 @@ class Migration(migrations.Migration):
196206
migrations.AddField(
197207
model_name='enumflagtesterrelated',
198208
name='related_flags',
199-
field=models.ManyToManyField(related_name='related_flags', to='django_enum_tests_djenum.enumflagtester'),
209+
field=models.ManyToManyField(related_name='related_flags', to='django_enum_tests_djenum.EnumFlagTester'),
200210
),
201211
migrations.AddConstraint(
202212
model_name='emptyenumvaluetester',
@@ -210,6 +220,14 @@ class Migration(migrations.Migration):
210220
model_name='emptyenumvaluetester',
211221
constraint=models.CheckConstraint(check=models.Q(('none_int_enum_non_null__in', [None, 2]), ('none_int_enum_non_null__isnull', True), _connector='OR'), name='s_djenum_EmptyEnumValueTester_none_int_enum_non_null_NoneIntEnum'),
212222
),
223+
migrations.AddConstraint(
224+
model_name='customprimitivetestmodel',
225+
constraint=models.CheckConstraint(check=models.Q(('path__in', ['/usr', '/usr/local', '/usr/local/bin'])), name='django_enum_tests_djenum_CustomPrimitiveTestModel_path_PathEnum'),
226+
),
227+
migrations.AddConstraint(
228+
model_name='customprimitivetestmodel',
229+
constraint=models.CheckConstraint(check=models.Q(('str_props__in', ['str1', 'str2', 'str3'])), name='num_tests_djenum_CustomPrimitiveTestModel_str_props_StrPropsEnum'),
230+
),
213231
migrations.AddConstraint(
214232
model_name='baddefault',
215233
constraint=models.CheckConstraint(check=models.Q(('non_strict_int__in', [0, 2, 32767]), ('non_strict_int__isnull', True), _connector='OR'), name='ango_enum_tests_djenum_BadDefault_non_strict_int_SmallPosIntEnum'),

django_enum/tests/djenum/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@
2424
MultiPrimitiveEnum,
2525
MultiWithNone,
2626
NegativeFlagEnum,
27+
PathEnum,
2728
PosIntEnum,
2829
PositiveFlagEnum,
2930
SmallIntEnum,
3031
SmallNegativeFlagEnum,
3132
SmallPosIntEnum,
3233
SmallPositiveFlagEnum,
34+
StrProps,
35+
StrPropsEnum,
3336
TextEnum,
3437
TimeEnum,
3538
)
@@ -394,3 +397,10 @@ class MultiPrimitiveTestModel(models.Model):
394397
constrained=False,
395398
strict=False
396399
)
400+
401+
402+
class CustomPrimitiveTestModel(models.Model):
403+
404+
path = EnumField(PathEnum, primitive=str)
405+
406+
str_props = EnumField(StrPropsEnum, primitive=str)

django_enum/tests/tests.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,63 @@ def test_enum_choice_field(self):
406406
self.assertEqual(form_field3.choices, choices(MultiWithNone))
407407
self.assertEqual(form_field3.primitive, str)
408408

409+
def test_custom_primitive(self):
410+
from django_enum.tests.djenum.enums import (
411+
PathEnum,
412+
StrProps,
413+
StrPropsEnum,
414+
)
415+
from django_enum.tests.djenum.models import CustomPrimitiveTestModel
416+
417+
obj = CustomPrimitiveTestModel.objects.create(
418+
path='/usr/local',
419+
str_props='str1'
420+
)
421+
self.assertEqual(obj.path, PathEnum.USR_LOCAL)
422+
self.assertEqual(obj.str_props, StrPropsEnum.STR1)
423+
424+
obj2 = CustomPrimitiveTestModel.objects.create(
425+
path=PathEnum.USR,
426+
str_props=StrPropsEnum.STR2
427+
)
428+
self.assertEqual(obj2.path, PathEnum.USR)
429+
self.assertEqual(obj2.str_props, StrPropsEnum.STR2)
430+
431+
obj3 = CustomPrimitiveTestModel.objects.create(
432+
path=Path('/usr/local/bin'),
433+
str_props=StrProps('str3')
434+
)
435+
self.assertEqual(obj3.path, PathEnum.USR_LOCAL_BIN)
436+
self.assertEqual(obj3.str_props, StrPropsEnum.STR3)
437+
438+
self.assertEqual(
439+
obj,
440+
CustomPrimitiveTestModel.objects.get(path='/usr/local')
441+
)
442+
self.assertEqual(
443+
obj,
444+
CustomPrimitiveTestModel.objects.get(str_props='str1')
445+
)
446+
447+
self.assertEqual(
448+
obj2,
449+
CustomPrimitiveTestModel.objects.get(path=PathEnum.USR)
450+
)
451+
self.assertEqual(
452+
obj2,
453+
CustomPrimitiveTestModel.objects.get(str_props=StrPropsEnum.STR2)
454+
)
455+
456+
self.assertEqual(
457+
obj3,
458+
CustomPrimitiveTestModel.objects.get(path=Path('/usr/local/bin')),
459+
)
460+
461+
self.assertEqual(
462+
obj3,
463+
CustomPrimitiveTestModel.objects.get(str_props=StrProps('str3')),
464+
)
465+
409466

410467
class TestEnumCompat(TestCase):
411468
""" Test that django_enum allows non-choice derived enums to be used """

doc/source/eccentric_enums.py

Whitespace-only changes.

doc/source/eccentric_enums.rst

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
.. include:: refs.rst
2+
3+
.. _eccentric_enums:
4+
5+
======================
6+
Eccentric Enumerations
7+
======================
8+
9+
Python's Enum_ type is extremely lenient. Enumeration values may be any
10+
hashable type and values of the same enumeration may be of different types.
11+
12+
For use in databases it is recommended to use more strict enumeration types
13+
that only allow a single value type of either string or integer. If additional
14+
properties need to be associated with enumeration values, a library like
15+
enum-properties_ should be used to store them on the enumeration value classes.
16+
17+
However, the goal of django-enum is to provide as complete a bridge as possible
18+
between Python and the database so eccentric enumerations are supported with
19+
caveats. The following enumeration value types are supported out of the box,
20+
and map to the obvious Django model field type:
21+
22+
* :class:`int`
23+
* :class:`str`
24+
* :class:`float`
25+
* :class:`datetime.date`
26+
* :class:`datetime.datetime`
27+
* :class:`datetime.time`
28+
* :class:`datetime.timedelta`
29+
* :class:`decimal.Decimal`
30+
31+
While it is mostly not advisable to use eccentric enumerations, there may be
32+
some compelling reasons to do so. For example, it may make sense in
33+
situations where the database will be used in a non-Django context and the
34+
enumeration values need to retain their native meaning.
35+
36+
37+
Mixed Value Enumerations
38+
========================
39+
40+
Mixed value enumerations are supported. For example:
41+
42+
.. code-block:: python
43+
44+
from enum import Enum
45+
46+
class EccentricEnum(Enum):
47+
48+
NONE = None
49+
VAL1 = 1
50+
VAL2 = '2.0'
51+
VAL3 = 3.0
52+
VAL4 = Decimal('4.5')
53+
54+
55+
``EnumField`` will determine the most appropriate database column type to store
56+
the enumeration by trying each of the supported primitive types in order and
57+
selecting the first one that is symmetrically coercible to and from each
58+
enumeration value. None values are allowed and do not take part in the
59+
primitive type selection. In the above example, the database column type would
60+
be a string.
61+
62+
.. note::
63+
64+
If none of the supported primitive types are symmetrically coercible
65+
``EnumField`` will not be able to determine an appropriate column type and
66+
a ``ValueError`` will be raised.
67+
68+
In these cases, or to override the primitive type selection made by
69+
``EnumField``, pass the ``primitive`` parameter. It may be necessary to extend
70+
one of the supported primitives to make it coercible. It may also be necessary
71+
to override the Enum_'s ``_missing_`` method:
72+
73+
.. code-block:: python
74+
75+
# eccentric will be a string
76+
eccentric_str = EnumField(EccentricEnum)
77+
78+
# primitive will be a float
79+
eccentric_float = EnumField(EccentricEnum, primitive=float)
80+
81+
In the above case since None is an enumeration value, ``EnumField`` will
82+
automatically set null=True on the model field.
83+
84+
Custom Enumeration Values
85+
=========================
86+
87+
.. warning::
88+
There is almost certainly a better way to do what you might be trying to do
89+
by writing a custom enumeration value - for example consider using
90+
enum-properties_ to make your enumeration types more robust by pushing more
91+
of this functionality on the Enum_ class itself.
92+
93+
If you must use a custom value type, you can by specifying a symmetrically
94+
coercible primitive type. For example Path is already symmetrically coercible
95+
to str so this works:
96+
97+
.. code-block:: python
98+
99+
class MyModel(models.Model):
100+
101+
class PathEnum(Enum):
102+
103+
USR = Path('/usr')
104+
USR_LOCAL = Path('/usr/local')
105+
USR_LOCAL_BIN = Path('/usr/local/bin')
106+
107+
path = EnumField(PathEnum, primitive=str)
108+
109+
110+
A fully custom value might look like the following (admittedly contrived)
111+
example:
112+
113+
.. code-block:: python
114+
115+
class StrProps:
116+
"""
117+
Wrap a string with some properties.
118+
"""
119+
120+
_str = ''
121+
122+
def __init__(self, string):
123+
self._str = string
124+
125+
def __str__(self):
126+
""" coercion to str - str(StrProps('str1')) == 'str1' """
127+
return self._str
128+
129+
@property
130+
def upper(self):
131+
return self._str.upper()
132+
133+
@property
134+
def lower(self):
135+
return self._str.lower()
136+
137+
def __eq__(self, other):
138+
""" Make sure StrProps('str1') == 'str1' """
139+
if isinstance(other, str):
140+
return self._str == other
141+
if other is not None:
142+
return self._str == other._str
143+
return False
144+
145+
def deconstruct(self):
146+
"""Necessary to construct choices and default in migration files"""
147+
return 'my_module.StrProps', (self._str,), {}
148+
149+
150+
class MyModel(models.Model):
151+
152+
class StrPropsEnum(Enum):
153+
154+
STR1 = StrProps('str1')
155+
STR2 = StrProps('str2')
156+
STR3 = StrProps('str3')
157+
158+
str_props = EnumField(StrPropsEnum, primitive=str)
159+

doc/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,5 +182,6 @@ exception will be thrown.
182182
usage
183183
examples
184184
performance
185+
eccentric_enums
185186
reference
186187
changelog

doc/source/performance.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ does a full table scan:
7979

8080
Query performance comparison without indexing. In this scenario, with a 16
8181
flag bitmask compared to 16 boolean columns, each of the three query types
82-
perform roughly 20% faster on PostgreSQL.
82+
perform roughly 20-40% faster on PostgreSQL.
8383

8484

8585
Indexed Exact Queries

0 commit comments

Comments
 (0)