Django QuerySet注解ended字段后过滤结果异常,如何排查?
Django annotate关联查询导致的计数异常问题分析与解决
问题场景
假设Django项目中有如下模型结构:
from django.db import models class Object(models.Model): name = models.CharField(max_length=100) class ObjectEvent(models.Model): class EventTypes(models.IntegerChoices): CREATED = 1, "Created" SCHEDULED = 2, "Scheduled" COMPLETED = 3, "Completed" CANCELED = 4, "Canceled" event_type = models.IntegerField(choices=EventTypes.choices) object = models.ForeignKey(Object, on_delete=models.CASCADE, related_name="events")
为了实现通过ended属性(定义为拥有COMPLETED或CANCELED类型事件的对象)过滤,在自定义ObjectManager中使用annotate实现:
class ObjectManager(models.Manager): def get_queryset(self): qs = super().get_queryset() qs = qs.annotate( ended=models.Case( models.When( events__event_type__in=( ObjectEvent.EventTypes.COMPLETED, ObjectEvent.EventTypes.CANCELED, ), then=models.Value(True), ), default=models.Value(False), output_field=models.BooleanField( verbose_name="Object ended", ), ) ) return qs class Object(models.Model): objects = ObjectManager() name = models.CharField(max_length=100)
使用测试数据fake_data.json(包含4个Object和9个关联Event)测试时,出现异常结果:
>>> ended_objects = Object.objects.filter(ended=True) >>> ended_objects.count() 2 # 符合预期,对应task1和task3 >>> not_ended_objects = Object.objects.filter(ended=False) >>> not_ended_objects.count() 7 # 不符合预期,预期应该是2(task2和task4) >>> not_ended_objects.distinct().count() 4 # 依然不符合预期
原因分析
- 关联查询的左连接行为:当使用
events__event_type__in作为When的条件时,Django会自动执行左连接,将每个Object与它的所有ObjectEvent关联,生成一对多的结果行。比如task1有3个Event,就会生成3条关联记录。 - Case语句的逐行判断逻辑:当前的
annotate逻辑是针对每一条关联记录设置ended值:如果当前关联的Event是COMPLETED/CANCELED,则ended=True,否则ended=False。这意味着:- task1有2个非结束Event(CREATED、SCHEDULED),对应的2条关联记录
ended=False;1个结束Event(CANCELED),对应1条记录ended=True。 - task3有2个非结束Event,对应的2条记录
ended=False;1个结束Event,对应1条记录ended=True。 - task2和task4的所有Event都是非结束类型,对应的关联记录
ended全为False。
- task1有2个非结束Event(CREATED、SCHEDULED),对应的2条关联记录
- 过滤计数的偏差:
- 过滤
ended=False时,会筛选出所有ended为False的关联行,总数是2(task1)+1(task2)+2(task3)+2(task4)=7,这就是计数为7的原因。 - 使用
distinct()后,会对Object去重,得到4个Object(因为这4个Object都存在至少一条ended=False的关联记录),但这并不是我们需要的“没有任何结束事件的Object”。
- 过滤
解决方案
需要修改annotate的逻辑,让ended属性针对Object本身是否存在至少一个结束事件进行判断,而非逐行判断。以下是两种可行方案:
方案1:使用Exists子查询
利用Exists子查询直接判断当前Object是否存在符合条件的Event,返回的是针对Object的布尔值:
class ObjectManager(models.Manager): def get_queryset(self): qs = super().get_queryset() qs = qs.annotate( ended=models.Exists( ObjectEvent.objects.filter( object=models.OuterRef('pk'), event_type__in=( ObjectEvent.EventTypes.COMPLETED, ObjectEvent.EventTypes.CANCELED, ) ) ) ) return qs
方案2:使用Count过滤统计
通过统计符合条件的Event数量,判断是否大于0来标记ended:
class ObjectManager(models.Manager): def get_queryset(self): qs = super().get_queryset() qs = qs.annotate( ended=models.Case( models.When( models.Count( 'events', filter=models.Q( events__event_type__in=( ObjectEvent.EventTypes.COMPLETED, ObjectEvent.EventTypes.CANCELED, ) ) ) > 0, then=models.Value(True), ), default=models.Value(False), output_field=models.BooleanField(verbose_name="Object ended"), ) ) return qs
修改后重新测试,过滤ended=False时会得到预期的2个结果(task2和task4),计数和去重后的结果一致。
内容的提问来源于stack exchange,提问作者Zeno Dalla Valle
相关产品推荐
相关产品推荐

