qtatsuの週報

初心者ですわぁ

【Django】例文で理解するselect_relatedとprefetch_relatedパターン集

前書き

去年末もDjangoを書いてました。

DjangoのORMは簡潔な記述でSQLの発行とPythonオブジェクトを橋渡ししてくれて便利です。

しかし何も考えずに使っているとSQLの発行数が増えてきて、パフォーマンスがどんどん下がってきます。 効率のよいSQLを発行してもらうためにselect_relatedとprefetch_relatedを使用します。

自分は毎回「このパターンどうするんだっけ...」と忘れてしまうので、パターン集を作成しました。

以下のような人向けです。

  • モデルが複雑になってくると混乱して、クエリ削減実装の手が止まってしまう。
  • select_related/prefetch_relatedの基本的な使用方法と目的は理解している。
  • SQLを最低限理解している(INNER JOIN, OUTER JOIN, WHERE, IN あたりの基本構文)

サンプルは全てdjango shellで実行しており、データ作成コードも乗せているので、すぐに試すことができます。

参考リンク

本記事は公式ドキュメントの例を元に、補足を追加した内容です。

QuerySet API reference

環境

バージョン
MacOS Ventura 13.5.2
Python3 3.11.4
Django 5.0.1

Djangoの環境を構築する方法は以下を参考にしてください。

qtatsuの手順書 - qtatsuの週報

事前準備: モデルの作成とデータ投入

以下のドキュメントで出されている例を少し改変しています。

QuerySet API リファレンス | Django ドキュメント | Django

ピザ、トッピング、レストランのモデルです。

  • ピザとトッピングは多:多の関係。
  • レストランはpizzasフィールドでピザと多:多の関係にあります。(提供するピザ全て)
  • レストランはbest_pizzaフィールドでピザと多:1の関係にあります。(一番人気のピザ)
    • この時、ピザが親です.
from django.db import models 

class Country(models.Model):
    name = models.CharField(max_length=256)
    def __str__(self):
        return self.name

class Topping(models.Model):
    name = models.CharField(max_length=256)
    def __str__(self):
        return self.name

class Pizza(models.Model):
    name = models.CharField(max_length=256)
    country = models.ForeignKey(
        Country,
        related_name='pizza',
        null=True,
        on_delete=models.CASCADE)
    toppings = models.ManyToManyField(Topping)
    def __str__(self):
        return self.name

class Restaurant(models.Model):
    name = models.CharField(max_length=256)  # 追加
    pizzas = models.ManyToManyField(Pizza, related_name='restaurants')
    best_pizza = models.ForeignKey(
        Pizza, 
        related_name='championed_by',
        on_delete=models.CASCADE)       
    def __str__(self):
        return self.name

マイグレーションします。

(env) $ python manage.py makemigrations
(env) $ python manage.py migrate

データ投入したいので、django shellに入ります。

(env) $ python manage.py shell

シェル内部で以下のコードを実行します。

from app.models import Topping, Pizza, Restaurant, Country

i = Country.objects.create(name='イタリア')

Topping.objects.create(name='トマト')
Topping.objects.create(name='ピクルス')
Topping.objects.create(name='ベーコン')
Topping.objects.create(name='パイナップル')
Topping.objects.create(name='チーズ')
Topping.objects.create(name='焼き魚')

pizza_A = Pizza.objects.create(name='ピザA')
pizza_A.toppings.set(Topping.objects.filter(name__in=['トマト', 'ピクルス', 'ベーコン']))
pizza_A.country = i
pizza_A.save()
pizza_B = Pizza.objects.create(name='ピザB')
pizza_B.toppings.set(Topping.objects.filter(name__in=['トマト', 'ピクルス', 'パイナップル', 'チーズ']))
pizza_C = Pizza.objects.create(name='ピザC')
pizza_C.toppings.set(Topping.objects.filter(name__in=['トマト', '焼き魚']))

restaurant_1 = Restaurant.objects.create(name='レストラン1', best_pizza=pizza_A)
restaurant_1.pizzas.set(Pizza.objects.filter(name__in=['ピザA', 'ピザB']))
restaurant_2 = Restaurant.objects.create(name='レストラン2', best_pizza=pizza_C)
restaurant_2.pizzas.set(Pizza.objects.filter(name__in=['ピザA', 'ピザC']))

No.0 発行されたSQLを確認する

