聚合

關於 Django 資料庫抽象化 API 的主題指南描述了如何使用 Django 查詢來建立、擷取、更新和刪除個別物件。然而,有時您需要擷取透過總結或聚合物件集合而衍生的值。本主題指南說明如何使用 Django 查詢產生和傳回聚合值。

在本指南中,我們將參考以下模型。這些模型用於追蹤一系列線上書店的庫存。

from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=100)
    age = models.IntegerField()


class Publisher(models.Model):
    name = models.CharField(max_length=300)


class Book(models.Model):
    name = models.CharField(max_length=300)
    pages = models.IntegerField()
    price = models.DecimalField(max_digits=10, decimal_places=2)
    rating = models.FloatField()
    authors = models.ManyToManyField(Author)
    publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
    pubdate = models.DateField()


class Store(models.Model):
    name = models.CharField(max_length=300)
    books = models.ManyToManyField(Book)

速查表

趕時間嗎?以下是如何執行常見的聚合查詢,假設使用上述模型

# Total number of books.
>>> Book.objects.count()
2452

# Total number of books with publisher=BaloneyPress
>>> Book.objects.filter(publisher__name="BaloneyPress").count()
73

# Average price across all books, provide default to be returned instead
# of None if no books exist.
>>> from django.db.models import Avg
>>> Book.objects.aggregate(Avg("price", default=0))
{'price__avg': 34.35}

# Max price across all books, provide default to be returned instead of
# None if no books exist.
>>> from django.db.models import Max
>>> Book.objects.aggregate(Max("price", default=0))
{'price__max': Decimal('81.20')}

# Difference between the highest priced book and the average price of all books.
>>> from django.db.models import FloatField
>>> Book.objects.aggregate(
...     price_diff=Max("price", output_field=FloatField()) - Avg("price")
... )
{'price_diff': 46.85}

# All the following queries involve traversing the Book<->Publisher
# foreign key relationship backwards.

# Each publisher, each with a count of books as a "num_books" attribute.
>>> from django.db.models import Count
>>> pubs = Publisher.objects.annotate(num_books=Count("book"))
>>> pubs
<QuerySet [<Publisher: BaloneyPress>, <Publisher: SalamiPress>, ...]>
>>> pubs[0].num_books
73

# Each publisher, with a separate count of books with a rating above and below 5
>>> from django.db.models import Q
>>> above_5 = Count("book", filter=Q(book__rating__gt=5))
>>> below_5 = Count("book", filter=Q(book__rating__lte=5))
>>> pubs = Publisher.objects.annotate(below_5=below_5).annotate(above_5=above_5)
>>> pubs[0].above_5
23
>>> pubs[0].below_5
12

# The top 5 publishers, in order by number of books.
>>> pubs = Publisher.objects.annotate(num_books=Count("book")).order_by("-num_books")[:5]
>>> pubs[0].num_books
1323

QuerySet 上產生聚合

Django 提供了兩種產生聚合的方法。第一種方法是在整個 QuerySet 上產生摘要值。例如,假設您想要計算所有可供銷售的書籍的平均價格。Django 的查詢語法提供了一種描述所有書籍集合的方法

>>> Book.objects.all()

我們需要一種方法來計算屬於此 QuerySet 的物件的摘要值。這可以透過將 aggregate() 子句附加到 QuerySet 來完成

>>> from django.db.models import Avg
>>> Book.objects.all().aggregate(Avg("price"))
{'price__avg': 34.35}

在這個範例中,all() 是多餘的,因此可以簡化為

>>> Book.objects.aggregate(Avg("price"))
{'price__avg': 34.35}

aggregate() 子句的引數描述了我們想要計算的聚合值 - 在這種情況下,是 Book 模型上 price 欄位的平均值。可在 QuerySet 參考 中找到可用的聚合函式列表。

aggregate()QuerySet 的終端子句,當被調用時,會傳回一個名稱-值對的字典。名稱是聚合值的識別符;值是計算出的聚合值。名稱會自動從欄位的名稱和聚合函式產生。如果您想要手動指定聚合值的名稱,您可以在指定聚合子句時提供該名稱

