Использование `search = term1, term2` для сопоставления нескольких тегов для одного и того же объекта с использованием DRF - PullRequest
1 голос
/ 20 февраля 2020

Я поднимаю старую Django 1.11 кодовую базу до последних версий Django и Django Rest Framework, но я столкнулся с жесткой стеной вокруг того, как работает фильтр ?search=... при использовании нескольких терминов в последние версии Django Rest Framework.

Вплоть до версии 3.6.3 DRF можно было выполнять запрос конечной точки ?search=term1,term2 и иметь возвращаемые объекты DRF с отношениями "многие ко многим", в которых оба условия поиска соответствует одному и тому же имени поля, например, если модель имеет поле «многие ко многим», называемое tags, относящееся к некоторой модели Tag, то объект с тегами cake и baker может быть найден DRF, запросив ?search=cake,baker.

В кодовой базе, которую я поднимаю, (сокращенный) код для этого выглядит следующим образом:

class TagQuerySet(models.query.QuerySet):
    def public(self):
        return self

class Tag(models.Model):
    name = models.CharField(unique=True, max_length=150)
    objects = TagQuerySet.as_manager()
    def _get_entry_count(self):
        return self.entries.count()
    entry_count = property(_get_entry_count)
    def __str__(self):
        return str(self.name)
    class Meta:
        ordering = ['name',]

class Entry(models.Model):
    title = models.CharField(max_length=140)
    description = models.CharField(max_length=600, blank=True)
    tags = models.ManyToManyField(Tag, related_name='entries', blank=True)
    def __str__(self):
        return str(self.title)
    class Meta:
        verbose_name_plural = "entries"
        ordering = ['-id']

class EntryCustomFilter(filters.FilterSet):
    tag = django_filters.CharFilter(name='tags__name', lookup_expr='iexact', )
    class Meta:
        model = Entry
        fields = [ 'tags', ]

class EntriesListView(ListCreateAPIView):
    """
    - `?search=` - Searches title, description, and tags
    - `&format=json` - return results in JSON rather than HTML format
    """
    filter_backends = (filters.DjangoFilterBackend, filters.SearchFilter, )
    filter_class = EntryCustomFilter
    search_fields = ('title', 'description', 'tags__name', )
    parser_classes = ( JSONParser, )

Однако такое поведение для search произошло непреднамеренно изменено в 3.6.4 , так что вместо этого DRF теперь соответствует, только если одиночное отношение , найденное через поле "многие ко многим", соответствует всем терминам. Таким образом, запись с полем tags, имеющая отношение к Tag(name="cake") и Tag(name="baker"), больше не совпадает, так как нет единственного тега, который соответствует обоим терминам, но имеется запись с Tag(name="baker of cake") и Tag(name="teller of tales") соответствует, так как существует одно отношение, которое соответствует обоим терминам.

Нет (по крайней мере, на момент написания) никакой документации, которую я могу найти, которая объясняет, как добиться этого более старого поведения для Фильтр generi c search, и я не могу найти здесь ранее задаваемые вопросы о Stackoverflow о том, как заставить DRF работать снова (или даже «вообще»). Есть некоторые вопросы по поводу указанных c фильтров с именами полей, но не для search=.

Итак: какие изменения я могу внести, чтобы ?search=... продолжал работать как прежде, при использовании версии DRF 3.6.4+? Т.е. как можно заставить фильтр ?search=term1,term2 находить модели, в которых поля «многие ко многим» имеют отдельные отношения, которые соответствуют одному или нескольким из указанных терминов?

1 Ответ

2 голосов
/ 25 февраля 2020

Это ожидаемое поведение в DRF, введенное для оптимизации поиска / фильтра M2M, начиная с 3.6.4 . Причина, по которой это было введено, состояла в том, чтобы предотвратить комбинаторный взрыв при использовании более чем одного термина (см. «Время SearchFilter увеличивается экспоненциально на количество поисковых терминов» и связанный с ним PR ». Исправьте поведение SearchFilter для многих. / performance " для более подробной информации).

Чтобы выполнить сопоставление того же типа, что и в 3.6.3 и ниже, вам нужно создать собственный класс фильтра поиска, расширив filters.SearchFilter, и добавьте пользовательскую реализацию для определения filter_queryset (исходное определение можно найти здесь для DRF v3.6.3).

from rest_framework import filters
import operator
from functools import reduce
from django.db import models
from rest_framework.compat import distinct


class CustomSearchFilter(filters.SearchFilter):

    def required_m2m_optimization(self, view):
        return getattr(view, 'use_m2m_optimization', True)

    def get_search_fields(self, view, request):
        # For DRF versions >=3.9.2 remove this method,
        # as it already has get_search_fields built in.
        return getattr(view, 'search_fields', None)

    def chained_queryset_filter(self, queryset, search_terms, orm_lookups):
        for search_term in search_terms:
            queries = [
                models.Q(**{orm_lookup: search_term})
                for orm_lookup in orm_lookups
            ]
            queryset = queryset.filter(reduce(operator.or_, queries))
        return queryset

    def optimized_queryset_filter(self, queryset, search_terms, orm_lookups):
        conditions = []
        for search_term in search_terms:
            queries = [
                models.Q(**{orm_lookup: search_term})
                for orm_lookup in orm_lookups
            ]
            conditions.append(reduce(operator.or_, queries))

        return queryset.filter(reduce(operator.and_, conditions))

    def filter_queryset(self, request, queryset, view):
        search_fields = self.get_search_fields(view, request)
        search_terms = self.get_search_terms(request)

        if not search_fields or not search_terms:
            return queryset

        orm_lookups = [
            self.construct_search(str(search_field))
            for search_field in search_fields
        ]

        base = queryset
        if self.required_m2m_optimization(view):
            queryset = self.optimized_queryset_filter(queryset, search_terms, orm_lookups)
        else:
            queryset = self.chained_queryset_filter(queryset, search_terms, orm_lookups)

        if self.must_call_distinct(queryset, search_fields):
            # Filtering against a many-to-many field requires us to
            # call queryset.distinct() in order to avoid duplicate items
            # in the resulting queryset.
            # We try to avoid this if possible, for performance reasons.
            queryset = distinct(queryset, base)
        return queryset

Затем замените filters.Searchfilter в вашем filter_backends с этим пользовательским классом:

class EntriesListView(ListCreateAPIView):
    filter_backends = (
        filters.DjangoFilterBackend,
        CustomSearchFilter,
        ...
    )
    use_m2m_optimization = False  # this attribute control the search results
    ...
...