Saltar a contenido

Introducción a FilterSchema#

iThome Ironman 2024

Este es el artículo número 26 de la serie de tutoriales de Django Ninja.

Las «consultas» son un requerimiento adicional común en las APIs, que esencialmente consiste en el filtrado y selección de datos.

Ya sea para filtrar artículos, productos o buscar usuarios, filtrar datos y obtener resultados basados en diferentes condiciones se puede decir que es una función imprescindible en la mayoría de los proyectos.

En la función view, la forma más sencilla de implementar consultas es utilizar los métodos de filtrado del ORM de Django. Por ejemplo, podemos usar el método filter para filtrar QuerySets según condiciones específicas.

Este método es simple y directo, adecuado para requerimientos de consulta básicos. Sin embargo, también tiene sus limitaciones: a medida que aumentan los campos y las necesidades, las condiciones de consulta pueden volverse cada vez más complejas, lo que genera código prolijo y difícil de mantener.

Para resolver este problema, Django Ninja proporciona FilterSchema, permitiéndonos definir y gestionar las condiciones de consulta de una manera más «estructurada».

Este artículo presentará FilterSchema, implementando y explicando paso a paso para que entiendas cómo usar FilterSchema en Django Ninja y lograr funciones de consulta API más flexibles y modularizadas.

Todos los cambios de código de este artículo se pueden consultar en este PR.

Proyecto de ejemplo en GitHub#

👉 Django-Ninja-Tutorial


Métodos de consulta tradicionales y sus problemas#

En el artículo anterior mencionamos la API «Obtener la lista de artículos». ¿Recuerdas la función de «consulta por título de artículo» que le agregamos en la «Entrega 11: Solicitudes (Parte 3) Parámetros de consulta - Query Parameters»?

Este es el estado actual del código: (presta atención al nombre del parámetro de consulta title)

...
def get_posts(
    request: HttpRequest,
    title: None | str = Query(None, min_length=2, max_length=10),
) -> QuerySet[Post]:
    """
    Obtener la lista de artículos
    """
    posts = Post.objects.all()
    if title:
        posts = posts.filter(
            title__icontains=title).select_related('author')
    return posts

¡En ese momento usamos el método filter del ORM de Django!

posts.filter(title__icontains=title).select_related('author')

El select_related('author') final se utiliza para evitar el problema «N+1» y no tiene nada que ver con la lógica de consulta, por lo que podemos ignorarlo por ahora.

Problemas potenciales#

Esta forma de escribir es muy intuitiva y resulta muy efectiva para requerimientos de consulta sencillos.

Sin embargo, a medida que la escala del proyecto se expande y los requerimientos de consulta se vuelven complejos, se generarán los siguientes dilemas:

  • Duplicación de código: Cuando necesitas realizar filtrados similares en múltiples lugares, puedes descubrir que estás repitiendo la misma lógica de filtrado.
  • Dificultad de mantenimiento: Con el aumento de las condiciones de filtrado, tu función view se puede volver difícil de mantener. Cada vez que agregas una nueva condición de filtrado, puede ser necesario modificar el código en múltiples lugares.
  • Validación y transformación de datos: Debes manejar manualmente los problemas de validación y conversión de datos, lo que no solo aumenta la posibilidad de errores, sino que también incrementa la complejidad del desarrollo.
  • Escalabilidad: Cuando necesitas admitir condiciones de filtrado más complejas, como consultas de rango o combinadas con múltiples condiciones, el método de ensamblar consultas ORM manualmente puede resultar muy pesado y difícil de gestionar.

Por lo tanto, no recomendamos ensamblar condiciones de consulta directamente con el método filter del ORM en el caso de consultas complejas.


Nuevo requerimiento: consultar simultáneamente el nombre del autor#

El nuevo requerimiento es usar la misma palabra clave para consultar simultáneamente el título del artículo o el nombre del autor, mostrando el resultado siempre que coincida con cualquiera de ellos (basta con cumplir uno de los dos, o también ambos).

