Django: ¿Cómo anotar los campos M2M o OneToMany usando una subconsulta?

Tengo objetos Order y objetos OrderOperation que representan una acción en una orden (creación, modificación, cancelación).

Conceptualmente, una orden tiene 1 a muchas operaciones de orden. Cada vez que hay una operación en el pedido, el total se calcula en esta operación. Lo que significa que cuando necesito encontrar un atributo de una orden, solo obtengo el último atributo de operación de orden, usando una subconsulta.

El código simplificado

 class OrderOperation(models.Model): order = models.ForeignKey(Order) total = DecimalField(max_digits=9, decimal_places=2) class Order(models.Model) # ... class OrderQuerySet(query.Queryset): @staticmethod def _last_oo(field): return Subquery(OrderOperation.objects .filter(order_id=OuterRef("pk")) .order_by('-id') .values(field) [:1]) def annotated_total(self): return self.annotate(oo_total=self._last_oo('total')) 

De esta manera, puedo ejecutar my_order_total = Order.objects.annotated_total()[0].oo_total . Funciona muy bien

La cuestión

Calcular el total es fácil ya que es un valor simple. Sin embargo, cuando hay un campo M2M o OneToMany, este método no funciona. Por ejemplo, usando el ejemplo anterior, agreguemos este campo:

 class OrderOperation(models.Model): order = models.ForeignKey(Order) total = DecimalField(max_digits=9, decimal_places=2) ordered_articles = models.ManyToManyField(Article,through='orders.OrderedArticle') 

Escribir algo como lo siguiente NO funciona, ya que solo devuelve 1 clave externa (no una lista de todos los FK):

 def annotated_ordered_articles(self): return self.annotate(oo_ordered_articles=self._last_oo('ordered_articles')) 

El propósito

El propósito principal es permitir que un usuario busque entre todos los pedidos, proporcionando una lista o artículos en la entrada. Por ejemplo: “Encuentre todos los pedidos que contengan al menos el artículo 42 o el artículo 43”, o “Encuentre todos los pedidos que contengan exactamente el artículo 42 y 43”, etc.

Si pudiera conseguir algo como:

 >>> Order.objects.annotated_ordered_articles()[0].oo_ordered_articles <ArticleQuerySet [
,
]>

o incluso:

 >>> Order.objects.annotated_ordered_articles()[0].oo_ordered_articles [42,43] 

Eso resolvería mi problema.

Mi idea actual

  • Tal vez algo como ArrayAgg (estoy usando pgSQL) podría hacer el truco, pero no estoy seguro de entender cómo usarlo en mi caso.
  • Tal vez esto tenga que ver con el método de values() que parece no tener la intención de manejar las relaciones M2M y 1TM como se indica en el documento:

values ​​() y values_list () están pensados ​​como optimizaciones para un caso de uso específico: recuperar un subconjunto de datos sin la sobrecarga de crear una instancia de modelo. Esta metáfora se derrumba cuando se trata de relaciones de muchos a muchos y otras relaciones multivalor (como la relación de uno a muchos de una clave foránea inversa) porque no se cumple el supuesto de “una fila, un objeto”.

Para mi sorpresa, su idea con ArrayAgg está en lo cierto. No sabía que había una forma de hacer anotaciones con una matriz (y creo que todavía no existe para otros backends que no sean Postgres).

 from django.contrib.postgres.aggregates.general import ArrayAgg qs = Order.objects.annotate(oo_articles=ArrayAgg( 'order_operation__ordered_articles__id', 'DISTINCT')) 

Luego puede filtrar el conjunto de consultas resultante usando las búsquedas de ArrayField :

 # Articles that contain the specified array qs.filter(oo_articles__contains=[42,43]) # Articles that are identical to the specified array qs.filter(oo_articles=[42,43,44]) # Articles that are contained in the specified array qs.filter(oo_articles__contained_by=[41,42,43,44,45]) # Articles that have at least one element in common # with the specified array qs.filter(oo_articles__overlap=[41,42]) 

'DISTINCT' es necesario solo si la operación puede contener artículos duplicados.

Es posible que deba modificar el nombre exacto del campo pasado a la función ArrayAgg . Para que funcione el filtrado posterior, es posible que también deba convertir los campos de identificación en el ArrayAgg en int lo contrario, Django convierte la matriz de identificación en ::serial[] , y mi Postgres se quejó del type "serial[]" does not exist :

 from django.db.models import IntegerField from django.contrib.postgres.fields.array import ArrayField from django.db.models.functions import Cast ArrayAgg(Cast('order_operation__ordered_articles__id', IntegerField())) # OR Cast(ArrayAgg('order_operation__ordered_articles__id'), ArrayField(IntegerField())) 

Mirando más de cerca su código publicado, también tendrá que filtrar en la única OrderOperation que le interesa; La consulta anterior examina todas las operaciones para el orden relevante.

ArrayAgg será excelente si desea obtener solo una variable (es decir, nombre) de todos los artículos. Si necesitas más, hay una mejor opción para eso:

prefetch_related

En su lugar, puede realizar una búsqueda previa para cada Order, lates Operación Order, lates as a whole object. This adds the ability to easily get any field from as a whole object. This adds the ability to easily get any field from OrderOperation` sin magia extra.

La única advertencia con esto es que siempre obtendrá una lista con una operación o una lista vacía cuando no haya operaciones para el orden seleccionado.

Para hacerlo, debe usar el modelo de conjunto de consultas prefetch_related junto con el objeto Prefetch y la consulta personalizada para OrderOperation . Ejemplo:

 from django.db.models import Max, F, Prefetch last_order_operation_qs = OrderOperation.objects.annotate( lop_pk=Max('order__orderoperation__pk') ).filter(pk=F('lop_pk')) orders = Order.objects.prefetch_related( Prefetch('orderoperation_set', queryset=last_order_operation_qs, to_attr='last_operation') ) 

Luego, solo puede usar order.last_operation[0].ordered_articles para obtener todos los artículos pedidos para un orden particular. Puede agregar prefetch_related('ordered_articles') a la primera consulta para tener un mejor rendimiento y menos consultas en la base de datos.