通常SQLの発行はdjango-debug-toolbarを導入したり、logから確認することが多いと思います。 今回はdjango shellだけで完結させるので、django.db.connection.queriesを確認します。 ここに発行されたSQLの履歴が入っています。

django shellへの入り方を再掲します。

(env) $ python manage.py shell

また、発行されたSQL部分のみを確認したいので、以下のヘルパー関数を定義しておきます。(django shellに貼り付ければOKです)

from django.db import reset_queries, connection


def f(q):
    for qt in q:
        print(qt['sql'])

# クエリ確認 
f(connection.queries)
# リセット
reset_queries()

以降、全てdjango shell内部で実行しています。

No.1 select_relatedで親を取る

レストラン: 一番人気のピザは多:1の関係です。 レストラン(子)から検索すると、一番人気のピザ(親)は1つにさだまります。

素の状態だとレストランを取得するクエリに加え、取得されたレストランの数だけ一番人気のピザを取るクエリが発行されてしまいます。

>>> for restaurant in Restaurant.objects.all():
        print(f'{restaurant.name}店の一番人気のピザは{restaurant.best_pizza.name}')

レストラン1店の一番人気のピザはピザA
レストラン2店の一番人気のピザはピザC
>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id" FROM "app_restaurant"
SELECT "app_pizza"."id", "app_pizza"."name" FROM "app_pizza" WHERE "app_pizza"."id" = 1 LIMIT 21
SELECT "app_pizza"."id", "app_pizza"."name" FROM "app_pizza" WHERE "app_pizza"."id" = 3 LIMIT 21

このような場合、SQLでは親を結合して取得します。 DjangoのORMでは、select_relatedによって結合が可能です。

発行されたSQLを確認すると、確かにINNER JOINされており、クエリ発行数は1件となっています。やりました。

>>> reset_queries()  # 初期化しておきます.

>>> for restaurant in Restaurant.objects.select_related('best_pizza').all():
        print(f'{restaurant.name}店の一番人気のピザは{restaurant.best_pizza.name}')

レストラン1店の一番人気のピザはピザA
レストラン2店の一番人気のピザはピザC
>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id", "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_restaurant" INNER JOIN "app_pizza" ON ("app_restaurant"."best_pizza_id" = "app_pizza"."id")

No.2 select_relatedで親の親を取る

ダブルアンダースコアによって、親の親の...と辿ることができます。 後半は、LEFT OUTER JOINとなっていることに注意してください。(CountryはピザAにだけ設定しています。)

for restaurant in Restaurant.objects.select_related('best_pizza__country').all():
    print(f'{restaurant.name}店の一番人気のピザは{restaurant.best_pizza.name}({restaurant.best_pizza.country})')

レストラン1店の一番人気のピザはピザA(イタリア)
レストラン2店の一番人気のピザはピザC(None)
>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id", "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id", "app_country"."id", "app_country"."name" FROM "app_restaurant" INNER JOIN "app_pizza" ON ("app_restaurant"."best_pizza_id" = "app_pizza"."id") LEFT OUTER JOIN "app_country" ON ("app_pizza"."country_id" = "app_country"."id")

ところで、以下のようなコードを書く必要はありません。 親の親の親...と辿る時は、一番遠い親を指定すれば良いです。

ダブルアンダースコアで指定すれば、その途中のテーブルもきちんと結合されます。

# 冗長な例. best_pizzaの指定は不要.
select_related('best_pizza', 'best_pizza__country')

No.3 prefetch_relatedで複数件の多を取る

よく解説されている基本の形です。

ピザとトッピングは多:多の関係です。ピザをベースにして取得します。

取得したそれぞれのピザの、トッピングも全て取得したい というケースを考えます。 以下のようにアクセスするとピザを取得するクエリ(1つめ)に加え、取得したピザの数だけ、そのピザに紐つくトッピングを取得するクエリが発行されてしまいます。

f(connection.queries)

reset_queries()
for pizza in Pizza.objects.all():
    print(f'{pizza.name}', ','.join([t.name for t in pizza.toppings.all()]))

ピザA トマト,ピクルス,ベーコン
ピザB トマト,ピクルス,パイナップル,チーズ
ピザC トマト,焼き魚
>>> f(connection.queries)
SELECT "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza"
SELECT "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" = 1
SELECT "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" = 2
SELECT "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" = 3

このようなケースでは、SQLとやや異なる方法を取ります。

