a_hamada/ 2023年 9月 27日/ 技術

こんにちは!濱田です。
札幌はだいぶ涼しくなってきて、だいぶ秋になってきました。

さて今回もQGISについて書いていこうと思ったのですが、別に書きたいことができたので
一旦QGISの話はお休みさせていただいて今回はDjangoのsubqueryについて書いていきます。

はじめに

Djangoのsubqueryに関してですが、その名の通りDjangoでサブクエリを実現するときに使用します。
今回私がsubqueryを使用するきっかけになったのは、業務で既読管理をする必要があったからです。

サンプルモデル
以下に説明用のサンプルモデルを記載します。
付箋モデル、それを確認するユーザモデル、あるユーザの付箋の既読状態のモデルを定義します。

class Users(models.Model):
    name = models.CharField(max_length=10)
    age = models.IntegerField()

class StickyNote(models.Model):
    note = models.TextField(blank=True, null=True, default=None)
    last_update_time = models.DateTimeField(blank=True, null=True, default=None)

class Read_status(models.Model):
    sticky_note = models.ForeignKey(to=StickyNote, on_delete=models.CASCADE)
    read_user = models.ForeignKey(to=users, on_delete=models.CASCADE)
    read_datetime = models.DateTimeField(default=datetime.fromtimestamp(0))

実際使用したモデルはもっと複雑なのですが
説明のために簡単なmodelを定義しました。

既読管理の設計

上記で定義したモデルを以下のような方法で既読管理します。
StickyNote作成時、読んでほしいユーザごとにread_statusが作成されます。
StickyNoteのupdate_timeよりread_statusのread_datetimeが過去のものを未読とするような運用を想定します。
(若干設計が甘いのは目を瞑ってください…)

作成したクエリセット

とあるユーザの付箋の既読状態を取得するために私が作成した処理はこちらです。

def get_queryset(read_user: Users):
    subquery = (
            read_status.objects.filter(sticky_note=OuterRef("pk"), read_user=read_user)
            .values("read_datetime")
        )
qs = StickyNote.objects.annotate(
    read_datetime=Subquery(
        subquery, output_field=models.DateTimeField(),null=True),
    read_status=Case(
        When(read_datetime__isnull=True, then=Value(0)),
        When(last_update_time__gt=F("read_datetime"), then=Value(2)),
        When(read_datetime__gte=F("last_update_time"),then=Value(1)),
        output_field=IntegerField(),
        )
    )
)

一つ一つ何をやっているか解説していきます。
まず

subquery = (
    read_status.objects.filter(
        sticky_note=OuterRef("pk"), read_user=read_user).values("read_datetime")
)

の部分ですが
これが本題のsubqueryの定義をしている部分です。
read_statusオブジェクトから外部参照のオブジェクトOuterRefを使用して持ってきた別のmodel(今回でいうStickyNote)
のあるレコードのpkをフィルタ条件としてread_statusオブジェクトを取得します。
ここでsubqueryを使用するときに一点注意したいのがsubqueryで取得しているクエリセットで取得されるレコード数は必ず1つ出なければならないことです。
複数レコードが取得できるようなクエリセットを取得すると例外を吐かれてしまいます。

次に

qs = StickyNote.objects.annotate(
    read_datetime=Subquery(
        subquery, output_field=models.DateTimeField(), null=True),
            read_status=Case(
                When(read_datetime__isnull=True, then=Value(0)),
                When(last_update_time__gt=F("read_datetime"), then=Value(2)),
                When(read_datetime__gte=F("last_update_time"), then=Value(1)),
                output_field=IntegerField(),
        ),
    )
)

の部分ですが
ここでは先ほどのsubqueryで取得したフィールドをannotateでフィールドとして定義しています。
そのあとのCase、When句で定義したフィールドを使用してさらに新たに状態のフィールドを定義、
Case句の中で使用しているFオブジェクトですが、自身のあるレコードのあるフィールドの値を条件判定に使用するために使っています。
私ははじめOuterRefはsubqueryでしか使用できないのがわかっておらず、Fオブジェクトの部分も全部OuterRefにしてエラーの解消にかなり時間を浪費しました。
今回read_datetimeが取得できない場合は0、StickyNote.last_update_timeがStickyNote.read_datetimeより大きい場合を2、
StickyNote.read_datetimeがStickyNote.last_update_time以上の場合を1と定義しました。
まとめると状態として下記のようになっています。
0:read_datetimeが取得できないの=該当ユーザが呼んだ履歴がない→未読
1:1回以上読まれてはいるが最終更新日時の方が新しい→未読
2:1回以上読まれていてread_datetimeがlast_update_timeより新しい→既読

まとめ
いかがだったでしょうか?
私としても初めて使うものばかりだったのでまだまだ勉強中の部分も多いですが
今回学んだ内容を皆さんに共有できていたら幸いです。
ただぱっと見でもコードが複雑になってしまうので、本来このような実装になってしまう時点で設計に見直しがひつようなのかもしれないですね。
とは言え今回の私のように使わないとどうしようもない人が必要とする場面が少なからずあると思うので、そういった時には知っていると役立ちますね。

ということで長くなってしまいましたが今回はDjangoのsubquery(+その他諸々)について書きました。
次回の内容はちょっと未定です。

それではまた!

About a_hamada

2020年9月からWebプログラマに転向した半人前 日々勉強することばかり。 最近使っている言語はもっぱらPython