>>> Book.objects.aggregate(average_price=Avg("price"))
{'average_price': 34.35}

如果您想要產生多個聚合,請將另一個引數新增到 aggregate() 子句。因此,如果我們也想知道所有書籍的最大和最小價格,我們會發出以下查詢

>>> from django.db.models import Avg, Max, Min
>>> Book.objects.aggregate(Avg("price"), Max("price"), Min("price"))
{'price__avg': 34.35, 'price__max': Decimal('81.20'), 'price__min': Decimal('12.99')}

QuerySet 中的每個項目產生聚合

產生摘要值的第二種方法是為 QuerySet 中的每個物件產生獨立的摘要。例如,如果您正在擷取書籍清單,您可能想知道每本書有多少位作者參與撰寫。每本 Book 都與 Author 有多對多關係;我們想要摘要 QuerySet 中每本書的這種關係。

可以使用 annotate() 子句來產生每個物件的摘要。當指定 annotate() 子句時,QuerySet 中的每個物件都會使用指定的的值進行註解。

這些註解的語法與 aggregate() 子句使用的語法相同。annotate() 的每個引數都描述了要計算的聚合。例如,要使用作者數量註解書籍

# Build an annotated queryset
>>> from django.db.models import Count
>>> q = Book.objects.annotate(Count("authors"))
# Interrogate the first object in the queryset
>>> q[0]
<Book: The Definitive Guide to Django>
>>> q[0].authors__count
2
# Interrogate the second object in the queryset
>>> q[1]
<Book: Practical Django Projects>
>>> q[1].authors__count
1

aggregate() 一樣,註解的名稱會自動從聚合函式的名稱和正在聚合的欄位的名稱衍生而來。您可以在指定註解時提供別名來覆寫此預設名稱

>>> q = Book.objects.annotate(num_authors=Count("authors"))
>>> q[0].num_authors
2
>>> q[1].num_authors
1

aggregate() 不同,annotate() 不是終端子句。annotate() 子句的輸出是 QuerySet;可以使用任何其他 QuerySet 操作(包括 filter()order_by(),甚至額外呼叫 annotate())來修改此 QuerySet

組合多個聚合

將多個聚合與 annotate() 組合將會產生錯誤的結果,因為使用聯結而不是子查詢

>>> book = Book.objects.first()
>>> book.authors.count()
2
>>> book.store_set.count()
3
>>> q = Book.objects.annotate(Count("authors"), Count("store"))
>>> q[0].authors__count
6
>>> q[0].store__count
6

對於大多數聚合,沒有辦法避免這個問題,但是 Count 聚合有一個可能有幫助的 distinct 參數

>>> q = Book.objects.annotate(
...     Count("authors", distinct=True), Count("store", distinct=True)
... )
>>> q[0].authors__count
2
>>> q[0].store__count
3

如有疑問,請檢查 SQL 查詢!

為了瞭解查詢中發生的情況,請考慮檢查 QuerySetquery 屬性。

聯結和聚合

到目前為止,我們處理了查詢模型欄位的聚合。但是,有時您要聚合的值會屬於與您正在查詢的模型相關的模型。

當在聚合函式中指定要聚合的欄位時,Django 將允許您使用與在篩選器中參照相關欄位時使用的相同的 雙底線表示法。然後,Django 將處理擷取和聚合相關值所需的任何表格聯結。

例如,若要找出每間商店提供的書籍價格範圍,您可以使用註解

>>> from django.db.models import Max, Min
>>> Store.objects.annotate(min_price=Min("books__price"), max_price=Max("books__price"))

這會告訴 Django 擷取 Store 模型,透過多對多關係與 Book 模型聯結,並在 book 模型的價格欄位上進行聚合,以產生最小值和最大值。

相同的規則適用於 aggregate() 子句。如果您想知道任何商店中可供銷售的任何書籍的最低和最高價格,您可以使用聚合

>>> Store.objects.aggregate(min_price=Min("books__price"), max_price=Max("books__price"))

聯結鏈可以根據您的需求設定深度。例如,若要擷取任何可供銷售的書籍的最年輕作者的年齡,您可以發出以下查詢

>>> Store.objects.aggregate(youngest_age=Min("books__authors__age"))

反向追蹤關係

跨越關係的查找 類似,對模型或與您正在查詢的模型相關的模型的欄位進行聚合和註解可以包括遍歷「反向」關係。相關模型的小寫名稱和雙底線也在此處使用。

例如,我們可以要求所有出版社,並使用其各自的總書籍庫存計數器進行註解(請注意我們如何使用 'book' 來指定 Publisher -> Book 反向外鍵躍點)

>>> from django.db.models import Avg, Count, Min, Sum
>>> Publisher.objects.annotate(Count("book"))

(產生的 QuerySet 中的每個 Publisher 都會有一個名為 book__count 的額外屬性。)

我們也可以要求每個出版社管理的任何書籍中最舊的書籍

>>> Publisher.objects.aggregate(oldest_pubdate=Min("book__pubdate"))

(產生的字典將有一個名為 'oldest_pubdate' 的索引鍵。如果未指定此類別名,則它會是相當長的 'book__pubdate__min'。)

這不僅適用於外鍵。它也適用於多對多關係。例如,我們可以要求每位作者,並註解考慮作者已(共同)撰寫的所有書籍的總頁數(請注意我們如何使用 'book' 來指定 Author -> Book 反向多對多躍點)

>>> Author.objects.annotate(total_pages=Sum("book__pages"))

(結果 QuerySet 中的每個 Author 都會有一個額外的屬性,名為 total_pages。如果沒有指定此別名,則會是較長的 book__pages__sum。)

或查詢我們檔案中所有作者所寫的書籍的平均評分

>>> Author.objects.aggregate(average_rating=Avg("book__rating"))

(產生的字典將會有一個名為 'average_rating' 的鍵。如果沒有指定此別名,則會是較長的 'book__rating__avg'。)

聚合和其他 QuerySet 子句

filter()exclude()

聚合也可以參與篩選。任何應用於正常模型欄位的 filter() (或 exclude())都會限制用於聚合的物件。

當與 annotate() 子句一起使用時,篩選會限制計算註釋的物件。例如,您可以使用以下查詢生成所有標題以 “Django” 開頭的書籍的註釋列表:

>>> from django.db.models import Avg, Count
>>> Book.objects.filter(name__startswith="Django").annotate(num_authors=Count("authors"))

當與 aggregate() 子句一起使用時,篩選會限制計算聚合的物件。例如,您可以使用以下查詢生成標題以 “Django” 開頭的所有書籍的平均價格:

>>> Book.objects.filter(name__startswith="Django").aggregate(Avg("price"))

篩選註釋

註釋值也可以被篩選。註釋的別名可以在 filter()exclude() 子句中以與任何其他模型欄位相同的方式使用。

例如,要生成有多個作者的書籍列表,您可以發出以下查詢:

>>> Book.objects.annotate(num_authors=Count("authors")).filter(num_authors__gt=1)

此查詢會產生一個帶註釋的結果集,然後根據該註釋產生一個篩選。

如果您需要兩個具有兩個獨立篩選的註釋,則可以使用任何聚合的 filter 引數。例如,要生成一個具有高評價書籍計數的作者列表:

>>> highly_rated = Count("book", filter=Q(book__rating__gte=7))
>>> Author.objects.annotate(num_books=Count("book"), highly_rated_books=highly_rated)

結果集中的每個 Author 都將具有 num_bookshighly_rated_books 屬性。另請參閱條件聚合

filterQuerySet.filter() 之間做選擇

避免將 filter 引數與單個註釋或聚合一起使用。使用 QuerySet.filter() 排除行效率更高。僅當使用兩個或多個具有不同條件的相同關係的聚合時,聚合 filter 引數才有用。

annotate()filter() 子句的順序

在開發涉及 annotate()filter() 子句的複雜查詢時,請特別注意將子句應用於 QuerySet 的順序。

當將 annotate() 子句應用於查詢時,註釋將在查詢達到請求註釋的點時的狀態下計算。這實際上的含義是 filter()annotate() 不是可交換的操作。

假設

  • 出版社 A 有兩本評分為 4 和 5 的書籍。

  • 出版社 B 有兩本評分為 1 和 4 的書籍。

  • 出版社 C 有一本評分為 1 的書籍。

以下是使用 Count 聚合的範例

>>> a, b = Publisher.objects.annotate(num_books=Count("book", distinct=True)).filter(
...     book__rating__gt=3.0
... )
>>> a, a.num_books
(<Publisher: A>, 2)
>>> b, b.num_books
(<Publisher: B>, 2)

>>> a, b = Publisher.objects.filter(book__rating__gt=3.0).annotate(num_books=Count("book"))
>>> a, a.num_books
(<Publisher: A>, 2)
>>> b, b.num_books
(<Publisher: B>, 1)

兩個查詢都會回傳至少有一本評分超過 3.0 的書籍的出版社列表,因此排除出版社 C。

在第一個查詢中,註釋先於篩選,因此篩選對註釋沒有影響。需要 distinct=True 以避免查詢錯誤

第二個查詢計算每個出版社評分超過 3.0 的書籍數量。篩選先於註釋,因此篩選會限制計算註釋時考慮的物件。

以下是另一個使用 Avg 聚合的範例

>>> a, b = Publisher.objects.annotate(avg_rating=Avg("book__rating")).filter(
...     book__rating__gt=3.0
... )
>>> a, a.avg_rating
(<Publisher: A>, 4.5)  # (5+4)/2
>>> b, b.avg_rating
(<Publisher: B>, 2.5)  # (1+4)/2

>>> a, b = Publisher.objects.filter(book__rating__gt=3.0).annotate(
...     avg_rating=Avg("book__rating")
... )
>>> a, a.avg_rating
(<Publisher: A>, 4.5)  # (5+4)/2
>>> b, b.avg_rating
(<Publisher: B>, 4.0)  # 4/1 (book with rating 1 excluded)

第一個查詢要求出版社至少有一本評分超過 3.0 的書籍的所有書籍的平均評分。第二個查詢要求僅對評分超過 3.0 的出版社書籍的評分進行平均。

很難直觀地了解 ORM 將如何將複雜的查詢集轉換為 SQL 查詢,因此如有疑問,請使用 str(queryset.query) 檢查 SQL 並編寫大量測試。

order_by()

註釋可以用作排序的基礎。當您定義 order_by() 子句時,您提供的聚合可以參考在查詢的 annotate() 子句中定義的任何別名。

例如,要按照為該書做出貢獻的作者數量對書籍的 QuerySet 進行排序,您可以使用以下查詢:

>>> Book.objects.annotate(num_authors=Count("authors")).order_by("num_authors")

values()

通常,註釋是基於每個物件生成的 — 帶註釋的 QuerySet 將為原始 QuerySet 中的每個物件回傳一個結果。但是,當使用 values() 子句來限制結果集中回傳的列時,評估註釋的方法會略有不同。原始結果不是為原始 QuerySet 中的每個結果回傳帶註釋的結果,而是根據 values() 子句中指定的欄位的唯一組合進行分組。然後為每個唯一群組提供註釋;該註釋是針對群組的所有成員計算的。

例如,考慮一個嘗試找出每位作者所寫書籍的平均評分的作者查詢:

>>> Author.objects.annotate(average_rating=Avg("book__rating"))

這將為資料庫中的每位作者回傳一個結果,並附帶其書籍的平均評分。

但是,如果您使用 values() 子句,結果將會略有不同:

>>> Author.objects.values("name").annotate(average_rating=Avg("book__rating"))

在此範例中,作者將按姓名分組,因此您只會針對每個唯一的作者姓名獲得一個帶註釋的結果。這表示如果您有兩位同名的作者,他們的結果將合併到查詢輸出中的單個結果中;平均值將計算為兩位作者所寫書籍的平均值。

annotate()values() 子句的順序

filter() 子句一樣,將 annotate()values() 子句應用於查詢的順序非常重要。如果 values() 子句先於 annotate(),則註釋將使用 values() 子句描述的分組進行計算。

