Consulta multicampo#

Este es el artículo número 27 de la serie de tutoriales de Django Ninja.
En el artículo anterior aprendimos sobre el objeto Q del ORM de Django y el FilterSchema de Django Ninja, pero de este último pareció quedar la lección a medias.
Lo que más se discutió fue la forma de definir parámetros al usar FilterSchema en la función view; eso es ciertamente muy importante, pero es solo una parte de FilterSchema.
En este artículo vamos a completar el contenido restante:
- Perfeccionar FilterSchema: Usar una forma de escribir «más idiomática» para liberar el verdadero poder de FilterSchema.
- Implementar una función de consulta de campos más avanzada: Consulta multicampo — Filtrado por rango de fechas.
- Implementar de forma adicional la «validación entre campos» aprendida en la entrega 20: Validar si el rango de fechas de los parámetros de consulta es válido.
Parece que esta es otra entrega llena de información. ¡Sin más preámbulos, comencemos directamente!
Todos los cambios de código de este artículo se pueden consultar en este PR.
Proyecto de ejemplo en GitHub#
1. Migrar la lógica de consulta a FilterSchema#
¿Recuerdas la implementación de código de nuestro artículo anterior?
A pesar de definir un FilterSchema adicional, el código dentro de la función view no solo no disminuyó, ¡sino que aumentó! (Aunque la lógica de consulta también aumentó porque teníamos que consultar dos campos al mismo tiempo).
@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(content__icontains=filters.query)
posts = posts.filter(q)
return posts
Esto es simplemente insólito 🐸
¡Eso se debe a que FilterSchema no se usa así!
El uso «correcto» de FilterSchema#
Deberíamos encapsular la lógica de consulta dentro de FilterSchema en la medida de lo posible. Esto permite que la función view sea más limpia y logra el efecto de «separación de responsabilidades».
Veamos una forma de escribir más razonable: migrar la lógica de consulta a FilterSchema:
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None,
q=['title__icontains', 'author__username__icontains'],
min_length=2,
max_length=10,
)
El cambio principal está en la parte Field del campo query, donde ahora se añade el contenido del parámetro q=:
¿Te resulta familiar? Así es, en realidad son las sentencias condicionales del objeto Q, y Django Ninja llamará automáticamente al objeto Q internamente para ejecutar estas consultas.
Advertencia de Mypy#
Una vez que usas el parámetro q=, Mypy te advertirá de nuevo:
Unexpected keyword argument "q" for "Field"
No se equivoca, porque el Field de Pydantic ciertamente no tiene este parámetro; es algo implementado por el propio Django Ninja.
Puedes ignorarlo o añadir los comentarios necesarios.
Simplificación de la función view#
De esta manera, la función view solo necesita escribirse así:
...
def get_posts(
request: HttpRequest,
filters: PostFilterSchema = Query(),
) -> QuerySet[Post]:
"""
Obtener la lista de artículos
"""
posts = Post.objects.select_related('author')
posts = filters.filter(posts)
return posts
¿A que es mucho más simple?
Dado que la lógica de consulta se ha «separado» de la función view, las responsabilidades de la función view son más únicas y más fáciles de mantener.
2. Consulta multicampo: añadir una función de filtrado por fecha#
Nuevo requerimiento: Además de poder consultar por el título del artículo o el nombre del autor, ¡ahora también se agregará el filtrado por «fecha de publicación»!
Introduciremos dos nuevos parámetros de consulta URL:
start_dateend_date
Ambos se utilizarán para consultar y filtrar el campo created_at en el modelo Post (es decir, la fecha de publicación), con el fin de filtrar los datos de artículos dentro de un rango de tiempo específico.
Hay un requerimiento adicional: ambos deben ser «todo o nada»; pueden no estar presentes ninguno de los dos, pero no se puede completar solo uno. Debido a que es una consulta de rango de fechas, debe contar con inicio y fin.
Esta es también una típica consulta «multicampo».
Nuevo código#
Este es el FilterSchema después de incorporar la lógica anterior:
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None, q=["title__icontains", "author__username__icontains"])
start_date: str | None = Field(None, q="created_at__gte")
end_date: str | None = Field(None, q="created_at__lte")
En este esquema, tanto start_date como end_date son condiciones de consulta sobre el campo created_at del modelo.
Por lo tanto, utilizamos created_at__gte y created_at__lte para describir la lógica de filtrado (cada uno corresponde a su respectivo objeto Q), a fin de filtrar los datos que cumplan con las condiciones.
¿Y la función view? Lo has adivinado: ¡no hace falta tocarla en absoluto!
Esa es la ventaja de usar FilterSchema.
Problema de renderizado en la documentación de la API#
Curiosamente, cuando intenté agregar ejemplos para la documentación a estos parámetros de consulta escribiendo así:
Al ver la documentación de la API se obtiene:
😱 Could not render Parameters, see the console.
Sin embargo, escribir example='2021-01-01' funciona con éxito.
Esto puede ser un bug en la integración entre Django Ninja y Pydantic, ¡así que por ahora lo pasaremos por alto!
Ejemplo de consulta desde el cliente#
Cuando queremos consultar artículos dentro de un determinado período de tiempo, podemos usar los siguientes parámetros de consulta URL:
De esta manera se pueden consultar fácilmente todos los artículos del mes de enero de 2023.
Por cierto, dado que la hora en la fecha no está especificada, por defecto es 0 horas, 0 minutos, 0 segundos. Por lo tanto, si se ingresa el mismo día, no se encontrará nada.
Este es un detalle que necesita ser mejorado o reajustado; la práctica común es sumar 1 día a end_date internamente en el código, pero yo elegí directamente no permitir que ambos sean iguales XD. Cómo hacerlo en la práctica dependerá de tus necesidades.
Además de las consultas de período simple, también podemos consultar los artículos de un autor en un período determinado. Tomando como ejemplo la consulta del autor Alice:
El resultado mostrará todos los artículos de Alice en el mes de enero de 2023.
Relación de condiciones de consulta por defecto en FilterSchema#
Esta parte debe presentarse de manera especial. Según se describe en la documentación, por defecto:
- Field-level expressions are joined together using
ORoperator. - The fields themselves are joined together using
ANDoperator.
Esto significa que múltiples sentencias Q dentro de un solo campo tienen una relación OR entre sí, como el query anterior:
Puede buscar el título del artículo «o» el nombre del autor.
Mientras que las condiciones en diferentes campos (si las hay) tienen una relación AND: deben cumplirse simultáneamente. Por lo tanto, las condiciones para el nombre del autor y el rango de fechas deben cumplirse al mismo tiempo.
Estas lógicas predeterminadas se pueden modificar; para más detalles, consulta la documentación mencionada anteriormente.
3. Validación de rango de fechas#
En este ejemplo, además de la consulta por campos, también debemos asegurarnos de que la fecha de inicio ingresada por el usuario sea anterior a la fecha de fin.
Además, los valores de consulta de ambos campos deben ser «todo» o «nada» (si no hay nada, no hace falta validar).
Este requerimiento resulta muy familiar: ¿no es la «validación entre campos» mencionada en la entrega 20?
Así es, lo implementaremos a través del model_validator de Pydantic, el cual nos permite realizar verificaciones lógicas personalizadas sobre los datos de entrada durante el proceso de validación.
Implementar la validación de rango de fechas con model_validator de Pydantic#
El código es un poco extenso, así que veamos directamente los puntos clave:
class PostFilterSchema(FilterSchema):
...
start_date: str | None = Field(None, q='created_at__gte')
end_date: str | None = Field(None, q='created_at__lte')
@model_validator(mode='after')
def check_date_range(self) -> Self:
# Si la fecha de inicio y la fecha de fin son ambas None, no se realiza ninguna verificación
if self.start_date is None and self.end_date is None:
return self
if not all([self.start_date, self.end_date]):
raise ValueError('La fecha de inicio y la fecha de fin deben proporcionarse ambas o ninguna')
try:
start_date_dt = datetime.strptime(self.start_date, '%Y-%m-%d')
end_date_dt = datetime.strptime(self.end_date, '%Y-%m-%d')
except ValueError:
raise ValueError('Formato de fecha inválido, debe ser YYYY-MM-DD')
if start_date_dt > end_date_dt:
raise ValueError('La fecha de inicio debe ser anterior a la fecha de fin')
return self
Para las condiciones de consulta, utilizamos el model_validator de Pydantic para realizar validaciones entre campos, asegurando que la fecha ingresada por el usuario sea válida y razonable.
De hecho, la validación entre campos a menudo requiere considerar muchos detalles; de lo contrario, se pueden pasar por alto cosas y generar nuevos errores indirectamente.
Este ejemplo es un caso típico.
Debemos considerar detenidamente todas las posibles situaciones de entrada, incluyendo si el formato de la fecha es correcto, si el rango de fechas es razonable y si ambos campos de fecha existen simultáneamente o están vacíos al mismo tiempo.
Una lógica de validación meticulosa puede mejorar la confiabilidad de la API, evitando comportamientos inesperados en el sistema debido a entradas no válidas o no razonables. Una lógica descuidada hace lo contrario.
Probar respuestas de error#
PD: En cuanto a los errores lanzados aquí, el código de ejemplo sigue utilizando ValueError, no lo he cambiado por el ValidationError de Django (se ha corregido en la versión más reciente).
Sin embargo, las siguientes respuestas son una simulación del ValidationError de Django para reducir repeticiones innecesarias.
- Ingresar solo la fecha de inicio: (La probabilidad de que ocurra esto es baja, ya que el frontend suele restringirlo)
- Ingresar una fecha no válida, por ejemplo
2023-02-30:
- Ingresar un rango de fechas no válido, por ejemplo
start_date=2023-01-31&end_date=2023-01-01:
Resumen y siguientes pasos#
En estos dos artículos hemos presentado el uso efectivo de FilterSchema, completando consultas multicampo y filtrado de fechas, y demostrando cómo usar model_validator para reforzar la validación de datos y garantizar la precisión de la lógica de consulta.
También hemos visto código de ejemplo de estos escenarios de aplicación para ayudar a los lectores a comprender mejor el propósito y efecto de cada paso.
En el próximo capítulo exploraremos el mecanismo de Autenticación (Authentication) en Django Ninja e introduciremos cómo realizar pruebas unitarias utilizando pytest, elementos indispensables en el desarrollo backend.