3

I read on several places that it is not possible to filter Django querysets using properties because Django ORM would have no idea how to convert those into SQL.

However, once the data are fetched and loaded into memory, it shall be possible to filter them in Python using those properties.

And my question: is there any library that allows the querysets to be filtered by properties in memory? And if not, how exactly must the querysets be tampered with so that this becomes possible? And how to include django-filter into this?

karlosss
  • 2,816
  • 7
  • 26
  • 42

4 Answers4

3

Have you got difficult property or not? If not you can rewrite it to queryset like this:

from django.db import models

class UserQueryset(models.Manager):

    def get_queryset(self):

        return super().get_queryset().annotate(
            has_profile=models.Exists(Profile.objects.filter(user_id=models.OuterRef('id')))
        )

class User(models.Model):
    objects = UserQueryset


class Profile(models.Model):
    user = models.OneToOneField(User, related_name='profile')


# When you want to filter by has profile just use it like has field has profile

user_with_profiles = User.objects.filter(has_profile=True)

Maybe it is not what you want, but it can help you in some cases

Benedikt S. Vogler
  • 554
  • 1
  • 5
  • 19
Andrei Berenda
  • 1,946
  • 2
  • 13
  • 27
  • I would like to have a general API for all kinds of properties so yes, I might have difficult ones. – karlosss Apr 02 '19 at 13:02
  • @karloss, I would suggest that the generic API is Django ORM itself. Almost every time you push a filter to the Database through the Django ORM, it is going to be faster than loading up that data and then filtering it yourself in python. Not because python is too slow, but because you're pulling more data out of the database instead of filtering it inside the database. Writing a custom `django_filters.Filter` for odd inferred properties isn't that bad. – Ross Rogers Apr 02 '19 at 16:46
  • @karloss, you could also generate annotations and filters on the fly. Create new filters and then filterset using [Metaclasses](https://realpython.com/python-metaclasses/). I've done this to great effect with Django Rest Framework in order to let the client trim down the number of fields coming back to itself from the server backend. – Ross Rogers Apr 02 '19 at 16:49
1

django-filter wants and assumes that you are using querysets. Once you take a queryset and change it into a list, then whatever is downstream needs to be able to handle just a list or just iterate through the list, which is no longer a queryset.

If you have a django_filters.FilterSet like:

class FooFilterset(django_filters.FilterSet):
    bar = django_filters.Filter('updated', lookup_expr='exact')
    my_property_filter = MyPropertyFilter('property')
    class Meta:
        model = Foo
        fields = ('bar',  'my_property_filter')

then you can write MyPropertyFilter like:

class MyPropertyFilter(django_filters.Filter):
    def filter(self, qs, value):
        return [row for row in qs if row.baz == value]

At this point, anything downstream of MyProperteyFilter will have a list.

Note: I believe the order of fields should have your custom filter, MyPropertyFilter last, because then it will always be processed after the normal queryset filters.


So, you have just broken the "queryset" API, for certain values of broken. At this point, you'll have to work through the errors of whatever is downstream. If whatever is after the FilterSet requires a .count member, you can change MyPropertyFilter like:

class MyPropertyFilter(django_filters.Filter):
    def filter(self, qs, value):
        result = [row for row in qs if row.baz == value]
        result.count = len(result)
        return result

You're in uncharted territory, and you'll have to hack your way through.

Anyways, I've done this before and it isn't horrible. Just take the errors as they come.

Ross Rogers
  • 23,523
  • 27
  • 108
  • 164
  • 1
    Isn't it better to return a QuerySet instead of a list? – karlosss Apr 02 '19 at 12:59
  • Sure it's better to return a queryset. But you're saying that you can't. If you cannot do the filtering on the database, then you must do the filtering afterwards. A queryset is something that can absolutely be executed on a database. If you can change your property to somehow be executed on the database, then you avoid this altogether. If, however, you must do filtering in actual python, then you have violated the essence of a queryset and it is no longer a queryset. – Ross Rogers Apr 02 '19 at 13:02
  • Sorry, maybe I did not express myself well. Can I do something like `return QuerySet([row for row in qs if row.baz ==value])` so that it has all the methods like `.count`, `.filter` and so on? Of course, they would all just work in-memory and won't hit the database at all, or at least this is my idea. May I get this somehow to work? – karlosss Apr 02 '19 at 13:06
  • There isn't a default way to change `list` back into a `QuerySet`. `.count` is easy in the list case, but what about `.filter()` ? Django ORM doesn't know how to do filtering on a `list`, it only does filtering on the database. Anyways, each part of the `QuerySet` API, _that your downstream code is using_, will need to be implemented. If you order the filters correctly, you may be fine with just putting your `QuerySet` -> `list` filter at the end of the order of `fields`. – Ross Rogers Apr 02 '19 at 13:10
1

Since filtering by non-field attributes such as property inevitably converts the QuerySet to list (or similar), I like to postpone it and do the filtering on object_list in get_context_data method. To keep the filtering logic inside the filterset class, I use a simple trick. I've defined a decorator

def attr_filter(func):

    def wrapper(self, queryset, name, value, force=False, *args, **kwargs):
        if force:
            return func(self, queryset, name, value, *args, **kwargs)
        else:
            return queryset
    return wrapper

which is used on django-filter non-field filtering methods. Thanks to this decorator, the filtering basically does nothing (or skips) the non-field filtering methods (because of force=False default value).

Next, I defined a Mixin to be used in the view class.

    class FilterByAttrsMixin:
    
        def get_context_data(self, **kwargs):
            context = super().get_context_data(**kwargs)
            filtered_list = self.filter_qs_by_attributes(self.object_list, self.filterset)
            context.update({
                'object_list': filtered_list,
            })
            return context
    
        def filter_qs_by_attributes(self, queryset, filterset_instance):
            if hasattr(filterset_instance.form, 'cleaned_data'):
                for field_name in filter_instance.filters:
                    method_name = f'attr_filter_{field_name}'
                    if hasattr(filterset_instance, method_name):
                        value = filterset_instance.form.cleaned_data[field_name]
                        if value:
                            queryset = getattr(filterset_instance, filter_method_name)(queryset, field_name, value, force=True)
            return queryset

It basically just returns to your filterset and runs all methods called attr_filter_<field_name>, this time with force=True.

In summary, you need to:

  • Inherit the FilterByAttrsMixin in your view class
  • call your filtering method attr_filter_<field_name>
  • use attr_filter decorator on the filtering method

Simple example (given that I have model called MyModel with property called is_static that I want to filter by:

model:

class MyModel(models.Model):
    ...

@property
def is_static(self):
    ...

view:

class MyFilterView(FilterByAttrsMixin, django_filters.views.FilterView):
    ...
    filterset_class = MyFiltersetClass
    ...

filter:

class MyFiltersetClass(django_filters.FilterSet):
    is_static = django_filters.BooleanFilter(
        method='attr_filter_is_static',
    )

    class Meta:
        model = MyModel
        fields = [...]

    @attr_filter
    def attr_filter_is_static(self, queryset, name, value):
        return [instance for instance in queryset if instance.is_static]
barneytabor
  • 33
  • 1
  • 6
1

Take a look at django-property-filter package. This is an extension to django-filter and provides functionality to filter querysets by class properties.

Short example from the documentation:

from django_property_filter import PropertyNumberFilter, PropertyFilterSet

class BookFilterSet(PropertyFilterSet):
    prop_number = PropertyNumberFilter(field_name='discounted_price', lookup_expr='gte')

    class Meta:
        model = NumberClass
        fields = ['prop_number']