DEV Community

Cover image for Full-Text Search: Criando um Back-End de Filtro para o Django Rest-Framework
Eduardo Oliveira
Eduardo Oliveira

Posted on • Edited on

7 2 2 1 1

Full-Text Search: Criando um Back-End de Filtro para o Django Rest-Framework

O texto Full-Text Search: Implementando com Postgres e Django [1] comenta sobre a implementação do sistema de Full-Text Search do Postgres, trazido pelo Leandro Proença no texto A powerful full-text search in PostgreSQL in less than 20 lines [2], utilizando o django.

O projeto está no GitHub [3] e, para complementá-lo, esse texto tem por objetivo, construir um back-end de filtro, i.e. um adapter de filtro, para lidar com o full-text search, como no algoritmo do texto anterior dentro do rest-framework.

Pra poder adicionar esse suporte, da melhor forma possível, podemos criar um filter back-end customizado. São utilizados, como referência, o SearchFilter original do django [4] e [5].


Mostre-me o código

O código desenvolvido nesse texto está disponível no repositório django-full-text-search no Github.

Disclaimer:

O código da versão desse texto está disponível na branch texto-2.


Implementando o BaseFilterBackend

Para criar o back-end de filtro, é preciso implementar a classe rest_framework.filters.BaseFilterBackend:

from rest_framework.filters import BaseFilterBackend

class FullTextSearchFilter(BaseFilterBackend):
    pass
Enter fullscreen mode Exit fullscreen mode

Obtendo os parâmetros

Os primeiros métodos que serão implementados na classe acima são apenas métodos que buscam atributos na requisição, como o parâmetro ?search, ou no ModelViewSet como, por exemplo, o search_fields. Esse código é bem parecido com o da referência em [5]:

from rest_framework.filters import BaseFilterBackend
from rest_framework.settings import api_settings

class FullTextSearchFilter(BaseFilterBackend):
    search_param = api_settings.SEARCH_PARAM

    def get_config(self, view, request):
        return getattr(view, "search_config", None)

    def get_search_fields(self, view, request):
        return getattr(view, "search_fields", None)

    def get_similarity_threshold(self, view, request):
        return getattr(view, "similarity_threshold", 0)

    def get_search_term(self, request):
        params = request.query_params.get(self.search_param, '')
        params = params.replace('\x00', '')  # strip null characters
        params = params.replace(',', ' ')
        return params
Enter fullscreen mode Exit fullscreen mode

Fazendo a Busca

O método mais importante dessa classe é, sem dúvidas, o filter_queryset que é o método que faz as alterações em um queryset para devolver a resposta da API.

É preciso, antes de tudo, obter os parâmetros para fazer nossa busca, por meio dos métodos implementados acima:

def filter_queryset(self, request, queryset, view):
    search_fields = self.get_search_fields(view, request)
    search_term = self.get_search_term(request)
    config = self.get_config(view, request)
    threshold = self.get_similarity_threshold(view, request)
Enter fullscreen mode Exit fullscreen mode

Um primeiro ponto, que deve ser levado em consideração, é que, caso a variável search_fields ou a search_term não esteja preenchida, podemos retornar o queryset sem fazer alteração:

def filter_queryset(self, request, queryset, view):
    # ...

    if not search_term or not search_fields:
        return queryset
Enter fullscreen mode Exit fullscreen mode

O restante do método é bem parecido com o que já implementamos no texto anterior:

def filter_queryset(self, request, queryset, view):
    # ...

    search_vector = SearchVector(*search_fields, config=config)
    search_query = SearchQuery(search_term, config=config)

    queryset = queryset.annotate(
        search=search_vector,
        rank=SearchRank(
            search_vector,
            search_query,
        ),
        similarity=TrigramSimilarity(*search_fields, search_term),
    ).filter(
        Q(search=search_query) | Q(similarity__gt=threshold)
    ).order_by("-rank", "-similarity")

    return queryset
Enter fullscreen mode Exit fullscreen mode

Faz-se importante denotar que o search_fields aqui é usado como *search_fields para "desconstruir" o array. Assim, se search_fields = ["name", "description"], a criação da instância SearchVector seria feita como SearchVector("name", "description", config=config).

Por fim, a classe, completa, será:

class FullTextSearchFilter(BaseFilterBackend):
    search_param = api_settings.SEARCH_PARAM

    def get_config(self, view, request):
        return getattr(view, "search_config", None)

    def get_search_fields(self, view, request):
        return getattr(view, "search_fields", None)

    def get_similarity_threshold(self, view, request):
        return getattr(view, "similarity_threshold", 0)

    def get_search_term(self, request):
        params = request.query_params.get(self.search_param, '')
        params = params.replace('\x00', '')  # strip null characters
        params = params.replace(',', ' ')
        return params

    def filter_queryset(self, request, queryset, view):
        search_fields = self.get_search_fields(view, request)
        search_term = self.get_search_term(request)
        config = self.get_config(view, request)
        threshold = self.get_similarity_threshold(view, request)

        if not search_term or not search_fields:
            return queryset

        search_vector = SearchVector(*search_fields, config=config)
        search_query = SearchQuery(search_term, config=config)

        queryset = queryset.annotate(
            search=search_vector,
            rank=SearchRank(
                search_vector,
                search_query,
            ),
            similarity=TrigramSimilarity(*search_fields, search_term),
        ).filter(
            Q(search=search_query) | Q(similarity__gt=threshold)
        ).order_by("-rank", "-similarity")

        return queryset
Enter fullscreen mode Exit fullscreen mode

Usando o FullTextSearchFilter

A classe FullTextSearchFilter pode ser utilizada nos filter_backends dos ModelViewSet do django-rest-framework. Simplificando:

from rest_framework import serializers
from rest_framework.viewsets import ModelViewSet
from texto.models import Singer
from core.filters import FullTextSearchFilter

class SingerSerializer(serializers.ModelSerializer):
    class Meta:
        model = Singer
        fields = "__all__"

class SingerViewSet(ModelViewSet):
    queryset = Singer.objects.all()
    serializer_class = SingerSerializer
    filter_backends = [FullTextSearchFilter]
    search_config = "portuguese"
    search_fields = ["name"]
Enter fullscreen mode Exit fullscreen mode

Ao registrar o SingerViewSet nas urls do projeto já é possível fazer chamadas para o endpoint utilizando o ?search como full-text search:

from django.urls import path, include
from rest_framework.routers import SimpleRouter
from .viewsets import SingerViewSet

router = SimpleRouter()
router.register("singer", SingerViewSet, "Singer")

urlpatterns = [
    path('api/', include(router.urls))
]
Enter fullscreen mode Exit fullscreen mode

Exemplo de chamada para a API com ?search=Marrone e mostrando os resultados filtrados e ordenados de modo correto


Mostrando o Rank e Similarity no retorno da API

É possível, inclusive, exibir os dados de rank e similarity no retorno da API. Como esses dados estão sendo anotados, i.e. acrescentados, na entidade, é possível, apenas, alterar o ModelSerializer:

class SingerSerializer(serializers.ModelSerializer):
    rank = serializers.FloatField(read_only=True)
    similarity = serializers.FloatField(read_only=True)

    class Meta:
        model = Singer
        fields = "__all__"
Enter fullscreen mode Exit fullscreen mode

Exemplo de chamada para a API com ?search=Marrone e mostrando os resultados com os campos rank e similarity sendo exibidos

Mas, e sem a busca?

Acrescentar, apenas, o rank e similarity no ModelSerializer traz um problema: quando o endpoint é chamado sem o ?search os dados de rank e similarity não são retornados:

Exemplo de retorno da API sem utilizar o parâmetro ?search na URL e que os itens são retornados sem o campo rank e similarity

Isso pode ser resolvido, acrescentando, no construtor do FloatField, o parâmetro default=0:

class SingerSerializer(serializers.ModelSerializer):
    rank = serializers.FloatField(read_only=True, default=0)
    similarity = serializers.FloatField(read_only=True, default=0)

    class Meta:
        model = Singer
        fields = "__all__"
Enter fullscreen mode Exit fullscreen mode

Filtrando por Similaridade

Por fim, para filtrar por similaridade, é possível definir a variável similarity_threshold no ModelViewSet:

class SingerViewSet(ModelViewSet):
    queryset = Singer.objects.all()
    serializer_class = SingerSerializer
    filter_backends = [FullTextSearchFilter]
    search_config = "portuguese"
    search_fields = ["name"]
    similarity_threshold = 0.3
Enter fullscreen mode Exit fullscreen mode

Exemplo de chamada para a API com  raw `?search=Bruninho` endraw  exibindo apenas os itens com o campo


Referências

[1] Full-Text Search: Implementando com Postgres e Django

[2] A powerful full-text search in PostgreSQL in less than 20 lines

[3] django-full-text-search

[4] Filtering - SearchFilter

[5] rest_framework/filters.py


Foto de Capa por Douglas Lopes no Unsplash

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (1)

Collapse
 
urielsouza29 profile image
Uriel dos Santos Souza

Sempre foda

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay