Django: Third Party Library: django-filter

22nd April 2021 at 1:22pm
Django: Third Party Library

django-filter 库针对的场景是:根据用户的请求参数(HTTP query param)来筛选出返回的数据。它做的事情有:

  • 校验请求参数(参数由 Filter 定义)
  • 按用户设定的筛选规则构建 queryset
    • 这一步是 核心,将过程式的筛选逻辑变成声明式
  • 形成数据库结果集

django-filter 还可以用于 Django REST Framework

使用示例

# 通过 FilterSet 定义用于筛选的请求参数。这个例子支持这些参数:
# price__lt, price__gt, release_date, release_date__year__gt
class ProductFilter(django_filters.FilterSet):
    class Meta:
        model = Product
        fields = {
            'price': ['lt', 'gt'],
            'release_date': ['exact', 'year__gt'],
        }

# View 函数实现
def product_list(request):
    # 将用户请求参数(request.GET)及基础 queryset 提供给 ProductFilter
    f = ProductFilter(request.GET, queryset=Product.objects.all())
    return render(request, 'my_app/template.html', {'filter': f})

ProductFilter 会提供一个 Form(.form),也可以通过其 .qs 访问到其根据请求参数生成的 queryset。模版的示例如下:

{% extends "base.html" %}

{% block content %}
    <form method="get">
        {{ filter.form.as_p }}
        <input type="submit" />
    </form>
    {% for obj in filter.qs %}
        {{ obj.name }} - ${{ obj.price }}<br />
    {% endfor %}
{% endblock %}

核心概念

django-filter 的核心概念有:

  • FilterSet:表示一组 filter
  • Filter:
    • 每个 filter 代表一个过滤条件
    • Filter 有类型,如 BooleanFilter,它们有对应的 Django form field,如 django.forms.fields.NullBooleanField
    • django-filter 使用 Django Form 的能力对请求参数做 validation

至于 Widget 和 Form 的能力,由于我不使用 Django 输出 HTML,没有去关注。

仅显示用户自己发表的内容

在 UGC 应用中,经常需要拉取用户自身的内容。这个可以通过 编写 一个自定义的 base queryset 来实现:

class ArticleFilter(django_filters.FilterSet):
    class Meta:
        model = Article
        fields = [...]

    @property
    def qs(self):
        parent = super().qs
        author = getattr(self.request, 'user', None)

        return parent.filter(author=author)

对单个 Filter 实现自定义的筛选逻辑

通过 Filter 类提供的 method 参数:

class F(FilterSet):
    """Filter for Books by if books are published or not"""
    published = BooleanFilter(field_name='published_on', method='filter_published')

    def filter_published(self, queryset, name, value):
        # construct the full lookup expression.
        lookup = '__'.join([name, 'isnull'])
        return queryset.filter(**{lookup: False})

        # alternatively, you could opt to hardcode the lookup. e.g.,
        # return queryset.filter(published_on__isnull=False)

    class Meta:
        model = Book
        fields = ['published']

通过多个 Filter 做组合筛选

django-filter 并没有给出解决方案。可能的方法有以下几种,但都不是很理想。Django Form 你可以实现一个 clean() 函数来做跨多个 field 的 validation,比如 field A 的值为 "email" 时 field B 必须是个合法的 Email 地址。django-filter 应该实现一个类似的机制。

实现自定义的 base queryset

为你的 FilterSet 实现 一个 base queryset。框架在调用 FilterSet.qs 时,已经对请求参数(Filter 来表达)做过校验了。缺点是 .qs 是用来表达 base queryset 的,而不是用来做筛选逻辑的。

通过 Filter 的 method 做 hack

django-filter 调用各 filter 定义的 method 参数来构建 queryset 时,各 filter(请求参数)已经通过 validation 了。可以用这样的方式做 hack:

class NoteFilterSet(filters.FilterSet):
    topic = filters.ModelChoiceFilter(queryset=Topic.objects.all(), method='filter_topic', label='Topic')
    recursive = filters.BooleanFilter(label='Recursive', method='filter_recursive')

    def filter_topic(self, queryset, name, value):
        """
        根据 topic 筛选 note。如果请求参数中 recursive 为 true,则筛选指定的 topic 及其子 topic。
        django-filter 不支持多 filter 同时起作用,因此这里用了 self.form 来获取 recursive 的值。
        """
        recursive = self.form.cleaned_data.get('recursive')
        if recursive:
            return queryset.filter(topics__in=list(value.descendants(include_self=True)))
        else:
            return queryset.filter(topics__in=[value])

    def filter_recursive(self, queryset, name, value):
        """
        这里的 recursive 参数不单独做 filter,它在 filter_topic 中被使用。
        使用这个函数使得 django-filter 不尝试去为 recursive 寻找相对应的 field。
        """
        return queryset

并不优雅,但是可以运行。

不在 django filter 上做此类逻辑

比如 DRF 搭配 django-filter 时,可以在 DRF 的 get_queryset() 做这种逻辑。好处是灵活;代价是可能需要自己实现 query param 的校验逻辑。