diff --git a/eav/logic/__init__.py b/eav/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eav/logic/entity_pk.py b/eav/logic/entity_pk.py new file mode 100644 index 0000000..bdb0a02 --- /dev/null +++ b/eav/logic/entity_pk.py @@ -0,0 +1,12 @@ +from django.db.models.fields import UUIDField + + +def get_entity_pk_type(entity_cls) -> str: + """Returns the entity PK type to use. + + These values map to `models.Value` as potential fields to use to relate + to the proper entity via the correct PK type. + """ + if isinstance(entity_cls._meta.pk, UUIDField): + return 'entity_uuid' + return 'entity_id' diff --git a/eav/migrations/0006_add_entity_uuid.py b/eav/migrations/0006_add_entity_uuid.py new file mode 100644 index 0000000..541fab5 --- /dev/null +++ b/eav/migrations/0006_add_entity_uuid.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Creates UUID field to map to Entity FK.""" + + dependencies = [ + ('eav', '0005_auto_20210510_1305'), + ] + + operations = [ + migrations.AddField( + model_name='value', + name='entity_uuid', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AlterField( + model_name='value', + name='entity_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/eav/models.py b/eav/models.py index ecedab0..7b4aca8 100644 --- a/eav/models.py +++ b/eav/models.py @@ -20,6 +20,8 @@ from django.db.models.base import ModelBase from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from eav.logic.entity_pk import get_entity_pk_type + try: from django.db.models import JSONField except ImportError: @@ -334,17 +336,19 @@ class Attribute(models.Model): """ ct = ContentType.objects.get_for_model(entity) + entity_filter = { + 'entity_ct': ct, + 'attribute': self, + '{0}'.format(get_entity_pk_type(entity)): entity.pk, + } + try: - value_obj = self.value_set.get( - entity_ct=ct, entity_id=entity.pk, attribute=self - ) + value_obj = self.value_set.get(**entity_filter) except Value.DoesNotExist: if value == None or value == '': return - value_obj = Value.objects.create( - entity_ct=ct, entity_id=entity.pk, attribute=self - ) + value_obj = Value.objects.create(**entity_filter) if value == None or value == '': value_obj.delete() @@ -358,10 +362,11 @@ class Attribute(models.Model): return '{} ({})'.format(self.name, self.get_datatype_display()) -class Value(models.Model): - """ - Putting the **V** in *EAV*. This model stores the value for one particular - :class:`Attribute` for some entity. +class Value(models.Model): # noqa: WPS110 + """Putting the **V** in *EAV*. + + This model stores the value for one particular :class:`Attribute` for + some entity. As with most EAV implementations, most of the columns of this model will be blank, as onle one *value_* field will be used. @@ -380,22 +385,58 @@ class Value(models.Model): # = """ - entity_ct = models.ForeignKey( - ContentType, on_delete=models.PROTECT, related_name='value_entities' + # Direct foreign keys + attribute = models.ForeignKey( + Attribute, + db_index=True, + on_delete=models.PROTECT, + verbose_name=_('Attribute'), ) - entity_id = models.IntegerField() - entity = generic.GenericForeignKey(ct_field='entity_ct', fk_field='entity_id') + # Entity generic relationships. Rather than rely on database casting, + # this will instead use a separate ForeignKey field attribute that matches + # the FK type of the entity. + entity_id = models.IntegerField(blank=True, null=True) + entity_uuid = models.UUIDField(blank=True, null=True) - value_text = models.TextField(blank=True, null=True) + entity_ct = models.ForeignKey( + ContentType, + on_delete=models.PROTECT, + related_name='value_entities', + ) + + entity_pk_int = generic.GenericForeignKey( + ct_field='entity_ct', + fk_field='entity_id', + ) + + entity_pk_uuid = generic.GenericForeignKey( + ct_field='entity_ct', + fk_field='entity_uuid', + ) + + # Model attributes + created = models.DateTimeField( + _('Created'), + default=timezone.now, + ) + + modified = models.DateTimeField(_('Modified'), auto_now=True) + + # Value attributes + value_bool = models.BooleanField(blank=True, null=True) + value_csv = CSVField(blank=True, null=True) + value_date = models.DateTimeField(blank=True, null=True) value_float = models.FloatField(blank=True, null=True) value_int = models.IntegerField(blank=True, null=True) - value_date = models.DateTimeField(blank=True, null=True) - value_bool = models.BooleanField(blank=True, null=True) + value_text = models.TextField(blank=True, null=True) + value_json = JSONField( - default=dict, encoder=DjangoJSONEncoder, blank=True, null=True + default=dict, + encoder=DjangoJSONEncoder, + blank=True, + null=True, ) - value_csv = CSVField(blank=True, null=True) value_enum = models.ForeignKey( EnumValue, @@ -405,6 +446,7 @@ class Value(models.Model): related_name='eav_values', ) + # Value object relationship generic_value_id = models.IntegerField(blank=True, null=True) generic_value_ct = models.ForeignKey( @@ -416,42 +458,46 @@ class Value(models.Model): ) value_object = generic.GenericForeignKey( - ct_field='generic_value_ct', fk_field='generic_value_id' + ct_field='generic_value_ct', + fk_field='generic_value_id', ) - created = models.DateTimeField(_('Created'), default=timezone.now) - modified = models.DateTimeField(_('Modified'), auto_now=True) - - attribute = models.ForeignKey( - Attribute, db_index=True, on_delete=models.PROTECT, verbose_name=_('Attribute') - ) - - def save(self, *args, **kwargs): - """ - Validate and save this value. - """ - self.full_clean() - super(Value, self).save(*args, **kwargs) - - def _get_value(self): - """ - Return the python object this value is holding - """ - return getattr(self, 'value_%s' % self.attribute.datatype) - - def _set_value(self, new_value): - """ - Set the object this value is holding - """ - setattr(self, 'value_%s' % self.attribute.datatype, new_value) - - value = property(_get_value, _set_value) - def __str__(self): - return '{}: "{}" ({})'.format(self.attribute.name, self.value, self.entity) + """String representation of a Value.""" + entity = self.entity_pk_int + if self.entity_uuid: + entity = self.entity_pk_uuid + return '{0}: "{1}" ({2})'.format( + self.attribute.name, + self.value, + entity, + ) def __repr__(self): - return '{}: "{}" ({})'.format(self.attribute.name, self.value, self.entity.pk) + """Representation of Value object.""" + entity = self.entity_pk_int + if self.entity_uuid: + entity = self.entity_pk_uuid + return '{0}: "{1}" ({2})'.format( + self.attribute.name, + self.value, + entity.pk, + ) + + def save(self, *args, **kwargs): + """Validate and save this value.""" + self.full_clean() + super().save(*args, **kwargs) + + def _get_value(self): + """Return the python object this value is holding.""" + return getattr(self, 'value_{0}'.format(self.attribute.datatype)) + + def _set_value(self, new_value): + """Set the object this value is holding.""" + setattr(self, 'value_{0}'.format(self.attribute.datatype), new_value) + + value = property(_get_value, _set_value) # noqa: WPS110 class Entity(object): @@ -604,12 +650,13 @@ class Entity(object): return {v.attribute.slug: v.value for v in self.get_values()} def get_values(self): - """ - Get all set :class:`Value` objects for self.instance - """ - return Value.objects.filter( - entity_ct=self.ct, entity_id=self.instance.pk - ).select_related() + """Get all set :class:`Value` objects for self.instance.""" + entity_filter = { + 'entity_ct': self.ct, + '{0}'.format(get_entity_pk_type(self.instance)): self.instance.pk, + } + + return Value.objects.filter(**entity_filter).select_related() def get_all_attribute_slugs(self): """ diff --git a/eav/registry.py b/eav/registry.py index 4c3ba6d..19b80ae 100644 --- a/eav/registry.py +++ b/eav/registry.py @@ -6,6 +6,8 @@ from django.db.models.signals import post_init, post_save, pre_save from eav.managers import EntityManager from eav.models import Attribute, Entity, Value +from eav.logic.entity_pk import get_entity_pk_type + class EavConfig(object): """ @@ -149,9 +151,7 @@ class Registry(object): post_save.disconnect(Entity.post_save_handler, sender=self.model_cls) def _attach_generic_relation(self): - """ - Set up the generic relation for the entity - """ + """Set up the generic relation for the entity.""" rel_name = ( self.config_cls.generic_relation_related_name or self.model_cls.__name__ ) @@ -159,7 +159,7 @@ class Registry(object): gr_name = self.config_cls.generic_relation_attr.lower() generic_relation = generic.GenericRelation( Value, - object_id_field='entity_id', + object_id_field=get_entity_pk_type(self.model_cls), content_type_field='entity_ct', related_query_name=rel_name, ) diff --git a/test_project/migrations/0001_initial.py b/test_project/migrations/0001_initial.py index a933d3a..ca50039 100644 --- a/test_project/migrations/0001_initial.py +++ b/test_project/migrations/0001_initial.py @@ -1,3 +1,5 @@ +import uuid + from django.db import migrations, models from test_project.models import MAX_CHARFIELD_LEN @@ -137,4 +139,22 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='Doctor', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ('name', models.CharField(max_length=MAX_CHARFIELD_LEN)), + ], + options={ + 'abstract': False, + }, + ), ] diff --git a/test_project/models.py b/test_project/models.py index 937055c..4e8b33b 100644 --- a/test_project/models.py +++ b/test_project/models.py @@ -1,4 +1,5 @@ import sys +import uuid if sys.version_info >= (3, 8): from typing import Final, final @@ -24,6 +25,15 @@ class TestBase(models.Model): abstract = True +@final +@register_eav() +class Doctor(TestBase): + """Test model using UUID as primary key.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=MAX_CHARFIELD_LEN) + + @final class Patient(TestBase): name = models.CharField(max_length=MAX_CHARFIELD_LEN) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 3538dd7..011f311 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -5,7 +5,7 @@ import eav from eav.exceptions import IllegalAssignmentException from eav.models import Attribute, Value from eav.registry import EavConfig -from test_project.models import Encounter, Patient, RegisterTestModel +from test_project.models import Doctor, Encounter, Patient, RegisterTestModel class Attributes(TestCase): @@ -66,6 +66,11 @@ class Attributes(TestCase): self.assertEqual(t.eav.age, 6) self.assertEqual(t.eav.height, 10) + # Validate repr of Value for an entity with an INT PK + v1 = Value.objects.filter(entity_id=p.pk).first() + assert isinstance(repr(v1), str) + assert isinstance(str(v1), str) + def test_illegal_assignemnt(self): class EncounterEavConfig(EavConfig): @classmethod @@ -81,3 +86,16 @@ class Attributes(TestCase): with self.assertRaises(IllegalAssignmentException): e.eav.color = 'red' e.save() + + def test_uuid_pk(self): + """Tests for when model pk is UUID.""" + d1 = Doctor.objects.create(name='Lu') + d1.eav.age = 10 + d1.save() + + assert d1.eav.age == 10 + + # Validate repr of Value for an entity with a UUID PK + v1 = Value.objects.filter(entity_uuid=d1.pk).first() + assert isinstance(repr(v1), str) + assert isinstance(str(v1), str)