Add support for Entity models with UUID as a primary key. (#89)

* chore: fix repo links

* style: flake8 format models.Value

* test: add test for an entity with uuid pk

* feat: support entity models with uuid as pk

* test: check Value repr and str methods
This commit is contained in:
Mike 2021-10-22 22:36:17 +00:00 committed by GitHub
parent edd050dd98
commit a98766fc55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 190 additions and 61 deletions

0
eav/logic/__init__.py Normal file
View file

12
eav/logic/entity_pk.py Normal file
View file

@ -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'

View file

@ -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),
),
]

View file

@ -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):
# = <Value: crazy_dev_user - Fav Drink: "red bull">
"""
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):
"""

View file

@ -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,
)

View file

@ -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,
},
),
]

View file

@ -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)

View file

@ -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)