Django:在queryset update()中使用带注释的聚合

dkh*_*upt 3 django django-orm

在添加到现有项目的新应用程序中,我遇到了一种有趣的情况。我的目标是(使用Celery任务)使用包含外键对象的带注释的聚合值的值一次更新许多行。这是我在之前的问题中使用过的一些示例模型:

class Book(models.model):
    author = models.CharField()
    num_pages = models.IntegerField()
    num_chapters = models.IntegerField()

class UserBookRead(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL)
    user_book_stats = models.ForeignKey(UserBookStats)
    book = models.ForeignKey(Book)
    complete = models.BooleanField(default=False)
    pages_read = models.IntegerField()

class UserBookStats(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL)
    total_pages_read = models.IntegerField()
Run Code Online (Sandbox Code Playgroud)

我正在尝试:

  1. 当页数更新时,请使用post_save来自Book实例的信号来更新pages_read相关UserBookRead对象Book
  2. 在信号结束时,启动后台Celery任务以汇总pages_read每个UserBookRead已更新的任务,并更新total_pages_read每个相关的任务UserBookStats(这是发生问题的地方)

我正在尝试尽可能地减少查询的数量-步骤1已经完成,并且只需要对我的实际用例进行少量查询,只要适当地优化了这些查询,这对于信号处理程序似乎是可接受的。

步骤2涉及更多,因此委派给后台任务。我已经设法以一种非常干净的方式完成了大部分任务(至少对我而言)。

我遇到的问题是,当UserBookStats使用total_pages聚合(Sum()所有pages_read相关UserBookRead对象的全部)注释查询集时,我不能直接update使用查询集来设置total_pages_read字段。

这是代码(Book实例作为传递给任务book):

# use the provided book instance to get the stats which need to be updated
book_read_objects= UserBookRead.objects.filter(book=book)
book_stat_objects = UserBookStats.objects.filter(id__in=book_read_objects.values_list('user_book_stats__id', flat=True).distinct())

# annotate top level stats objects with summed page count
book_stat_objects = book_stat_objects.annotate(total_pages=Sum(F('user_book_read__pages_read')))

# update the objects with that sum
book_stat_objects.update(total_pages_read=F('total_pages'))
Run Code Online (Sandbox Code Playgroud)

在执行最后一行时,将引发以下错误:

django.core.exceptions.FieldError: Aggregate functions are not allowed in this query
Run Code Online (Sandbox Code Playgroud)

经过研究后,我在这里找到了该用例的现有Django票证,在上面的评论中提到了1.11中的2个新功能,这些功能可能使之成为可能。

有没有已知/可接受的方式来完成此用例,也许使用SubqueryOuterRef?尝试将汇总折叠为时没有任何成功Subquery。此处的后备方法是:

for obj in book_stat_objects:
    obj.total_pages_read = obj.total_pages
    obj.save()
Run Code Online (Sandbox Code Playgroud)

但是,由于其中可能有成千上万的记录book_stat_objects,我实际上是在尝试避免为每个记录单独发出UPDATE。

dkh*_*upt 6

我最终想出了如何使用Subquery和来实现这一点OuterRef,但不得不采用与最初预期不同的方法。

我能够很快开始Subquery工作,但是当我用它注释父查询时,我注意到每个注释值都是子查询的第一个结果-这是我意识到需要的时候OuterRef,因为生成的SQL并不限制子查询由父查询中的任何内容组成。

Django文档的这一部分非常有用,StackStackflow问题也是如此。这个过程归结为,您必须使用它Subquery来创建聚合,并OuterRef确保子查询通过父查询PK限制聚合行。那时,您可以使用汇总值进行注释,并在queryset中直接使用它update()

正如我在问题中提到的那样,代码示例已组成。我已经尝试通过更改使它们适应我的实际用例:

from django.db.models import Subquery, OuterRef
from django.db.models.functions import Coalesce

# create the queryset to use as the subquery, restrict based on the `book_stat_objects` queryset
book_reads = UserBookRead.objects.filter(user_book_stat__in=book_stat_objects, user_book_stats=OuterRef('pk')).values('user_book_stats')
# annotate the future subquery with the aggregation of pages_read from each UserBookRead
total_pages = book_reads.annotate(total=Sum(F('pages_read')))
# annotate each stat object with the subquery total
book_stats = book_stats.annotate(total=Coalesce(Subquery(total_pages), 0))
# update each row with the new total pages count
book_stats.update(total_pages_read=F('total'))
Run Code Online (Sandbox Code Playgroud)

创建一个不能单独使用的查询集感觉很奇怪(book_reads由于包含,试图进行评估会引发错误OuterRef),但是一旦检查了为生成的最终SQL book_stats,它就很有意义。

编辑

在弄清楚这个答案后一两个星期,我最终遇到了这个代码的错误。原来是由于orderingUserBookRead模型的默认设置。如Django docs所述,默认值ordering已合并到任何聚合GROUP BY子句中,因此我所有的聚合均已关闭。解决方案是order_by()在创建基本子查询时清除默认的顺序,并用空格清除:

book_reads = UserBookRead.objects.filter(user_book_stat__in=book_stat_objects, user_book_stats=OuterRef('pk')).values('user_book_stats').order_by()
Run Code Online (Sandbox Code Playgroud)