Django Count和Sum批注相互干扰

abe*_*bey 6 python django django-queryset

在构建QuerySet带有多个批注的复合体时,我遇到了一个问题,该问题可以通过以下简单设置重现。

这些是模型:

class Player(models.Model):
    name = models.CharField(max_length=200)

class Unit(models.Model):
    player = models.ForeignKey(Player, on_delete=models.CASCADE,
                               related_name='unit_set')
    rarity = models.IntegerField()

class Weapon(models.Model):
    unit = models.ForeignKey(Unit, on_delete=models.CASCADE,
                             related_name='weapon_set')
Run Code Online (Sandbox Code Playgroud)

使用我的测试数据库,可以获得以下(正确)结果:

Player.objects.annotate(weapon_count=Count('unit_set__weapon_set'))

[{'id': 1, 'name': 'James', 'weapon_count': 23},
 {'id': 2, 'name': 'Max', 'weapon_count': 41},
 {'id': 3, 'name': 'Bob', 'weapon_count': 26}]


Player.objects.annotate(rarity_sum=Sum('unit_set__rarity'))

[{'id': 1, 'name': 'James', 'rarity_sum': 42},
 {'id': 2, 'name': 'Max', 'rarity_sum': 89},
 {'id': 3, 'name': 'Bob', 'rarity_sum': 67}]
Run Code Online (Sandbox Code Playgroud)

如果现在将两个批注合并到相同的QuerySet,我将获得不同的(不准确的)结果:

Player.objects.annotate(
    weapon_count=Count('unit_set__weapon_set', distinct=True),
    rarity_sum=Sum('unit_set__rarity'))

[{'id': 1, 'name': 'James', 'weapon_count': 23, 'rarity_sum': 99},
 {'id': 2, 'name': 'Max', 'weapon_count': 41, 'rarity_sum': 183},
 {'id': 3, 'name': 'Bob', 'weapon_count': 26, 'rarity_sum': 113}]
Run Code Online (Sandbox Code Playgroud)

请注意,rarity_sum现在与以前相比具有不同的值。删除distinct=True不会影响结果。我还尝试使用此答案中DistinctSum函数,在这种情况下,所有函数都设置为(也不准确)。rarity_sum18

为什么是这样?如何将两个批注合并到同一位置QuerySet

编辑:这是由组合QuerySet生成的sqlite查询:

SELECT "sandbox_player"."id",
       "sandbox_player"."name",
       COUNT(DISTINCT "sandbox_weapon"."id") AS "weapon_count",
       SUM("sandbox_unit"."rarity")          AS "rarity_sum"
FROM "sandbox_player"
         LEFT OUTER JOIN "sandbox_unit" ON ("sandbox_player"."id" = "sandbox_unit"."player_id")
         LEFT OUTER JOIN "sandbox_weapon" ON ("sandbox_unit"."id" = "sandbox_weapon"."unit_id")
GROUP BY "sandbox_player"."id", "sandbox_player"."name"
Run Code Online (Sandbox Code Playgroud)

用于以上结果的数据可在此处获得

rkt*_*avi 14

这不是Django ORM的问题,这只是关系数据库的工作方式。当您构建简单的查询集时,例如

Player.objects.annotate(weapon_count=Count('unit_set__weapon_set'))
Run Code Online (Sandbox Code Playgroud)

要么

Player.objects.annotate(rarity_sum=Sum('unit_set__rarity'))
Run Code Online (Sandbox Code Playgroud)

ORM不正是你希望它做什么-加入PlayerWeapon

SELECT "sandbox_player"."id", "sandbox_player"."name", COUNT("sandbox_weapon"."id") AS "weapon_count"
FROM "sandbox_player"
LEFT OUTER JOIN "sandbox_unit" 
    ON ("sandbox_player"."id" = "sandbox_unit"."player_id")
LEFT OUTER JOIN "sandbox_weapon" 
    ON ("sandbox_unit"."id" = "sandbox_weapon"."unit_id")
GROUP BY "sandbox_player"."id", "sandbox_player"."name"
Run Code Online (Sandbox Code Playgroud)

Player搭配Unit

SELECT "sandbox_player"."id", "sandbox_player"."name", SUM("sandbox_unit"."rarity") AS "rarity_sum"
FROM "sandbox_player"
LEFT OUTER JOIN "sandbox_unit" ON ("sandbox_player"."id" = "sandbox_unit"."player_id")
GROUP BY "sandbox_player"."id", "sandbox_player"."name"
Run Code Online (Sandbox Code Playgroud)

并对其执行COUNTSUM聚合。

请注意,尽管第一个查询在三个表之间有两个联接,但是中间表Unit既不在引用的列中SELECT,也不在GROUP BY子句中。那唯一的作用Unit在这里踢球是加入PlayerWeapon

现在,如果您查看第三个查询集,事情将变得更加复杂。再次,如在第一个查询中一样,联接位于三个表之间,但现在由于存在以下汇总而Unit被引用:SELECTSUMUnit.rarity

Player.objects.annotate(weapon_count=Count('unit_set__weapon_set'))
Run Code Online (Sandbox Code Playgroud)

这是第二和第三查询之间的关键区别。在第二个查询,要加入PlayerUnit,所以单Unit将再次为每个玩家,它引用被列出。

但在第三个查询要加入PlayerUnitUnitWeapon,所以不能只有一个Unit会被列出一次为每个玩家,它的参考,同时也为每个武器引用Unit

让我们看一个简单的例子:

Player.objects.annotate(rarity_sum=Sum('unit_set__rarity'))
Run Code Online (Sandbox Code Playgroud)

一个玩家,一个单位和两个引用相同单位的武器。

确认问题存在:

>>> from sandbox.models import Player
>>> from django.db.models import Count, Sum

>>> Player.objects.annotate(weapon_count=Count('unit_set__weapon_set')).values()
<QuerySet [{'id': 1, 'name': 'player_1', 'weapon_count': 2}]>

>>> Player.objects.annotate(rarity_sum=Sum('unit_set__rarity')).values()
<QuerySet [{'id': 1, 'name': 'player_1', 'rarity_sum': 10}]>


>>> Player.objects.annotate(
...     weapon_count=Count('unit_set__weapon_set', distinct=True),
...     rarity_sum=Sum('unit_set__rarity')).values()
<QuerySet [{'id': 1, 'name': 'player_1', 'weapon_count': 2, 'rarity_sum': 20}]>

Run Code Online (Sandbox Code Playgroud)

从该示例中可以很容易地看出问题是在组合查询中该单位将被列出两次,而引用该武器的每种武器都将被列出一次:

sqlite> SELECT "sandbox_player"."id",
   ...>        "sandbox_player"."name",
   ...>        "sandbox_weapon"."id",
   ...>        "sandbox_unit"."rarity"
   ...> FROM "sandbox_player"
   ...>          LEFT OUTER JOIN "sandbox_unit" ON ("sandbox_player"."id" = "sandbox_unit"."player_id")
   ...>          LEFT OUTER JOIN "sandbox_weapon" ON ("sandbox_unit"."id" = "sandbox_weapon"."unit_id");
id          name        id          rarity    
----------  ----------  ----------  ----------
1           player_1    1           10        
1           player_1    2           10   
Run Code Online (Sandbox Code Playgroud)

你该怎么办?

正如@ivissani所提到的,最简单的解决方案之一是为每个聚合编写子查询:

>>> from django.db.models import Count, IntegerField, OuterRef, Subquery, Sum
>>> weapon_count = Player.objects.annotate(weapon_count=Count('unit_set__weapon_set')).filter(pk=OuterRef('pk'))
>>> rarity_sum = Player.objects.annotate(rarity_sum=Sum('unit_set__rarity')).filter(pk=OuterRef('pk'))
>>> qs = Player.objects.annotate(
...     weapon_count=Subquery(weapon_count.values('weapon_count'), output_field=IntegerField()),
...     rarity_sum=Subquery(rarity_sum.values('rarity_sum'), output_field=IntegerField())
... )
>>> qs.values()
<QuerySet [{'id': 1, 'name': 'player_1', 'weapon_count': 2, 'rarity_sum': 10}]>
Run Code Online (Sandbox Code Playgroud)

产生以下SQL

SELECT "sandbox_player"."id", "sandbox_player"."name", COUNT("sandbox_weapon"."id") AS "weapon_count"
FROM "sandbox_player"
LEFT OUTER JOIN "sandbox_unit" 
    ON ("sandbox_player"."id" = "sandbox_unit"."player_id")
LEFT OUTER JOIN "sandbox_weapon" 
    ON ("sandbox_unit"."id" = "sandbox_weapon"."unit_id")
GROUP BY "sandbox_player"."id", "sandbox_player"."name"
Run Code Online (Sandbox Code Playgroud)

  • 另外,对于记录。虽然我明白你的意思,但我不同意这不是 Django 的 ORM 的问题。我觉得我的原始组合查询很好地捕捉了我的意图,并且应该开箱即用。事实上,我发现这被官方认为是一个 [bug](https://code.djangoproject.com/ticket/10060) 并在 [official documentation](https://docs.djangoproject.com /en/2.2/topics/db/aggregation/#combining-multiple-aggregations)。 (4认同)

Ben*_*hon 10

基于 @rktavi 的出色回答,我创建了两个简化Subquery/CountSubquery/Sum模式的帮助器类:

class SubqueryCount(Subquery):
    template = "(SELECT count(*) FROM (%(subquery)s) _count)"
    output_field = PositiveIntegerField()


class SubquerySum(Subquery):
    template = '(SELECT sum(_sum."%(column)s") FROM (%(subquery)s) _sum)'

    def __init__(self, queryset, column, output_field=None, **extra):
        if output_field is None:
            output_field = queryset.model._meta.get_field(column)
        super().__init__(queryset, output_field, column=column, **extra)
Run Code Online (Sandbox Code Playgroud)

人们可以像这样使用这些助手:

from django.db.models import OuterRef

weapons = Weapon.objects.filter(unit__player_id=OuterRef('id'))
units = Unit.objects.filter(player_id=OuterRef('id'))

qs = Player.objects.annotate(weapon_count=SubqueryCount(weapons),
                             rarity_sum=SubquerySum(units, 'rarity'))
Run Code Online (Sandbox Code Playgroud)


abe*_*bey 9

补充rktavi出色答案的一些说明:

1)这个问题显然已经被认为是10 年的错误。它甚至在官方文档中被提及。

2)在将我的实际项目的 QuerySets 转换为子查询时(根据 rktavi 的回答),我注意到将基本注释(对于distinct=True始终正常工作的计数)与 a Subquery(对于总和)相结合会产生极长的处理时间(35 秒 vs. 35 秒)。 100 毫秒)不正确的总和结果。这在我的实际设置中是正确的(对各种嵌套关系进行 11 个过滤计数,对多重嵌套关系 SQLite3 进行 1 个过滤总和),但不能用上面的简单模型重现。这个问题可能很棘手,因为您的代码的另一部分可能会向您的 QuerySet(例如一个Table.order_FOO()函数)添加注释,从而导致问题。

3)使用相同的设置,我有轶事证据表明子查询类型的 QuerySets 与基本注释 QuerySets 相比更快(distinct=True当然,在您只有计数的情况下)。我可以通过本地 SQLite3(83 毫秒 vs 260 毫秒)和托管 PostgreSQL(320 毫秒 vs 540 毫秒)观察到这一点。

由于上述原因,我将完全避免使用有利于子查询的准系统注释。