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
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
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)
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
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
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
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"]
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))
]
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__"
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:
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__"
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
Referências
[1] Full-Text Search: Implementando com Postgres e Django
[2] A powerful full-text search in PostgreSQL in less than 20 lines
Top comments (1)
Sempre foda