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

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

Top comments (1)

Collapse
 
urielsouza29 profile image
Uriel dos Santos Souza

Sempre foda