prefetch_relatedによってトッピングをあらかじめ別のクエリで取得し、Pythonコードによって結合します.

prefetch_relatedはキャッシュ機能(Python側の機能)だと意識すると、理解しやすいと自分は思います。

では実際にクエリを見てみます。

>>> reset_queries()
>>> for pizza in Pizza.objects.prefetch_related('toppings').all():
        print(f'{pizza.name}', ','.join([t.name for t in pizza.toppings.all()]))

ピザA トマト,ピクルス,ベーコン
ピザB トマト,ピクルス,パイナップル,チーズ
ピザC トマト,焼き魚
>>> f(connection.queries)
SELECT "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza"
SELECT ("app_pizza_toppings"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" IN (1, 2, 3)

1つめのクエリでピザ(idが1,2,3)を取得した後に、2つめのクエリが発行されています。

ピザのIDをIN句で全て指定し、必要になるトッピングを全部あらかじめ取得するクエリです。 このクエリの結果をキャッシュしておき、上記のprintで必要になった時に利用しているイメージです。

表からは見えませんが、Pythonのコードによりキャッシュから該当部分を見つけています。

(補足) 上記の説明のソースはこちら。 QuerySet API リファレンス | Django ドキュメント | Django

No.4 Prefetchオブジェクトで多をfilter

prefetch_relatedは上記のようにキャッシュする仕組みです。

なので、キャッシュしたクエリとは異なるパターンでアクセスすると、むしろprefetch_relatedの分だけクエリが増えて無駄になります

以下の例は、アクセス時にfilterを使っています。この場合、prefetchした結果は利用されず再度SQLが発行されます。

  • prefetch_relatedではallを指定.
  • アクセス時にはfilterを指定.
for pizza in Pizza.objects.prefetch_related('toppings').all():
    print(f'{pizza.name}', ','.join([t.name for t in pizza.toppings.filter(id__gte=3)]))

ピザA ベーコン
ピザB パイナップル,チーズ
ピザC 焼き魚
>>> f(connection.queries)
SELECT "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza"
SELECT ("app_pizza_toppings"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" IN (1, 2, 3)
SELECT "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE ("app_pizza_toppings"."pizza_id" = 1 AND "app_topping"."id" >= 3)
SELECT "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE ("app_pizza_toppings"."pizza_id" = 2 AND "app_topping"."id" >= 3)
SELECT "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE ("app_pizza_toppings"."pizza_id" = 3 AND "app_topping"."id" >= 3)

prefetchする子をフィルターする時は、Prefetchオブジェクトで指定します。

toppings側は、単にallを指定していますがきちんとフィルタされています。 この書き方は、allの結果を上書きしてしまうようなイメージです。

from django.db.models import Prefetch

>>> reset_queries()
>>> for pizza in Pizza.objects.prefetch_related(Prefetch('toppings', queryset=Topping.objects.filter(id__gte=3))):
            print(f'{pizza.name}', ','.join([t.name for t in pizza.toppings.all()]))

ピザA ベーコン
ピザB パイナップル,チーズ
ピザC 焼き魚
>>> f(connection.queries)
SELECT "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza"
SELECT ("app_pizza_toppings"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE ("app_topping"."id" >= 3 AND "app_pizza_toppings"."pizza_id" IN (1, 2, 3))

to_attr属性を指定することで、Prefetchオブジェクトでカスタムしたキャッシュに明示的にアクセスできるので、こちらの記述方法がおすすめです。

>>> for pizza in Pizza.objects.prefetch_related(Prefetch('toppings', queryset=Topping.objects.filter(id__gte=3), to_attr='filtered_toppings')):
            print(f'{pizza.name}', ','.join([t.name for t in pizza.filtered_toppings]))

No.5 prefetch_relatedで2つ先のリレーション: ManyToMany-ManyToMany

ダブルアンダースコアで繋ぐことで指定できます。

  • レストランをベースに、提供するピザとそのトッピングを全て取得する。
  • レストラン↔️ピザ↔️トッピング
    • レストランに紐つく全てのピザを取得する。
      • ピザに紐つく全てのトッピングを取得する。
for restaurant in Restaurant.objects.prefetch_related('pizzas__toppings').all():
    print(f'{restaurant.name}店のピザ一覧')
    for pizza in restaurant.pizzas.all():
        print(f'\t{pizza.name}', ','.join([t.name for t in pizza.toppings.all()]))

レストラン1店のピザ一覧
        ピザA トマト,ピクルス,ベーコン
        ピザB トマト,ピクルス,パイナップル,チーズ
レストラン2店のピザ一覧
        ピザA トマト,ピクルス,ベーコン
        ピザC トマト,焼き魚
  • prefetch_relatedなしなら各レストラン、各ピザごとにクエリが発生します。
  • prefetch_relatedで以下の3つのクエリにまとまります。
    1. レストラン一覧の取得
    2. レストランのIDをIN句で指定し、対応するピザを全て取得。
    3. ピザのIDをIN句で指定し、対応するトッピングを全て取得。
>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id" FROM "app_restaurant"
SELECT ("app_restaurant_pizzas"."restaurant_id") AS "_prefetch_related_val_restaurant_id", "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza" INNER JOIN "app_restaurant_pizzas" ON ("app_pizza"."id" = "app_restaurant_pizzas"."pizza_id") WHERE "app_restaurant_pizzas"."restaurant_id" IN (1, 2)
SELECT ("app_pizza_toppings"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" IN (1, 2, 3)

No.6 prefetch_relatedで2つ先のリレーション: ForeignKey-ManyToMany

※次のNo.7の劣化版です

No.5と同じく、ダブルアンダースコアで繋ぐことができます。FKで結合するモデルが間にあっても問題ありません。

  • レストラン️←一番人気のピザ↔️トッピング
    • レストランにFKで紐つく一番人気のピザ
    • ピザに紐つく全てのトッピング取得
for restaurant in Restaurant.objects.prefetch_related('best_pizza__toppings').all():
    print(f'{restaurant.name}店の一番人気のピザ')
    print(f'\t{restaurant.best_pizza.name}', ','.join([t.name for t in restaurant.best_pizza.toppings.all()]))

レストラン1店の一番人気のピザ
        ピザA トマト,ピクルス,ベーコン
レストラン2店の一番人気のピザ
        ピザC トマト,焼き魚
  • prefetch_relatedなしなら各ピザごとにクエリが発生します。
  • prefetch_relatedで3つのクエリにまとめることができます。
    1. レストラン一覧の取得
    2. レストランのIDをIN句で指定し、対応する一番人気のピザを取得(FKなので1件のみ)。
    3. ピザのIDをIN句で指定し、対応するトッピングを全て取得。
>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id" FROM "app_restaurant"
SELECT "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza" WHERE "app_pizza"."id" IN (1, 3)
SELECT ("app_pizza_toppings"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" IN (1, 3)

No.7 prefetch_relatedで2つ先のリレーション: ForeignKey-ManyToMany + select_related

No.6の改善版です。ForeignKeyの部分は、prefetchよりselect_relatedを使ってSQLレベルで効率化する方が優れています。

主クエリ(select_relatedの"後"に事前読み込み(prefetch)が走るのをイメージすると分かりやすいです。

for restaurant in Restaurant.objects.select_related('best_pizza').prefetch_related('best_pizza__toppings').all():
    print(f'{restaurant.name}店の一番人気のピザ')
    print(f'\t{restaurant.best_pizza.name}', ','.join([t.name for t in restaurant.best_pizza.toppings.all()]))

レストラン1店の一番人気のピザ
        ピザA トマト,ピクルス,ベーコン
レストラン2店の一番人気のピザ
        ピザC トマト,焼き魚
        
  • 2つのクエリにまとめることができます。
    1. レストラン一覧 + 一番人気のピザ(FK)を同時に取得
    2. ピザのIDをIN句で指定し、対応するトッピングを全て取得。
>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id", "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_restaurant" INNER JOIN "app_pizza" ON ("app_restaurant"."best_pizza_id" = "app_pizza"."id")
SELECT ("app_pizza_toppings"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" IN (1, 3)

No.8 Prefetchで2つ先のリレーションをorder_byする

  • レストラン↔️ピザ↔️トッピング: トッピングをnameの降順にする
  • 中間にあるピザもprefetchされている点に注意(No.5をベースに考えます)
>>> for restaurant in Restaurant.objects.prefetch_related(Prefetch('pizzas__toppings', queryset=Topping.objects.order_by('-name'))):
        print(f'{restaurant.name}店のピザ一覧')
        for pizza in restaurant.pizzas.all():
             print(f'\t{pizza.name}', ','.join([t.name for t in pizza.toppings.all()]))

レストラン1店のピザ一覧
        ピザA ベーコン,ピクルス,トマト
        ピザB ピクルス,パイナップル,トマト,チーズ
レストラン2店のピザ一覧
        ピザA ベーコン,ピクルス,トマト
        ピザC 焼き魚,トマト
  • ピザIDをINに指定した、トッピング取得クエリにORDER_BYがつきます。
>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id" FROM "app_restaurant"
SELECT ("app_restaurant_pizzas"."restaurant_id") AS "_prefetch_related_val_restaurant_id", "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza" INNER JOIN "app_restaurant_pizzas" ON ("app_pizza"."id" = "app_restaurant_pizzas"."pizza_id") WHERE "app_restaurant_pizzas"."restaurant_id" IN (1, 2)
SELECT ("app_pizza_toppings"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE "app_pizza_toppings"."pizza_id" IN (1, 2, 3) ORDER BY "app_topping"."name" DESC

No.9 prefetch_relatedで2つ先のリレーション: ManyToMany-ForeignKey

No.7は、クエリ対象の直接の関連先がForeignKey、その先がManyToManyでした。 今回は直接の関連先がManyToManyで、その先にForeignKeyで結合したモデルがある場合です。

Pretetchで取得するとき、対象のFKをJOINさせる指示 というイメージが分かりやすいと思います。 Prefetchのquerysetでselect_relatedを使うのがポイントです。

  • ピザ↔️レストラン←一番人気のピザ
    • ピザ↔️レストラン: prefetchで別クエリにします(ピザIDをIN句指定)
    • レストラン←一番人気のピザ: select_relatedでSQLで結合した状態で取得します。
for pizza in Pizza.objects.prefetch_related(Prefetch('restaurants', queryset=Restaurant.objects.select_related('best_pizza'))):
    print(f'{pizza.name}が提供されてるレストラン一覧')
    for restaurant in pizza.restaurants.all():
        print(f'\t{restaurant}の一番人気のピザは: {restaurant.best_pizza.name}')

ピザAが提供されてるレストラン一覧
        レストラン1の一番人気のピザは: ピザA
        レストラン2の一番人気のピザは: ピザC
ピザBが提供されてるレストラン一覧
        レストラン1の一番人気のピザは: ピザA
ピザCが提供されてるレストラン一覧
        レストラン2の一番人気のピザは: ピザC

最初に取得したピザIDをIN句にしてまとめてレストランを一発で取れていることに注目してください。 さらに、それぞれのレストランの最良ピザはSQLの時点で結合できています。

>>> f(connection.queries)
SELECT "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza"
SELECT ("app_restaurant_pizzas"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id", T4."id", T4."name", T4."country_id" FROM "app_restaurant" INNER JOIN "app_restaurant_pizzas" ON ("app_restaurant"."id" = "app_restaurant_pizzas"."restaurant_id") INNER JOIN "app_pizza" T4 ON ("app_restaurant"."best_pizza_id" = T4."id") WHERE "app_restaurant_pizzas"."pizza_id" IN (1, 2, 3)

省略しますが、prefetchなどをつけない状態だと以下のようにクエリがたくさん発行されます。 - ピザ一覧を取得し、それぞれのピザIDに対してレストランを1件ずつクエリで取得。 - そのレストランの最良ピザIDをクエリにして、再度ピザを取得する。

No.10 to_attrで複数の絞り込みをする

  • 同じ対象を複数のパターンで同時に絞って使いたいケース。
  • レストラン↔️ピザ で、ピザを複数の方法で絞る
    • あるレストランにひもつく、イタリアのピザ一覧 と 全てのピザ一覧を同時に取得する。

余談ですが、italy_pizzaとall_pizzaを定義した時点ではSQLは発行されていないという点も大事です。 QuerySetは遅延評価なので、実際の値を取得するまで発行されません。

italy_pizza = Pizza.objects.filter(country=italy)
all_pizza = Pizza.objects.all()
for restaurant in Restaurant.objects.prefetch_related(Prefetch('pizzas', queryset=italy_pizza, to_attr='italy'), Prefetch('pizzas', queryset=all_pizza, to_attr='all_pizzas')):
    print(f'{restaurant.name}店')
    print('\t', ','.join([pizza.name for pizza in restaurant.italy]))
    print('\t', ','.join([pizza.name for pizza in restaurant.all_pizzas]))

レストラン1店
         ピザA
         ピザA,ピザB
レストラン2店
         ピザA
         ピザA,ピザC

Prefetchを指定した数だけ、SQLが増えます。

>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id" FROM "app_restaurant"
SELECT ("app_restaurant_pizzas"."restaurant_id") AS "_prefetch_related_val_restaurant_id", "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza" INNER JOIN "app_restaurant_pizzas" ON ("app_pizza"."id" = "app_restaurant_pizzas"."pizza_id") WHERE ("app_pizza"."country_id" = 1 AND "app_restaurant_pizzas"."restaurant_id" IN (1, 2))
SELECT ("app_restaurant_pizzas"."restaurant_id") AS "_prefetch_related_val_restaurant_id", "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza" INNER JOIN "app_restaurant_pizzas" ON ("app_pizza"."id" = "app_restaurant_pizzas"."pizza_id") WHERE "app_restaurant_pizzas"."restaurant_id" IN (1, 2)

No.11 1つ先、2つ先のリレーションをそれぞれ条件付きでPrefetch(ManyToMany-ManyToMany)

  • 1つ先のManyToManyをfilter条件付きでprefetchし、2つ先もfilterして取得するパターン.
  • to_attrで名付けることで、2つ先をダブルアンダースコアで指定可能にします。
  • 以下これまで記述した類似パターン
    • No.5: all(1つ先)、all(2つ先)だった。
    • No.8: all(1つ先)、2つ先をorder_by
    • No.9: all(1つ先)、2つ先はselect_related

2つ先(トッピング)を取得する時に、すでにフィルタされた1つ先(ピザ)に関連するものだけ取得したい、というのがポイントです。

italy_pizza = Pizza.objects.filter(country=italy)
for restaurant in Restaurant.objects.prefetch_related(Prefetch('pizzas', queryset=italy_pizza, to_attr='italy'), Prefetch('italy__toppings', queryset=Topping.objects.filter(id__lte=2), to_attr='topping_2')):
    print(f'{restaurant.name}店')
    for pizza in restaurant.italy:
        print(f'\t{pizza.name}', ','.join([t.name for t in pizza.topping_2]))

レストラン1店
        ピザA トマト,ピクルス
レストラン2店
        ピザA トマト,ピクルス

一つ先(Pizza)がレストランの取得結果とcountry=1でフィルタされている。 その結果のピザIDを3つめのtopping取得時にIN句で使い、id<=2のフィルタも同時に実行している。

>>> f(connection.queries)
SELECT "app_restaurant"."id", "app_restaurant"."name", "app_restaurant"."best_pizza_id" FROM "app_restaurant"
SELECT ("app_restaurant_pizzas"."restaurant_id") AS "_prefetch_related_val_restaurant_id", "app_pizza"."id", "app_pizza"."name", "app_pizza"."country_id" FROM "app_pizza" INNER JOIN "app_restaurant_pizzas" ON ("app_pizza"."id" = "app_restaurant_pizzas"."pizza_id") WHERE ("app_pizza"."country_id" = 1 AND "app_restaurant_pizzas"."restaurant_id" IN (1, 2))
SELECT ("app_pizza_toppings"."pizza_id") AS "_prefetch_related_val_pizza_id", "app_topping"."id", "app_topping"."name" FROM "app_topping" INNER JOIN "app_pizza_toppings" ON ("app_topping"."id" = "app_pizza_toppings"."topping_id") WHERE ("app_topping"."id" <= 2 AND "app_pizza_toppings"."pizza_id" IN (1))

2つ先をフィルタしないなら以下のように書けば良いです。

>>> for restaurant in Restaurant.objects.prefetch_related(Prefetch('pizzas', queryset=italy_pizza, to_attr='italy'), 'italy__toppings'):
        print(f'{restaurant.name}店')
        for pizza in restaurant.italy:
            print(f'\t{pizza.name}', ','.join([t.name for t in pizza.toppings.all()]))

レストラン1店
        ピザA トマト,ピクルス,ベーコン
レストラン2店
        ピザA トマト,ピクルス,ベーコン

No.12 親ベースで1件の子を取得する

  • 子を、親モデル.get(検索条件)するようなケース
  • 取得結果は1件でもList形式になり、これはどうしようもないっぽい。
  • prefetchで指定し、0番目を取得する。

子側から検索して、関連する親をselect_relatedすることができるならそちらを採用する.