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 • Updated on

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 "similarity" maior que 0.3


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

Top comments (1)

Collapse
 
urielsouza29 profile image
Uriel dos Santos Souza

Sempre foda