Аннотации Django Count и Sum мешают друг другу - PullRequest
3 голосов
/ 12 июня 2019

При создании комплекса 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')

СВ своей тестовой базе данных я получаю следующие (правильные) результаты:

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}]

Если теперь я объединю обе аннотации в одном 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}]

Обратите внимание, что rarity_sum теперь имеет другие значения, чем раньше.Удаление distinct=True не влияет на результат.Я также пытался использовать функцию DistinctSum из этого ответа , в этом случае все rarity_sum установлены на 18 (также неточно).

Почему это так?Как объединить обе аннотации в одном QuerySet?

Редактировать : вот запрос sqlite, сгенерированный комбинированным QuerySet:

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"

Используемые данныедля приведенных выше результатов доступно здесь .

Ответы [ 2 ]

4 голосов
/ 16 июня 2019

Это не проблема с Django ORM, это просто способ работы реляционных баз данных.Когда вы создаете простые наборы запросов, такие как

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

или

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

ORM делает именно то, что вы ожидаете, - присоединяйтесь Player с Weapon

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"

или 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"

и выполните агрегирование COUNT или SUM над ними.

Обратите внимание, что хотя первый запрос имеет два объединенияМежду тремя таблицами промежуточная таблица Unit не находится ни в столбцах, на которые есть ссылки в SELECT, ни в предложении GROUP BY.Единственная роль, которую играет здесь Unit, - присоединиться к Player с Weapon.

Теперь, если вы посмотрите на свой третий набор запросов, все станет сложнее.Опять же, как и в первом запросе, соединения находятся между тремя таблицами, но теперь на Unit есть ссылка на SELECT, поскольку существует SUM агрегация для Unit.rarity:

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"

И этопринципиальная разница между вторым и третьим запросами.Во втором запросе вы присоединяете Player к Unit, поэтому один Unit будет указан один раз для каждого игрока, на которого он ссылается.

Но в третьем запросе, к которому вы присоединяетесь Player до Unit, а затем Unit до Weapon, поэтому не только один Unit будет указан один раз для каждого игрока, на которого он ссылается, , но и для каждого оружия, которое ссылается на Unit.

Давайте рассмотрим простой пример:

insert into sandbox_player values (1, "player_1");

insert into sandbox_unit values(1, 10, 1);

insert into sandbox_weapon values (1, 1), (2, 1);

Один игрок, один юнит и два оружия, которые ссылаются на один и тот же юнит.

Убедитесь, что проблемасуществует:

>>> 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}]>

Из этого примера легко увидеть, что проблема в том, что в комбинированном запросе юнит будет указан дважды, по одному разу для каждого оружия, ссылающегося на него:

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   

Что делать?

Как уже упоминал @ivissani, одним из самых простых решений было бы написать подзапросы для каждого из агрегатов:

>>> from django.db.models import Count, Sum, Subquery, IntegerField
>>> 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}]>

, который выдает следующий SQL

SELECT "sandbox_player"."id", "sandbox_player"."name", 
(
    SELECT COUNT(U2."id") AS "weapon_count"
    FROM "sandbox_player" U0 
    LEFT OUTER JOIN "sandbox_unit" U1
        ON (U0."id" = U1."player_id")
    LEFT OUTER JOIN "sandbox_weapon" U2 
        ON (U1."id" = U2."unit_id")
    WHERE U0."id" = ("sandbox_player"."id") 
    GROUP BY U0."id", U0."name"
) AS "weapon_count", 
(
    SELECT SUM(U1."rarity") AS "rarity_sum"
    FROM "sandbox_player" U0
    LEFT OUTER JOIN "sandbox_unit" U1
        ON (U0."id" = U1."player_id")
    WHERE U0."id" = ("sandbox_player"."id")
GROUP BY U0."id", U0."name") AS "rarity_sum"
FROM "sandbox_player"
3 голосов
/ 17 июня 2019

Несколько замечаний, дополняющих отличный ответ Рктави:

1) Эта проблема, по-видимому, уже 10 лет считается ошибкой 1004 *. Это даже упоминается в официальной документации .

2) Преобразуя QuerySets моего реального проекта в подзапросы (согласно ответу rktavi), я заметил, что объединение аннотаций без костей (для подсчетов distinct=True, которые всегда работали правильно) с Subquery (для сумм) дает чрезвычайно длительную обработку (35 с против 100 мс) и неверные результаты для суммы. Это верно в моей реальной настройке (11 отфильтрованных подсчетов для различных вложенных отношений и 1 отфильтрованная сумма для многострочных отношений, SQLite3), но не может быть воспроизведено с помощью простых моделей, описанных выше. Эта проблема может быть сложной, поскольку другая часть вашего кода может добавить аннотацию к вашему QuerySet (например, функцию Table.order_FOO()), что приведет к проблеме.

3) С той же самой установкой у меня есть анекдотическое доказательство того, что QuerySets типа подзапроса быстрее по сравнению с QuerySets с аннотациями без костей (конечно, в случаях, когда у вас есть только distinct=True счетчиков). Я мог наблюдать это как с локальным SQLite3 (83 мс против 260 мс), так и с размещенным PostgreSQL (320 мс против 540 мс).

В результате вышеизложенного я полностью избегу использования аннотаций «голыми руками» в пользу подзапросов.

...