Este requerimiento es similar a esta función de consulta en el sitio web oficial de la iThome Ironman:

Aunque nosotros solo podemos buscar en 2 tipos, mientras que allí se puede buscar en 3 a la vez: título, introducción y apodo del participante.

Pero la esencia es la misma.

Implementar con el método tradicional + objeto Q#

Si implementamos esto con el método tradicional + el objeto Q de Django, la consulta se verá así:

from django.db.models import Q
...

posts = posts.filter(
    Q(title__icontains=title) | Q(author__name__icontains=title)
).select_related('author')

En este punto, ya no es adecuado llamar al parámetro de consulta title, ya que va a consultar dos campos. No importa, lo cambiaremos a query más adelante.

Este fragmento de código tiene dos puntos clave:

  1. ¡La consulta efectivamente se volvió más larga! Si más adelante hay nuevas condiciones de consulta, ¿no sería...?
  2. ¿Qué demonios es este Q?

Q es el objeto Q del ORM de Django, el cual ocupa un lugar muy importante en la lógica de consultas complejas. Por lo tanto, es necesario que lo presentemos brevemente primero.


Introducción a los objetos Q de Django#

Para mejorar la complejidad de la estructura del programa al realizar consultas con múltiples condiciones, Django proporciona el objeto Q.

El objeto Q nos permite organizar flexiblemente las condiciones de consulta, utilizando operadores lógicos (como &, |) para realizar combinaciones de condiciones. Es extremadamente útil al manejar filtrados por condiciones complejas.

Por ejemplo, si queremos filtrar artículos cuyo título contenga «Ninja» y además el nombre del autor contenga «Alice», podemos escribirlo así:

posts = posts.filter(
    Q(title__icontains='Ninja') & Q(author__name__icontains='Alice')
)

La forma de escribir anterior es, en realidad, equivalente a lo que vemos habitualmente:

posts = posts.filter(
    title__icontains='Ninja', author__name__icontains='Alice'
)

Por lo tanto, normalmente no utilizarás el objeto Q para requerimientos de tipo «AND».

Las condiciones de consulta con «OR» son el escenario clásico para Q.

Ahora la condición cambia a: el título del artículo «o» el nombre del autor contiene «Alice». Podemos usar |:

posts = posts.filter(
    Q(title__icontains='Ninja') | Q(author__name__icontains='Alice')
)

El objeto Q hace que las consultas sean más flexibles y claras, especialmente al enfrentarse a múltiples condiciones opcionales.


Mejorar las consultas usando FilterSchema#

Una vez comprendido que los métodos de consulta tradicionales tienden a generar código prolijo y habiendo aprendido lo básico del objeto Q, vamos a presentar al protagonista de hoy: FilterSchema.

El FilterSchema que proporciona Django Ninja tiene como función principal hacer que las sentencias de consulta sean más estructuradas y modularizadas, evitando que las funciones view se vuelvan prolijas y difíciles de leer.

Además, al igual que los métodos de validación en los Schema, también logra en cierta medida el principio de «separación de responsabilidades», al extraer la lógica de consulta fuera de la función view.

Sin embargo, no nos apresuremos a llegar de un solo paso; permíteme mejorar el código por etapas.

Aunque esto sea un poco torpe, te dará una comprensión más profunda de FilterSchema y de la implementación de consultas complejas.

Primera versión de «mejora»#

Primero implementemos el «Nuevo requerimiento: consultar simultáneamente el nombre del autor» usando FilterSchema.

Creamos un nuevo Schema en schemas.py, pero esta vez será un FilterSchema:

# post/schemas.py
from ninja import Field, FilterSchema, Schema
...

class PostFilterSchema(FilterSchema):
    query: str | None = Field(None, min_length=2, max_length=10)

