Saltar al contenido
🐍 Curso de Python
/

Módulo 4 · Lección 37 de 45

Búsqueda y filtros

El buscador que no encuentra a Óscar, el desplegable vacío que tira la página, y cómo se combinan filtros sin escribir un if por cada uno.

⏱ 16 min de lectura

Un buscador es de lo primero que se pide en cualquier listado y de lo que más fácil es hacer mal. La versión ingenua son tres líneas y funciona en las pruebas del desarrollo, porque quien prueba escribe ana y busca a Ana.

En español se rompe con el primer nombre con tilde, y eso es la mitad de los nombres. Esta lección lo mide, lo arregla, y después monta el formulario de filtros combinados, que es el otro problema del mismo listado.

La versión ingenua y su problema#

python
Contacto.objects.filter(nombre__icontains=peticion.GET.get('q', ''))
Salida en pantalla
icontains('ana')    -> ['Ana Cambiada', 'ana lopez']
icontains('Ana')    -> ['Ana Cambiada', 'ana lopez']
icontains('ANA')    -> ['Ana Cambiada', 'ana lopez']
icontains('íñigo')  -> []
icontains('Íñigo')  -> ['Íñigo Ñuño']
icontains('inigo')  -> []
icontains('oscar')  -> []
icontains('Óscar')  -> ['Óscar Paz']

Las tres primeras líneas son la razón de que esto parezca funcionar. Las cinco siguientes son el problema.

Buscar oscar no encuentra a Óscar. Ni inigo a Íñigo, ni siquiera íñigo en minúsculas, que es lo que cualquiera escribiría.

El motivo se midió en la lección 12: en SQLite, icontains solo entiende de mayúsculas y minúsculas en el alfabeto inglés. Para la base de datos, Ó y ó son dos caracteres sin ninguna relación, y o es un tercero.

Y esto no es un problema exótico ni de un caso raro: en un listado de nombres en español, la mayoría de las búsquedas fallan.

El campo normalizado#

La solución es la misma que resolvió la ordenación en la lección 12, y la misma que el módulo 3 usó para ordenar nombres: guardar una copia comparable.

contactos/models.py
import unicodedata


def normalizar(texto):
        """Minúsculas sin tildes, para poder comparar."""
    descompuesto = unicodedata.normalize('NFKD', texto.casefold())
    return ''.join(c for c in descompuesto if not unicodedata.combining(c))


class Contacto(models.Model):
    # ...
        busqueda = models.CharField(max_length=200, blank=True, editable=False, db_index=True)

    def save(self, *args, **kwargs):
                self.busqueda = normalizar(f'{self.nombre} {self.correo}')
        super().save(*args, **kwargs)
Salida en pantalla
buscar('ana')    -> ['Ana Cambiada', 'ana lopez']
buscar('íñigo')  -> ['Íñigo Ñuño']
buscar('inigo')  -> ['Íñigo Ñuño']
buscar('oscar')  -> ['Óscar Paz']
buscar('Óscar')  -> ['Óscar Paz']
buscar('NUNO')   -> ['Íñigo Ñuño']
buscar('ñuño')   -> ['Íñigo Ñuño']

Los siete casos. Y la consulta es esta, con contains y no con icontains, porque ya está todo en minúsculas:

python
Contacto.objects.filter(busqueda__contains=normalizar(termino))

Tres decisiones del modelo que merecen explicación. editable=False lo saca de los formularios y del panel, porque nadie debe escribirlo. db_index=True porque es el campo por el que se busca. Y el save() lo rellena, que es la manera de que no se pueda olvidar.

Varias palabras#

Buscar ana lopez con un solo contains no encuentra nada, porque no hay ninguna cadena que sea exactamente eso en ese orden. Lo que se espera es que todas las palabras aparezcan, en cualquier orden:

python
def buscar(consulta, texto):
        for palabra in normalizar(texto).split():
                consulta = consulta.filter(busqueda__contains=palabra)
    return consulta
Salida en pantalla
'ana'          -> ['Ana Cambiada', 'ana lopez']
'ana lopez'    -> ['ana lopez']
'lopez ana'    -> ['ana lopez']
'ana ruiz'     -> []
'nuno inigo'   -> ['Íñigo Ñuño']

Los dos órdenes dan lo mismo, y una palabra que no está descarta el resultado. Eso funciona porque encadenar filter es un Y lógico, que es lo que se vio en la lección 12.

Para buscar en campos que no están en el texto normalizado, Q con |:

python
from django.db.models import Q

consulta.filter(Q(busqueda__contains=palabra) | Q(notas__icontains=palabra))

Y recuerda la regla de la lección 12: los Q van antes que los argumentos con nombre, o Python se queja.

Filtros combinados#

El error de partida es escribir un if por cada combinación. Lo que se hace es construir el QuerySet por partes, aprovechando que es perezoso:

contactos/views.py
def lista(peticion):
        consulta = Contacto.objects.select_related('grupo')

    termino = peticion.GET.get('q', '').strip()
    if termino:
                for palabra in normalizar(termino).split():
                        consulta = consulta.filter(busqueda__contains=palabra)

    grupo = peticion.GET.get('grupo')
    if grupo:
                consulta = consulta.filter(grupo_id=grupo)

    if peticion.GET.get('con_telefono'):
                consulta = consulta.exclude(telefono='')

    orden = peticion.GET.get('orden', 'nombre')
    if orden in {'nombre', '-nombre', 'creado', '-creado'}:
                consulta = consulta.order_by(orden)

    paginador = Paginator(consulta, 20)
    return render(peticion, 'contactos/lista.html', {
        'pagina': paginador.get_page(peticion.GET.get('page')),
        'valores': peticion.GET,
    })
Salida en pantalla
sin filtros:      16
+ grupo:           2
+ con teléfono:    0

Cada if estrecha lo anterior y no se ejecuta ninguna consulta hasta el final, por la pereza de la lección 11. Da igual que haya tres filtros o diez: siempre es una consulta.

Fíjate en ese if orden in {...}. Es una lista blanca, y no es una precaución exagerada: order_by con lo que llegue de la dirección permite ordenar por campos que no deberían verse y, con las relaciones, recorrer tablas ajenas. Lo que llega de fuera nunca se pasa tal cual a order_by, a values ni a only.

Cuándo esto se queda corto#

El campo normalizado con contains cubre bastante y tiene un techo claro:

No tolera erratas. Buscar Alvares no encuentra a Álvarez.

No entiende variantes de la misma palabra. contactos no encuentra contacto.

No ordena por relevancia. Todos los resultados valen igual, y en una lista larga eso importa más que encontrarlos.

El contains no usa el índice por la izquierda. Un LIKE '%ana%' recorre la tabla entera; el índice solo ayuda si la búsqueda empieza por el principio del campo.

Cuando eso pese, hay tres caminos:

OpciónCuándo
Búsqueda de texto completo de PostgreSQLYa usas PostgreSQL, que es la lección 41
FTS5 de SQLiteSigues en SQLite y quieres relevancia
Un motor aparteMillones de documentos, sinónimos, facetas

La de PostgreSQL es la que más se usa, y es una de las razones de peso para cambiar de base de datos:

python
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
Salida en pantalla
ModuleNotFoundError: No module named 'psycopg2'

Ese error es la prueba de que ese módulo entero no funciona sin PostgreSQL: da búsqueda con relevancia, raíces de palabras y tolerancia a erratas por similitud, todo dentro de la base de datos.

Y la advertencia para el español: su configuración por defecto es en inglés. Hay que indicar config='spanish' para que las raíces y las palabras vacías sean las correctas. Es el mismo descuido de siempre con otra cara.