generic foreignkeyのsub relationをprefetchする方法

はじめに

generic foreignkey 自体のprefetchはできる。しかし、その更に先のrelationをprefetchすることができない。これをどうにかしようと言う苦肉の策を考えてみたという話。

言い訳

djangoのgeneric foreignkey関連のコードを読んでみたところ綺麗にできる方法は無さそうだった。実行時のprefetchの条件を上手く受け渡す方法が存在しなさそうだったので。仕方がないので thread localなcontext objectを作りそこでprefetchの条件を指定できるようにする。

概要

以下の様な形のモデルになっているとする。

Feed -- generic foreign key --> cotent = {A,B,C}

A -- 1:N --> xs = {X}
B -- 1:N --> ys = {Y}
C
X
Y

Feedというモデルが有りこれがgenericなrelationを持っており、A,B,Cのいずれかを保持する。また、AはXモデルとBはYモデルと1:Nの関係になっている。今回はFeedのqueryを取ってくる際にA,B,Cだけでなく、Aに結びつくX,Bに結びつくYも一緒に取ってくるようにしたい。

例えば以下のようなqueryを実行するとする。xs,ysの取得に関してはN+1になってしまう。

def use(content):
    if hasattr(content, "xs"):
        return [content, list(content.xs.all())]
    elif hasattr(content, "ys"):
        return [content, list(content.ys.all())]
    else:
        return [content]

content_list = []
for feed in Feed.objects.all().prefetch_related("content"):
    # content :: {A, B, C} はprefetchされるがそのsub relationであるxs,ysがprefetchされない
    content_list.append(use(feed.content))

この時以下のようにprefetchを指定するとエラーになる。

Feed.objects.all().prefetch_related("content", "content__xs", "content__ys")

generic foreignkeyのsub relationをprefetchする方法

試行錯誤を行なったdjangoのversionは1.9.5だった。

素朴な方法

基本的な方針としては以下のようになる。

GenericForeignKeyのget_content_typeが各relation(ここではA,B,C)を取ってくる際の始端となるオブジェクトになっている。これを書き換え、各relationを取ってくるquerysetを生成するところにprefetch_relatedを追加する処理を加えてあげる。

なので、django.contrib.contenttypes.fields.GenericForeignKey と django.contrib.contenttypes.models.ContentType を自分で定義したサブクラスに置き換える。

class MyContentType(ContentType):
    class Meta:
        proxy = True

    def get_all_objects_for_this_type(self, **kwargs):
        qs = super().get_all_objects_for_this_type(**kwargs)
        return self.attach_prefetch(qs)

    def attach_prefetch(self, qs):
        # ここでprefetch_relatedの設定をする
        model = qs.model
        if issubclass(model, A):
            return qs.prefetch_related("xs")
        elif issubclass(model, B):
            return qs.prefetch_related("ys")
        else:
            return qs

class MyGenericForeignKey(GenericForeignKey):
    def get_content_type(self, *args, **kwargs):
        ct = super().get_content_type(*args, **kwargs)
        ct.__class__ = MyContentType  # これはmethodの挙動を書き換えるための雑な方法。真面目な方法ではない。
        return ct

各relationの取得に、MyContentTypeのget_all_objects_for_this_type()が使われる。これをフックするために、GenericForeignKeyからはMyContentTypeが使われるように get_content_type() を書き換える。内部の実装的にcontent typeのインスタンスの取得はキャッシュされているのでまじめに使う場合には注意が必要。

このようにすると以下のようなquery中でもX,Yを含めてprefetchしてくれるようになる。

content_list = []
for feed in Feed.objects.all().prefetch_related("content"):
    # sub relationであるxs, ysもprefetchされる
    content_list.append(use(feed.content))

ただし、上のようにした場合には、xs,ysのprefetchがデフォルトの動作になってしまう点が問題になる。できれば実行時にprefetchの条件を指定したい。

実行時にprefetchの条件をしていするための苦肉の策

冒頭の方にも書いたがdjangoの現状のコードセットではつらい。なので苦肉の策としてthread local objectにprefetchの条件を格納させることにする。まじめに実装するなら、異なる条件が二重に重なる場合なども考えなければいけなそうではあるけれど。そこまではやっていない。(本来は上手くqueryのhintを指定するカタチで情報を付加できたら良いのだけれど)

import threading
import contextlib

class GFKPrefetchContext:
    def __init__(self):
        self._context = threading.local()
        self._context.attach_prefetch = lambda qs: qs

    @contextlib.contextmanager
    def activate_prefetch(self, fn):
        oldvalue = self._context.attach_prefetch
        self._context.attach_prefetch = fn
        yield
        self._context.attach_prefetch = oldvalue

    def attach_prefetch(self, qs):
        return self._context.attach_prefetch(qs)

gfk_prefetch_context = GFKPrefetchContext()


class ContentTypeWithPrefetch(ContentType):
    class Meta:
        proxy = True

    def get_all_objects_for_this_type(self, **kwargs):
        qs = super().get_all_objects_for_this_type(**kwargs)
        return gfk_prefetch_context.attach_prefetch(qs)


class MyGenericForeignKey(GenericForeignKey):
    def get_content_type(self, *args, **kwargs):
        ct = super().get_content_type(*args, **kwargs)
        ct.__class__ = ContentTypeWithPrefetch
        return ct

以下の様にして使う。

def attach_prefetch(qs):
    model = qs.model
    if issubclass(model, A):
        return qs.prefetch_related("xs")
    elif issubclass(model, B):
        return qs.prefetch_related("ys")
    else:
        return qs

content_list = []
with gfk_prefetch_context.activate_prefetch(attach_prefetch):
    for feed in Feed.objects.all().prefetch_related("content"):
        content_list.append(use(feed.content))

# もちろんsub relationのprefetchを効かせたくなければcontextを指定しなければ良い
for feed in Feed.objects.all().prefetch_related("content"):
    content_list.append(use(feed.content))

補足

想定していたモデルの定義は以下のようなものだった。

class A(models.Model):
    name = models.CharField(max_length=32, default="", blank=False)

    class Meta:
        db_table = "a"

class B(models.Model):
    name = models.CharField(max_length=32, default="", blank=False)

    class Meta:
        db_table = "b"

class C(models.Model):
    name = models.CharField(max_length=32, default="", blank=False)

    class Meta:
        db_table = "c"

class X(models.Model):
    name = models.CharField(max_length=32, default="", blank=False)
    a = models.ForeignKey(A, related_name="xs")

    class Meta:
        db_table = "x"

class Y(models.Model):
    name = models.CharField(max_length=32, default="", blank=False)
    b = models.ForeignKey(B, related_name="ys")

    class Meta:
        db_table = "y"

class Feed(models.Model):
    class Meta:
        db_table = "feed"
        unique_together = ("content_type", "object_id")

    object_id = models.PositiveIntegerField()
    content_type = models.ForeignKey(ContentType)
    content = MyGenericForeignKey('content_type', 'object_id')