但是,如果 annotate() 子句先於 values() 子句,則會在整個查詢集上產生註釋。在這種情況下,values() 子句只限制在輸出時產生的欄位。

例如,如果我們反轉先前範例中 values()annotate() 子句的順序

>>> Author.objects.annotate(average_rating=Avg("book__rating")).values(
...     "name", "average_rating"
... )

現在,這將為每位作者產生一個唯一的結果;但是,在輸出資料中只會回傳作者的姓名和 average_rating 註釋。

您還應該注意,average_rating 已明確包含在要回傳的值列表中。這是因為 values()annotate() 子句的順序。

如果 values() 子句先於 annotate() 子句,則任何註釋都將自動新增到結果集中。但是,如果在 annotate() 子句之後應用 values() 子句,則需要明確包含聚合列。

order_by() 的互動

查詢集的 order_by() 部分中提及的欄位用於選取輸出資料,即使它們沒有在 values() 呼叫中另行指定。這些額外的欄位用於將「相似」結果分組在一起,並且它們可以使原本相同的結果列顯示為不同的結果列。這在計算事物時尤其明顯。

舉例來說,假設您有如下的模型

from django.db import models


class Item(models.Model):
    name = models.CharField(max_length=10)
    data = models.IntegerField()

如果您想要計算每個不同的 data 值在排序的查詢集中出現的次數,您可以嘗試這樣做

items = Item.objects.order_by("name")
# Warning: not quite correct!
items.values("data").annotate(Count("id"))

…這會將 Item 物件依據它們相同的 data 值分組,然後計算每個組別中 id 值的數量。但它並不會完全如預期運作。依據 name 排序也會影響分組,因此這個查詢會依據不同的 (data, name) 配對進行分組,這並不是你想要的。相反地,你應該建構這個查詢集

items.values("data").annotate(Count("id")).order_by()

…清除查詢中的任何排序。你也可以依據,例如 data 排序,而不會有任何不良影響,因為它已經在查詢中發揮作用。

此行為與 distinct() 的查詢集文件中所述相同,總體規則也相同:通常你不會希望額外的欄位在結果中發揮作用,因此請清除排序,或至少確保它僅限於你在 values() 呼叫中也選擇的那些欄位。

注意

你可能會合理地問,為什麼 Django 不會為你移除多餘的欄位。主要原因是與 distinct() 和其他地方保持一致:Django **永遠不會**移除你已指定的排序限制(而且我們不能更改其他方法的行為,因為那會違反我們的 API 穩定性 政策)。

彙總註解

你也可以在註解的結果上產生彙總。當你定義 aggregate() 子句時,你提供的彙總可以參考在查詢的 annotate() 子句中定義的任何別名。

例如,如果你想計算每本書的平均作者人數,你首先用作者計數來註解這組書,然後彙總該作者計數,參考註解欄位

>>> from django.db.models import Avg, Count
>>> Book.objects.annotate(num_authors=Count("authors")).aggregate(Avg("num_authors"))
{'num_authors__avg': 1.66}

在空的查詢集或群組上進行彙總

當彙總應用於空的查詢集或分組時,結果預設為其 預設值 參數,通常為 None。發生此行為的原因是,當執行的查詢未傳回任何列時,彙總函數會傳回 NULL

你可以為大多數彙總提供 預設值 引數來指定傳回值。但是,由於 Count 不支援 預設值 引數,因此對於空的查詢集或分組,它將始終傳回 0

例如,假設沒有任何書名包含 *web*,則計算這組書的總價格將傳回 None,因為沒有符合的列來計算 Sum 彙總。

>>> from django.db.models import Sum
>>> Book.objects.filter(name__contains="web").aggregate(Sum("price"))
{"price__sum": None}

但是,呼叫 Sum 時,可以設定 預設值 引數,以便在找不到書籍時傳回不同的預設值

>>> Book.objects.filter(name__contains="web").aggregate(Sum("price", default=0))
{"price__sum": Decimal("0")}

在底層,預設值 引數是透過用 Coalesce 包裝彙總函數來實現的。

回到頂端