Este FilterSchema en realidad se utiliza para los «parámetros de consulta (query parameters)». Por lo tanto, los nombres de sus campos (atributos) son los nombres de los parámetros de consulta que consideras que el cliente debería utilizar.

Como necesitamos consultar al mismo tiempo el «título del artículo» y el «nombre del autor», lo he nombrado query.

A continuación, lo utilizamos en la función view:

@router.get(...)
@paginate(CustomPagination)
def get_posts(
    request: HttpRequest,
    filters: PostFilterSchema = Query(),  # Usar FilterSchema
) -> QuerySet[Post]:
    """
    Obtener la lista de artículos
    """
    posts = Post.objects.all()
    if filters.query:
        q = Q(title__icontains=filters.query) | \
            Q(author__username__icontains=filters.query)
        posts = posts.filter(q)
    return posts

PD: El código de ejemplo del proyecto aquí tiene un error, la segunda consulta Q se puso por error como «content__icontains»; los lectores deben tenerlo en cuenta. Lo he corregido en la siguiente rama.

Al ver esta nueva función view, quizás no puedas evitar pensar:

¿Esto es un chiste? ¡No se volvió más simple en absoluto!

Es verdad, porque este es solo un «producto a medio terminar» de FilterSchema, por lo que parece aún más prolijo que no usarlo.

A pesar de esto, hay algunos puntos destacados que vale la pena comprender.


Análisis de puntos clave#

q = Q(title__icontains=filters.query) | \
    Q(content__icontains=filters.query)
posts = posts.filter(q)

A partir de esta sección se puede observar que el objeto Q puede realizar varias operaciones de combinación por separado y finalmente pasarse como argumento al método filter de Django.

Sintaxis común en Django Ninja#

En este ejemplo, esta «sintaxis» de parámetros de función view es muy común en Django Ninja:

filters: PostFilterSchema = Query()

Y los principiantes pueden fácilmente tener una «mala interpretación» al verla.

¿Por qué? Porque podrías pensar que el tipo de filters es PostFilterSchema (eso está bien) y luego su valor por defecto es Query(), ya que las funciones de Python se definen de esa manera.

Pero no es así.

Query() no es el valor por defecto del parámetro filters; de lo contrario, ¿su tipo no debería ser Query?

De hecho, la marca = Query() no es para que la leas tú, sino para que la lea Django Ninja. Equivale a decirle a Django Ninja:

El contenido de este parámetro debe obtenerse de los parámetros de consulta (query parameters) en la solicitud HTTP, no del body ni del path.

Pensarlo así lo hace muy fácil de entender.

Django Ninja intentará obtener las cadenas de texto de los parámetros de consulta, desglosarlas (si hay múltiples cadenas de consulta) y luego pasárselas una a una a PostFilterSchema para su inicialización y validación:

  1. Validación fallida: Devuelve 422.
  2. Validación exitosa: Pasa el objeto Schema a la función view como un parámetro de la función (variable local).

Resultados de la consulta#

Este es el resultado de la consulta utilizando «Alice» como palabra clave:

Se encontraron 30 artículos, todos provenientes de usuarios cuyo nombre de autor contiene «Alice».


Resumen y siguientes pasos#

Hasta aquí la primera parte; hemos entrado en contacto con dos nuevos conceptos: los objetos Q y FilterSchema.

También analizamos la «sintaxis» común de los parámetros de funciones view en Django Ninja, lo cual es muy importante para comprender el modo de uso y los hábitos del framework.

Estos conceptos requieren tiempo para ser digeridos, pero puedo asegurarte que este recorrido vale la pena.

En comparación con profundizar directamente en el uso avanzado de FilterSchema, este método de aprendizaje gradual ayuda mucho más a la comprensión.

En el próximo artículo verás por qué es importante conocer los objetos Q, así como la forma de construir consultas multicampo estructuradas y alineadas con la «separación de responsabilidades» a través de FilterSchema.

Nos vemos en la siguiente entrega.