From 92ba7992d70a168cc0d647a842d729cc91b267d3 Mon Sep 17 00:00:00 2001 From: Siegmeyer Date: Fri, 21 Sep 2018 13:47:03 +0200 Subject: [PATCH] Add support for order_by clause to EavQuerySet (#39) --- eav/queryset.py | 94 +++++++++++++++++++++++++++++++++++++++++- tests/forms.py | 2 +- tests/models.py | 2 + tests/queries.py | 105 ++++++++++++++++++++++++++++++++++------------- 4 files changed, 171 insertions(+), 32 deletions(-) diff --git a/eav/queryset.py b/eav/queryset.py index a01eedb..0a1e13f 100644 --- a/eav/queryset.py +++ b/eav/queryset.py @@ -19,12 +19,14 @@ Q-expressions need to be rewritten for two reasons: 2. To ensure that Q-expression tree is compiled to valid SQL. For details see: :func:`rewrite_q_expr`. """ - +from itertools import count from functools import wraps +from django.core.exceptions import FieldError, ObjectDoesNotExist from django.db import models -from django.db.models import Q +from django.db.models import Case, IntegerField, Q, When from django.db.models.query import QuerySet +from django.db.utils import NotSupportedError from .models import Attribute, Value @@ -282,3 +284,91 @@ class EavQuerySet(QuerySet): the ``Manager`` get method. """ return super(EavQuerySet, self).get(*args, **kwargs) + + def order_by(self, *fields): + # Django only allows to order querysets by direct fields and + # foreign-key chains. In order to bypass this behaviour and order + # by EAV attributes, it is required to construct custom order-by + # clause manually using Django's conditional expressions. + # This will be slow, of course. + order_clauses = [] + query_clause = self + + for term in [t.split('__') for t in fields]: + # Continue only for EAV attributes. + if len(term) == 2 and term[0] == 'eav': + # Retrieve Attribute over which the ordering is performed. + try: + attr = Attribute.objects.get(slug=term[1]) + except ObjectDoesNotExist: + raise ObjectDoesNotExist( + 'Cannot find EAV attribute "{}"'.format(term[1]) + ) + + field_name = 'value_%s' % attr.datatype + + pks_values = Value.objects.filter( + # Retrieve pk-values pairs of the related values + # (i.e. values for the specified attribute and + # belonging to entities in the queryset). + attribute__slug=attr.slug, + entity_id__in=self + ).order_by( + # Order values by their value-field of + # appriopriate attribute data-type. + field_name + ).values_list( + # Retrieve only primary-keys of the entities + # in the current queryset. + 'entity_id', field_name + ) + + # Retrive ordered values from pk-value list. + _, ordered_values = zip(*pks_values) + + # Add explicit ordering and turn + # list of pairs into look-up table. + val2ind = dict(zip(ordered_values, count())) + + # Finally, zip ordered pks with their grouped orderings. + entities_pk = [(pk, val2ind[val]) for pk, val in pks_values] + + # Using ordered primary-keys, construct + # CASE clause of the form: + # + # CASE + # WHEN id = 2 THEN 1 + # WHEN id = 5 THEN 2 + # WHEN id = 9 THEN 2 + # WHEN id = 4 THEN 3 + # END + # + when_clauses = [ + When(id=pk, then=i) + for pk, i in entities_pk + ] + + order_clause = Case( + *when_clauses, + output_field=IntegerField() + ) + + clause_name = '__'.join(term) + # Use when-clause to construct + # custom order-by clause. + query_clause = query_clause.annotate( + **{clause_name: order_clause} + ) + + order_clauses.append(clause_name) + + elif len(term) >= 2 and term[0] == 'eav': + raise NotSupportedError( + 'EAV does not support ordering through ' + 'foreign-key chains' + ) + + else: + order_clauses.append(term[0]) + + return QuerySet.order_by(query_clause, *order_clauses) diff --git a/tests/forms.py b/tests/forms.py index e1ae486..9fa6f04 100644 --- a/tests/forms.py +++ b/tests/forms.py @@ -79,7 +79,7 @@ class Forms(TestCase): admin.form = BaseDynamicEntityForm view = admin.change_view(request, str(self.instance.pk)) - own_fields = 1 + own_fields = 2 adminform = view.context_data['adminform'] self.assertEqual( diff --git a/tests/models.py b/tests/models.py index 9af15e1..0375212 100644 --- a/tests/models.py +++ b/tests/models.py @@ -4,6 +4,8 @@ from eav.decorators import register_eav class Patient(models.Model): name = models.CharField(max_length=12) + example = models.ForeignKey( + 'ExampleModel', null=True, blank=True, on_delete=models.PROTECT) def __str__(self): return self.name diff --git a/tests/queries.py b/tests/queries.py index be4ed15..b6e1f54 100644 --- a/tests/queries.py +++ b/tests/queries.py @@ -1,5 +1,6 @@ -from django.core.exceptions import MultipleObjectsReturned -from django.db.models import Q +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import Q, Model +from django.db.utils import NotSupportedError from django.test import TestCase import eav @@ -34,25 +35,7 @@ class Queries(TestCase): eav.unregister(Encounter) eav.unregister(Patient) - def test_get_or_create_with_eav(self): - Patient.objects.get_or_create(name='Bob', eav__age=5) - self.assertEqual(Patient.objects.count(), 1) - self.assertEqual(Value.objects.count(), 1) - Patient.objects.get_or_create(name='Bob', eav__age=5) - self.assertEqual(Patient.objects.count(), 1) - self.assertEqual(Value.objects.count(), 1) - Patient.objects.get_or_create(name='Bob', eav__age=6) - self.assertEqual(Patient.objects.count(), 2) - self.assertEqual(Value.objects.count(), 2) - - def test_get_with_eav(self): - p1, _ = Patient.objects.get_or_create(name='Bob', eav__age=6) - self.assertEqual(Patient.objects.get(eav__age=6), p1) - - Patient.objects.create(name='Fred', eav__age=6) - self.assertRaises(MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6)) - - def test_filtering_on_normal_and_eav_fields(self): + def init_data(self): yes = self.yes no = self.no @@ -74,12 +57,33 @@ class Queries(TestCase): eav__country=row[4] ) + def test_get_or_create_with_eav(self): + Patient.objects.get_or_create(name='Bob', eav__age=5) + self.assertEqual(Patient.objects.count(), 1) + self.assertEqual(Value.objects.count(), 1) + Patient.objects.get_or_create(name='Bob', eav__age=5) + self.assertEqual(Patient.objects.count(), 1) + self.assertEqual(Value.objects.count(), 1) + Patient.objects.get_or_create(name='Bob', eav__age=6) + self.assertEqual(Patient.objects.count(), 2) + self.assertEqual(Value.objects.count(), 2) + + def test_get_with_eav(self): + p1, _ = Patient.objects.get_or_create(name='Bob', eav__age=6) + self.assertEqual(Patient.objects.get(eav__age=6), p1) + + Patient.objects.create(name='Fred', eav__age=6) + self.assertRaises(MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6)) + + def test_filtering_on_normal_and_eav_fields(self): + self.init_data() + # Check number of objects in DB. self.assertEqual(Patient.objects.count(), 5) self.assertEqual(Value.objects.count(), 20) # Nobody - q1 = Q(eav__fever=yes) & Q(eav__fever=no) + q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no) p = Patient.objects.filter(q1) self.assertEqual(p.count(), 0) @@ -90,26 +94,26 @@ class Queries(TestCase): self.assertEqual(p.count(), 2) # Anne - q1 = Q(eav__city__contains='Y') & Q(eav__fever=no) + q1 = Q(eav__city__contains='Y') & Q(eav__fever=self.no) q2 = Q(eav__age=3) p = Patient.objects.filter(q1 & q2) self.assertEqual(p.count(), 1) # Anne, Daniel - q1 = Q(eav__city__contains='Y', eav__fever=no) + q1 = Q(eav__city__contains='Y', eav__fever=self.no) q2 = Q(eav__city='Nice') q3 = Q(eav__age=3) p = Patient.objects.filter((q1 | q2) & q3) self.assertEqual(p.count(), 2) # Everyone - q1 = Q(eav__fever=no) | Q(eav__fever=yes) + q1 = Q(eav__fever=self.no) | Q(eav__fever=self.yes) p = Patient.objects.filter(q1) self.assertEqual(p.count(), 5) # Anne, Bob, Daniel - q1 = Q(eav__fever=no) # Anne, Bob, Daniel - q2 = Q(eav__fever=yes) # Cyrill, Eugene + q1 = Q(eav__fever=self.no) # Anne, Bob, Daniel + q2 = Q(eav__fever=self.yes) # Cyrill, Eugene q3 = Q(eav__country__contains='e') # Cyrill, Daniel, Eugene q4 = q2 & q3 # Cyrill, Daniel, Eugene q5 = (q1 | q4) & q1 # Anne, Bob, Daniel @@ -123,7 +127,7 @@ class Queries(TestCase): # Anne, Bob, Daniel q1 = Q(eav__city__contains='Y') - q2 = Q(eav__fever=no) + q2 = Q(eav__fever=self.no) q3 = q1 | q2 p = Patient.objects.filter(q3) self.assertEqual(p.count(), 3) @@ -134,6 +138,49 @@ class Queries(TestCase): self.assertEqual(p.count(), 2) # Eugene - q1 = Q(name__contains='E', eav__fever=yes) + q1 = Q(name__contains='E', eav__fever=self.yes) p = Patient.objects.filter(q1) self.assertEqual(p.count(), 1) + + def test_order_by(self): + def order(ordering): + query = Patient.objects.all().order_by(*ordering) + return list(query.values_list('name', flat=True)) + + self.init_data() + + self.assertEqual( + ['Bob', 'Eugene', 'Cyrill', 'Anne', 'Daniel'], + order(['eav__city']) + ) + + self.assertEqual( + ['Eugene', 'Anne', 'Daniel', 'Bob', 'Cyrill'], + order(['eav__age', 'eav__city']) + ) + + self.assertEqual( + ['Eugene', 'Cyrill', 'Anne', 'Daniel', 'Bob'], + order(['eav__fever', 'eav__age']) + ) + + self.assertEqual( + ['Eugene', 'Cyrill', 'Daniel', 'Bob', 'Anne'], + order(['eav__fever', '-name']) + ) + + self.assertEqual( + ['Eugene', 'Daniel', 'Cyrill', 'Bob', 'Anne'], + order(['-name', 'eav__age']) + ) + + self.assertEqual( + ['Anne', 'Bob', 'Cyrill', 'Daniel', 'Eugene'], + order(['example__name']) + ) + + with self.assertRaises(NotSupportedError): + Patient.objects.all().order_by('eav__first__second') + + with self.assertRaises(ObjectDoesNotExist): + Patient.objects.all().order_by('eav__nonsense')