From 39e1ff35f7b64657781f2f618bd102716c557d9f Mon Sep 17 00:00:00 2001 From: Mauro Date: Sun, 4 Apr 2021 23:53:45 -0300 Subject: [PATCH] Adding a JSON Datatype --- eav/forms.py | 8 ++++ eav/migrations/0003_auto_20210404_2209.py | 29 +++++++++++++ eav/models.py | 18 +++++++- eav/validators.py | 14 +++++++ tests/data_validation.py | 9 ++++ tests/queries.py | 50 +++++++++++++++++++---- 6 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 eav/migrations/0003_auto_20210404_2209.py diff --git a/eav/forms.py b/eav/forms.py index b1f97b1..d718d20 100644 --- a/eav/forms.py +++ b/eav/forms.py @@ -8,6 +8,12 @@ from django.forms import (BooleanField, CharField, ChoiceField, DateTimeField, from django.utils.translation import ugettext_lazy as _ +try: + from django.forms import JSONField +except: + JSONField = CharField + + class BaseDynamicEntityForm(ModelForm): """ ``ModelForm`` for entity with support for EAV attributes. Form fields are @@ -28,6 +34,7 @@ class BaseDynamicEntityForm(ModelForm): int DateTimeField bool BooleanField enum ChoiceField + json JSONField ===== ============= """ FIELD_CLASSES = { @@ -37,6 +44,7 @@ class BaseDynamicEntityForm(ModelForm): 'date': DateTimeField, 'bool': BooleanField, 'enum': ChoiceField, + 'json': JSONField, } def __init__(self, data=None, *args, **kwargs): diff --git a/eav/migrations/0003_auto_20210404_2209.py b/eav/migrations/0003_auto_20210404_2209.py new file mode 100644 index 0000000..3a60970 --- /dev/null +++ b/eav/migrations/0003_auto_20210404_2209.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.6 on 2021-04-04 22:09 + +from django.db import migrations +import eav.fields +import django.core.serializers.json +try: + from django.db.models import JSONField +except: + from django.contrib.postgres.fields import JSONField + + +class Migration(migrations.Migration): + + dependencies = [ + ('eav', '0002_add_entity_ct_field'), + ] + + operations = [ + migrations.AddField( + model_name='value', + name='value_json', + field=JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + migrations.AlterField( + model_name='attribute', + name='datatype', + field=eav.fields.EavDatatypeField(choices=[('text', 'Text'), ('date', 'Date'), ('float', 'Float'), ('int', 'Integer'), ('bool', 'True / False'), ('object', 'Django Object'), ('enum', 'Multiple Choice'), ('json', 'JSON Object')], max_length=6, verbose_name='Data Type'), + ), + ] diff --git a/eav/models.py b/eav/models.py index 6975cd8..ab83d71 100644 --- a/eav/models.py +++ b/eav/models.py @@ -19,6 +19,16 @@ from django.db.models.base import ModelBase from django.utils import timezone from django.utils.translation import ugettext_lazy as _ +from django.core.serializers.json import DjangoJSONEncoder +if hasattr(models, "JSONField"): + JSONField = models.JSONField +else: + try: + from django.contrib.postgres.fields import JSONField + except: + JSONField = models.TextField + + from .validators import ( validate_text, validate_float, @@ -26,7 +36,8 @@ from .validators import ( validate_date, validate_bool, validate_object, - validate_enum + validate_enum, + validate_json ) from .exceptions import IllegalAssignmentException from .fields import EavDatatypeField, EavSlugField @@ -107,6 +118,7 @@ class Attribute(models.Model): * bool (TYPE_BOOLEAN) * object (TYPE_OBJECT) * enum (TYPE_ENUM) + * json (TYPE_JSON) Examples:: @@ -138,6 +150,7 @@ class Attribute(models.Model): TYPE_BOOLEAN = 'bool' TYPE_OBJECT = 'object' TYPE_ENUM = 'enum' + TYPE_JSON = 'json' DATATYPE_CHOICES = ( (TYPE_TEXT, _('Text')), @@ -147,6 +160,7 @@ class Attribute(models.Model): (TYPE_BOOLEAN, _('True / False')), (TYPE_OBJECT, _('Django Object')), (TYPE_ENUM, _('Multiple Choice')), + (TYPE_JSON, _('JSON Object')), ) # Core attributes @@ -248,6 +262,7 @@ class Attribute(models.Model): 'bool': validate_bool, 'object': validate_object, 'enum': validate_enum, + 'json': validate_json, } return [DATATYPE_VALIDATORS[self.datatype]] @@ -382,6 +397,7 @@ class Value(models.Model): value_int = models.IntegerField(blank = True, null = True) value_date = models.DateTimeField(blank = True, null = True) value_bool = models.NullBooleanField(blank = True, null = True) + value_json = JSONField(default=dict, encoder=DjangoJSONEncoder, blank = True, null = True) value_enum = models.ForeignKey( EnumValue, diff --git a/eav/validators.py b/eav/validators.py index 0186482..23b484c 100644 --- a/eav/validators.py +++ b/eav/validators.py @@ -10,6 +10,7 @@ These validators are called by the :class:`~eav.models.Attribute` model. """ +import json import datetime from django.core.exceptions import ValidationError @@ -83,3 +84,16 @@ def validate_enum(value): if isinstance(value, EnumValue) and not value.pk: raise ValidationError(_(u"EnumValue has not been saved yet")) + + +def validate_json(value): + """ + Raises ``ValidationError`` unless *value* can be cast as an ``json object`` (a dict) + """ + try: + if isinstance(value, str): + value = json.loads(value) + if not isinstance(value, dict): + raise ValidationError(_(u"Must be a JSON Serializable object")) + except ValueError: + raise ValidationError(_(u"Must be a JSON Serializable object")) diff --git a/tests/data_validation.py b/tests/data_validation.py index f179519..85748bf 100644 --- a/tests/data_validation.py +++ b/tests/data_validation.py @@ -21,6 +21,7 @@ class DataValidation(TestCase): Attribute.objects.create(name='City', datatype=Attribute.TYPE_TEXT) Attribute.objects.create(name='Pregnant?', datatype=Attribute.TYPE_BOOLEAN) Attribute.objects.create(name='User', datatype=Attribute.TYPE_OBJECT) + Attribute.objects.create(name='Extra', datatype=Attribute.TYPE_JSON) def tearDown(self): eav.unregister(Patient) @@ -186,3 +187,11 @@ class DataValidation(TestCase): ynu.values.add(unkown) a = Attribute(name='color', datatype=Attribute.TYPE_TEXT, enum_group=ynu) self.assertRaises(ValidationError, a.save) + + def test_json_validation(self): + p = Patient.objects.create(name='Joe') + p.eav.extra = 5 + self.assertRaises(ValidationError, p.save) + p.eav.extra = {"eyes": "blue", "hair": "brown"} + p.save() + self.assertEqual(Patient.objects.get(pk=p.pk).eav.extra.get("eyes", ""), "blue") diff --git a/tests/queries.py b/tests/queries.py index cd5c54b..0fcb2f7 100644 --- a/tests/queries.py +++ b/tests/queries.py @@ -20,6 +20,7 @@ class Queries(TestCase): Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) Attribute.objects.create(name='city', datatype=Attribute.TYPE_TEXT) Attribute.objects.create(name='country', datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name='extras', datatype=Attribute.TYPE_JSON) self.yes = EnumValue.objects.create(value='yes') self.no = EnumValue.objects.create(value='no') @@ -41,12 +42,12 @@ class Queries(TestCase): no = self.no data = [ - # Name, age, fever, city, country. - ['Anne', 3, no, 'New York', 'USA'], - ['Bob', 15, no, 'Bamako', 'Mali'], - ['Cyrill', 15, yes, 'Kisumu', 'Kenya'], - ['Daniel', 3, no, 'Nice', 'France'], - ['Eugene', 2, yes, 'France', 'Nice'] + # Name, age, fever, city, country, extras + ['Anne', 3, no, 'New York', 'USA', {"chills": "yes"}], + ['Bob', 15, no, 'Bamako', 'Mali', {}], + ['Cyrill', 15, yes, 'Kisumu', 'Kenya', {"chills": "yes", "headache": "no"}], + ['Daniel', 3, no, 'Nice', 'France', {"headache": "yes"}], + ['Eugene', 2, yes, 'France', 'Nice', {"chills": "no", "headache": "yes"}] ] for row in data: @@ -55,7 +56,8 @@ class Queries(TestCase): eav__age=row[1], eav__fever=row[2], eav__city=row[3], - eav__country=row[4] + eav__country=row[4], + eav__extras=row[5] ) def test_get_or_create_with_eav(self): @@ -81,7 +83,7 @@ class Queries(TestCase): # Check number of objects in DB. self.assertEqual(Patient.objects.count(), 5) - self.assertEqual(Value.objects.count(), 20) + self.assertEqual(Value.objects.count(), 25) # Nobody q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no) @@ -149,6 +151,38 @@ class Queries(TestCase): p = Patient.objects.filter(q1) self.assertEqual(p.count(), 1) + # Extras: Chills + # Without + q1 = Q(eav__extras__has_key="chills") + p = Patient.objects.exclude(q1) + self.assertEqual(p.count(), 2) + + # With + q1 = Q(eav__extras__has_key="chills") + p = Patient.objects.filter(q1) + self.assertEqual(p.count(), 3) + + # No chills + q1 = Q(eav__extras__chills="no") + p = Patient.objects.filter(q1) + self.assertEqual(p.count(), 1) + + # Has chills + q1 = Q(eav__extras__chills="yes") + p = Patient.objects.filter(q1) + self.assertEqual(p.count(), 2) + + # Extras: Empty + # Yes + q1 = Q(eav__extras={}) + p = Patient.objects.filter(q1) + self.assertEqual(p.count(), 1) + + # No + q1 = Q(eav__extras={}) + p = Patient.objects.exclude(q1) + self.assertEqual(p.count(), 4) + def _order(self, ordering): query = Patient.objects.all().order_by(*ordering) return list(query.values_list('name', flat=True))