Агрегирующие функции в Django ORM

С агрегирующими функциями в SQL вы знакомы: они возвращают из БД сумму, максимум или среднее арифметическое по определённому полю для всех записей в таблице или для выборки: «SQL, выбери в таблице joke все записи за 1.04.2001 и примени к ним агрегирующую функцию: найди запись с максимальным значением в столбце likes».
База данных подсчитает и выведет полученные данные в отдельный столбец результирующей выборки.
Для группировки строк используется специальный запрос GROUP BY, в котором перечисляются столбцы, по которым должна идти группировка. Это значит, что если в таблице несколько строк с одним и тем же значением в указанном поле, то такие строки объединятся в одну, а над другими столбцами можно провести групповые операции.
Слоноводы несколько дней кормят слонов ивовыми вениками и записывают, кто сколько съел.
iddatenamebroom
12019-08-17Аристарх43
22019-08-17Джульетта38
32019-08-17Кузя26
42019-08-18Аристарх35
52019-08-18Джульетта33
62019-08-18Кузя19
72019-08-19Аристарх35
82019-08-19Джульетта41
92019-08-19Кузя9
102019-08-20Аристарх34
112019-08-20Джульетта42
122019-08-20Кузя18
132019-08-21Аристарх44
142019-08-21Джульетта39
152019-08-21Кузя23
Затем слоноводы хотят найти для каждого слона максимальное количество съеденного за один день, и для этого делают запрос к базе.
Скопировать кодSQL
SELECT date, name, MAX(broom) FROM brooms GROUP BY name;
«Под капотом» происходит следующее: сначала в колонке name будут найдены и сгруппированы все совпадающие записи, получится три группы:
все записи по Аристарху:
iddatenamebroom
12019-08-17Аристарх43
42019-08-18Аристарх35
72019-08-19Аристарх35
102019-08-20Аристарх34
132019-08-21Аристарх44
все записи по Джульетте:
iddatenamebroom
22019-08-17Джульетта38
52019-08-18Джульетта33
82019-08-19Джульетта41
112019-08-20Джульетта42
142019-08-21Джульетта39
и все записи по Кузе:
iddatenamebroom
32019-08-17Кузя26
62019-08-18Кузя19
92019-08-19Кузя9
122019-08-20Кузя18
152019-08-21Кузя23
Затем функция MAX(broom) в каждой из этих групп найдет максимальное значение в колонке broom, и в результирующую выборку будут выведены имена и найденные значения.
Результат запроса будет таким:
datenameMAX(broom)
2019-08-21Аристарх44
2019-08-20Джульетта42
2019-08-17Кузя26
Если в БД какого-нибудь магазина в таблице хранятся чеки покупок, можно сгруппировать записи по id покупателя, после чего применить агрегирующую функцию к полю, где указана цена чека — и посчитать максимальную или среднюю сумму заказов определённого покупателя.
А можно просто сложить все value по отдельности для A, B и C:
image
Если не указать GROUP BY — агрегирующая функция будет применена ко всей таблице. Например SELECT SUM(Value) FROM sample_table; вернет значение 140 для примера на иллюстрации, а SELECT MAX(broom) FROM brooms; вернёт 44, максимальное значение во всей колонке broom из таблицы со слонами и вениками.

Метод count()

Задача подсчёта записей в таблице или в выборке столь популярна, что в Django ORM она получила несколько вариантов решения. Чтобы узнать количество полученных строк, можно вызвать метод count() для objects, дописав его к конструкции, делающей выборку:
Скопировать кодPYTHON
# выбираем посты, опублибликованные позже (gt) июня 1854 года, затем пересчитываем их >>> Post.objects.filter(pub_date__month__gt=6, pub_date__year=1854).count() (0.012) SELECT COUNT(*) AS "__count" FROM "posts_post" WHERE (django_datetime_extract('month', "posts_post"."pub_date", 'UTC') > 6 AND "posts_post"."pub_date" BETWEEN '1854-01-01 00:00:00' AND '1854-12-31 23:59:59.999999'); args=(6, '1854-01-01 00:00:00', '1854-12-31 23:59:59.999999') 30
Когда нужно узнать лишь число записей, но сами записи не нужны — не вызывайте метод all(), применяйте count().
Метод all() заставит базу прочитать и передать в код весь объем данных, а count() выполнит всю работу на стороне базы и вернет лишь одно число. Загрузка и обработка всех данных в такой ситуации — это пустой расход ресурсов.
Скопировать кодPYTHON
# Проверка: есть ли данные в таблице # Неправильный способ: этот код загружает вообще все данные из таблицы if User.objects.all(): print("Пользователи есть") # Правильный способ: этот код просит базу вернуть число if User.objects.count(): print("Пользователи есть")

Метод aggregate()

Метод aggregate() применяет агрегирующие функции к определённой выборке или ко всей таблице.
В Django есть несколько агрегирующих функций, вот самые популярные из них:
  • Avg: вернёт среднее значение по указанной колонке в выборке
  • Count: вернёт количество записей в выборке, как и метод count(), описанный выше
  • Max: вернёт максимальное значение по указанной колонке в выборке
  • Min: вернёт минимальное значение по указанной колонке в выборке
  • Sum: вернёт сумму значений по указанной колонке в выборке
Эти функции хранятся в модуле django.db.models, перед применением их надо импортировать в код.
Найдём самый большой id и пересчитаем объекты в модели Post при помощи метода aggregate():
Скопировать кодPYTHON
>>> from django.db.models import Max, Count # найти максимальное значение id для объектов Post >>> Post.objects.aggregate(Max("id")) (0.000) SELECT MAX("posts_post"."id") AS "id__max" FROM "posts_post"; args=() {'id__max': 43} # пересчитать объекты id в модели Post >>> Post.objects.aggregate(Count("id")) (0.000) SELECT COUNT("posts_post"."id") AS "id__count" FROM "posts_post"; args=() {'id__count': 37} 37

Связи между таблицами

При создании модели Post мы добавили в неё ссылку на автора, на модель User, и указали related_name="posts".
Скопировать кодPYTHON
class Post(models.Model): # ... какой-то код author = models.ForeignKey( User, on_delete=models.CASCADE, related_name="posts" )
У модели User автоматически появится свойство posts, оно ссылается на все записи текущего автора.
Скопировать кодPYTHON
>>> leo = User.objects.get(id=2) (0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 2; args=(2,) >>> leo.username 'leo' >>> leo.posts.count() # leo.posts — это выборка тех объектов из модели Post, # у которых в поле author_id стоит "2" (которые связаны с leo), # потому что leo — это объект User с id=2 (0.000) SELECT COUNT(*) AS "__count" FROM "posts_post" WHERE "posts_post"."author_id" = 2; args=(2,) 36
Свойство posts у объекта модели User появилось в результате того, что модель Post ссылается на User. И, благодаря магии ORM, мы можем получить все записи автора, обратившись к свойству posts.
Как только вы создаёте в модели поле со ссылкой на другую модель — у второй модели появляется новое свойство, «обратная связь», и к этому свойству можно обращаться, как к любому другому.
Свойства, ссылающиеся на объекты, имеют специальный тип: менеджер объектов. До сих пор мы работали с менеджером объектов objects, делая запросы вида User.objects.get(id=2) или Post.objects.all().
Свойство posts — такой же менеджер объектов, как и objects, разница лишь в том, что оно было создано при связывании моделей.
Для менеджеров объектов можно вызывать метод count(), который пересчитает связанные объекты. Django сам разберется, как правильно составить запрос в этом случае.
Чтобы применить агрегирующие функции к связанным данным из других таблиц, запрос делается через аннотирование, методом annotate().

Метод annotate()

Чтобы получить количество записей, созданных каждым пользователем, нужно сгруппировать несколько таблиц и добавить объекту новое свойство, которое будет содержать количество связанных с ним объектов в другой таблице.
Конечно, можно было бы в цикле пройтись по всем пользователям, вызывая метод count() менеджера объектов posts. Но это создало бы огромную нагрузку на базу и каскад однотипных запросов.
В чистом SQL вопрос решается так: в результирующую выборку попадают запрошенные столбцы исходных таблиц и дополнительные столбцы с результатами вычислений.
Похожим образом работает метод annotate() в ORM, но в результате к полученным объектам добавляется новое свойство, содержащее результат вычисления.
В следующем примере аргумент posts_count — это имя нового свойства объекта, оно появится у объектов модели User:
Скопировать кодPYTHON
# Достать из модели User все объекты, # создать свойство posts_count и записать в него число постов, связанных с автором. # posts — это свойство модели User, менеджер объектов >>>> annotated_results = User.objects.annotate(posts_count = Count('posts')) >>> >>> annotated_results (0.001) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined", COUNT("posts_post"."id") AS "posts_count" FROM "auth_user" LEFT OUTER JOIN "posts_post" ON ("auth_user"."id" = "posts_post"."author_id") GROUP BY "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" LIMIT 21; args=() <QuerySet [<User: admin>, <User: leo>]>
Скопировать кодPYTHON
# перебрать в цикле список пользователей annotated_results # и для каждого объекта вывести свойство name # и новое свойство posts_count, которое хранит число постов пользователя >>> for item in annotated_results: ... print(f"Постов у пользователя {item.username}: {item.posts_count}") ... (0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined", COUNT("posts_post"."id") AS "posts_count" FROM "auth_user" LEFT OUTER JOIN "posts_post" ON ("auth_user"."id" = "posts_post"."author_id") GROUP BY "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined"; args=() Постов у пользователя admin: 2 Постов у пользователя leo: 36 # хорошо, что у нас пока что не 100500 авторов

Разница между annotate() и aggregate()

Метод annotate() возвращает объекты и добавляет к ним новые свойства:
Скопировать кодPYTHON
>>> rez = User.objects.annotate(written_posts = Count('posts')) >>> rez[1].written_posts 36 # у объекта класса User появилось свойство written_posts, # хотя в модели User оно не описано
Метод aggregate() отдает только значение, результат работы агрегирующей функции:
Скопировать кодPYTHON
>>> Checks.objects.aggregate(average_price=Avg('price')) {'average_price': 127.01}