OneToOneFieldを使うと関連モデルから属性へアクセス出来るようになる

www.youtube.com

Multiple User Types With Custom Data Fields | Djangoという動画中に出てくるコードを見て、自分にとっては謎の挙動だったので調べました。

具体的には以下のようなコードが動画中で書かれていました。

class DriverMore(User):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    model = models.CharField(max_length=255)
    make = models.CharField(max_length=255)
    year = models.IntegerField()


class Driver(User):
    base_type = User.Types.DRIVER
    objects = DriverManager()

    @property
    def more(self):
        return self.drivermore

疑問点としては、この中のDrivermoreが「なぜDriverとは別のDriverMoreクラス」に対してアクセス出来るようになっているのか分かりませんでした。

理由について

Djangoの公式リファレンスによると、以下の記述がありました。

Assuming an existing Employee Fred Smith who has both a User and Employee model, you can access the related information using Django's standard related model conventions:

Djangoの挙動によって関連づけられ、アクセスできるということが分かりました(実際にはUserとEmployee model の例が一緒に書かれているので後半部分の記述が主な理由です)。

https://docs.djangoproject.com/ja/5.0/topics/auth/customizing/#extending-the-existing-user-model

手元でも書いてみたサンプルコード

例えば、以下のようなコードがあるとします。

from django.db import models
from django.contrib.auth.models import User


class More(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    address = models.TextField()
    postal_code = models.CharField(max_length=7)

そして、以下のようなコードをmanage.py shell コマンドで実行してみます。

from django.contrib.auth.models import User
nibu = User.objects.create_user(username="nibu", email="nibu@example.com", password="nibu")

from drivers.models import More
nibu_more = More(user=nibu, address="住所", postal_code="0123456")

nibu.more.address  # 住所
nibu.more.postal_code  # 0123456

この挙動から、変数nibu、すなわちUserクラスにはmoreという属性は本来はありませんが、OneToOneFieldを利用したことでmoreにアクセス出来る事が自身の手を動かしてみても確認できました。

また、反対にmoreから辿ることも出来ました。

nibu_more.user  # <User: nibu>
nibu_more.user.username  # nibu
nibu_more.user.email  # nibu@example.com

最初、Pythonは同じファイルに書いたクラスにアクセス出来るんだっけ・・・?と驚いたが、Djangoの特性?によるものだったのでまだまだ知らない挙動がありそうだなと実感しています。

補足

ちなみに、こういうModelはProfile modelと呼ばれているそうです。

https://docs.djangoproject.com/en/5.0/topics/auth/customizing/#extending-the-existing-user-model

より以下の記述がありました。

This one-to-one model is often called a profile model

認証情報以外のユーザーに関連するデータを保持する、というのは良くあるケースと聞いて、確かにそうだな、と感じました。

そして、自社の自走プログラマーにも以下のようなページがあることを教えてもらって、自走プログラマーの内容を理解していたらもっと早く理解できていたのだろう、と思いました・・・。

51:参照頻度が低いカラムはテーブルを分ける

set型について

個人的にはPythonでなにかとlistを利用することが多かったのですが、set型について知って、改めて適切な型を利用するのが大切だなと感じました。

set型は以下のような特徴を持ちます。

  • 順序がない
  • 挿入順序、要素位置は保持しない
  • 重複を削除できる
  • 和集合や積集合といった計算ができる
  • ハッシュ可能なオブジェクトしか集合の要素にできない(id:XaroCydeykn さんから教えてもらいました)

https://docs.python.org/ja/3/library/stdtypes.html#set-types-set-frozenset

より簡易的にまとめました。

順序を意識しなくて良いのなら、listで受け取った値をそのまま使って処理をするのではなく、setを利用することでより効果的な処理を実現できる可能性があります。

メリット

重複を削除できることによって、listで受け取った値の重複チェックみたいな処理を書く必要がありません。

例えば、以下のような記述をすることでset型を利用する事ができます。

# 空のsetを作成する
foo = set()

# 空ではないsetを作成する
bar = {1, 2, 2, 3, 3, 3}
bar  # {1, 2, 3} が出力される

# listを元にsetを作成する
baz = [1, 2, 2, 3, 3, 3]
baz = set(baz)
baz # {1, 2, 3}

bar, bazの変数の例を見ると、重複された値が削除された事がわかります。

また、エキスパートPythonプログラミング 改訂4版曰く以下のようなメリットもあるようです。

set型を使えば、dictと同じようにハッシュを利用して探索を行うので高速になります。

エキスパートPythonプログラミング 改訂4版

著 Micha l Jaworski 著 Tarek Ziade

訳 新井 正貴 訳 稲田 直哉 訳 渋川 よしき 訳 清水川 貴之 訳 福田 隼也

P537 より引用

デメリット

順序がないため、例えばPython2系の時のdictのような挙動になります。 そのため、順序を保持する必要がある処理ではsetをそのまま利用する事ができません。

※順序を保持してlistを作成する方法もエキスパートPythonプログラミング 改訂4版には訳註が記載されていましたが内容の丸写しになりそうなので割愛します。

内容を保持する必要があるなら、別の方法を取ることが求められます。

ハッシュ可能なオブジェクトしか集合の要素にできない

id:XaroCydeykn さんから教えてもらった内容について追記。

例えば、以下のようなコードを書くとlistはハッシュ可能なオブジェクトではないため、失敗します。

foo = [1, 2, 3]
foo_set = set()
foo_set.add(foo)

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

公式リファレンスにも以下のように記述があります。

iterable から要素を取り込んだ、新しい set もしくは frozenset オブジェクトを返します。 集合の要素は ハッシュ可能 なものでなくてはなりません。

https://docs.python.org/ja/3/library/stdtypes.html#set より引用

set() で生成するときはiterableを元にオブジェクトを返すため生成できますが、addで追加しようとすると失敗します。

そのため、listのまま追加しようとすると以下のようにタプルへ変換してやる必要があるとのことでした。

foo = [1, 2, 3]
foo_tuple = tuple(foo)
foo_set.add(foo_tuple)
foo_set  # {(1, 2, 3)} が出力される

コメントをもらって、最初に自分が書いたコードがなぜ動いているか一瞬わからなくなりましたが、これでもうちょっとちゃんと説明できそうです。

また、set(集合)の説明は以下の書籍にも記載があるので、setとは?となった方がいたらオススメです。

Python Distilled ―プログラミング言語Pythonのエッセンス David M. Beazley 著、鈴木 駿 訳

https://www.oreilly.co.jp/books/9784814400461/

on_deleteはデータベース制約を作成しない

DjangoのModel定義のon_deleteについて改めて分かったことを簡単にまとめます。

以下のような要点を改めて知る機会がありました。

  • マイグレーション実行の有無は関係ない
  • Modelに記述したかどうかで処理が決まる
  • データベースにはon_deleteの内容は反映されない

どういうこと?

まず、on_deleteについてリファレンスに以下の説明があります。

When an object referenced by a ForeignKey is deleted, Django will emulate the behavior of the SQL constraint specified by the on_delete argument. For example, if you have a nullable ForeignKey and you want it to be set null when the referenced object is deleted:

https://docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.ForeignKey.on_delete

ここではSET_NULLの例を記述していますが、on_deleteの内容によってSQL制約の振る舞いをエミュレートする、とあります。

また、以下のような記述もあり、データベースにSQL制約を作成しないとあります。

on_delete doesn’t create an SQL constraint in the database.

https://docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.ForeignKey.on_delete

あくまでも、データベースではなくアプリケーション側(Django側)であるという説明をしてくれていると理解しています。

こういう事があるよ

データベースに対して制約をかけていない、という点を理解していないとロールバックが必要な際に取るべき手順を誤ってしまう可能性があります。

例えば以下の条件が存在していると仮定します。

  • taskというアプリケーション
  • ForeignKeyon_delete=PROTECTを設定していたモデルが存在する

そこから、on_delete=CASCADEに変更する場合、以下の条件で実施します。

  1. on_delete=CASCADEにモデルを変更する
  2. python manage.py makemigrationsマイグレーションファイルを作成する
  3. python manage.py migrateマイグレーションを実行する

2.の手順を踏んだ際にはcascadeの変更が記述されたマイグレーションファイルが作成され、以下のようになったと仮定します。

  • 0001_initial.py
  • 0002_change_cascade.py # このファイルが生成されたイメージ

そしてロールバックを実行する際はカラム追加のようなケースであれば python manage.py task migrate 0001と実行しますが、 データベースレベルでは何も制約がないため、on_deleteの変更の場合、実行した所で何も意味がない状態になってしまいます。

つまり、今回のケースで言うとPROTECTに戻ったように見えて実際はCASCADEのまま、のような状態です。

この場合、ファイルをrevertすることで期待するロールバックを実行する事が可能になります。

実は失敗談

私が勘違いしていた内容でした。 自社の書籍かつ愛読書である自走プログラマーではデータマイグレーションの例ですが、ロールバックができることを確認しようという節がありました。

59:データマイグレーションはロールバックも実装する

今回のようなケースでも念の為、ロールバックを出来ることを確認しておこう、と思ってロールバック後に動作確認して意図する挙動にならなかったので「なぜ?」ということになった感じでした。

そして、挙動については公式リファレンスにも載っているので、ちゃんと読まないといけないことを改めて実感・反省しました。

ちなみに、自走プログラマーにはそういう節もあります。 33:公式ドキュメントを読もう

とはいえ、この記事をどこかで見た誰かがそうだったのかーと思えると良いなと思っています。

この記事を書くにあたって調べてみたところ、Modelで作成されたテーブルの内容と、実際にSQLを書いて作成したテーブルの内容では差異もあったのでそうしたところもちゃんと調べてしていけると良いな、と思っています。

DjangoのQuerySetのdelete()について理解する

QuerySetのdelete()の挙動について教えてもらった & 自分でも改めて手を動かして理解してみました。

結論

delete()は単純に指定したModelが削除されるだけではなく返り値も存在します(=ここがまずちゃんと分かっていなかった所でした)。

具体的にはタプルで以下の値を受け取ることができます。

  • 削除されたオブジェクトの数
  • 削除されたオブジェクトのアプリ名・モデル名と削除されたオブジェクトの数

改めて知ってみると、何がどう削除されたかという情報は知りたい情報だと思うので「それはそうだよな」という気持ちになりました。

コードの例

とはいえ、文字だけだと分かりにくいので、実際に結果を見てみましょう。

https://github.com/nibuno/djangoTaskApp で書いている簡易的なTask管理アプリケーションの例です。

以下のModelが存在しています。

  • User
  • Task
>>> user = User.objects.first()
>>> Task.objects.create(title="サンプル",created_user=user,updated_user=user)
 <Task: Task object (1)>
>>> Task.objects.create(title="サンプル",created_user=user,updated_user=user)
 <Task: Task object (2)>
>>> Task.objects.create(title="サンプル",created_user=user,updated_user=user)
 <Task: Task object (3)>
>>> Task.objects.filter(title="サンプル").delete()
 (3, {'tasks.Task': 3})

はじめにユーザーを取得して、生成後、削除しています。

最後の結果にあるように(3, {'tasks.Task': 3})のように削除されたオブジェクトの数と削除されたオブジェクトのアプリ名・モデル名と削除されたオブジェクトの数が受け取られることがわかりました。

上記だと、1つのModelだけしか削除されていませんが、他のModelも一緒に削除された場合は以下のようになります。

こちらは公式リファレンスから引用しています。

>>> b = Blog.objects.get(pk=1)
>>> Entry.objects.filter(blog=b).delete()
(4, {'blog.Entry': 2, 'blog.Entry_authors': 2})

https://docs.djangoproject.com/en/5.0/ref/models/querysets/#delete より

delete()が使えない例

公式リファレンスには、以下のように書かれています。

You cannot call delete() on a QuerySet that has had a slice taken or can otherwise no longer be filtered.

この点についてはすぐ理解出来なかったのですが、

  • limit
  • offset
  • distinct
  • values
  • value_list

を利用したものが使えない、とコードを見て理解出来ました。

実際のコードはこのようになっています。

if self.query.is_sliced:
    raise TypeError("Cannot use 'limit' or 'offset' with delete().")
if self.query.distinct_fields:
    raise TypeError("Cannot call delete() after .distinct(*fields).")
if self._fields is not None:
    raise TypeError("Cannot call delete() after .values() or .values_list()")

https://github.com/django/django/blob/b47bdb4cd9149ee2a39bf1cc9996a36a940bd7d9/django/db/models/query.py#L1164 より抜粋

そのため、例えば以下のようなコードを実行しようとすると、TypeErrorが発生することが分かりました。

>>> Task.objects.all()[:10].delete()
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/Users/tatsuya/djangoTaskApp/venv/lib/python3.12/site-packages/django/db/models/query.py", line 1128, in delete
    raise TypeError("Cannot use 'limit' or 'offset' with delete().")
TypeError: Cannot use 'limit' or 'offset' with delete().

このようなケースでは、SQL的には余り望ましく無いと思いますが、for文で取得した後に削除するケースになるかと思います。

for task in Task.objects.filter(title="サンプル"):
    task.delete()

その他

以下のような事もdelete()の挙動に含まれるようでした。

  • delete()ではForeignKeyon_deleteに従って関連モデルの削除の可否が決まる
  • SQLで削除するため、関連モデルのdelete()が常に呼び出される訳ではない
  • delete()をカスタマイズしているので、必ず呼び出す必要がある時は繰り返し処理で呼び出す(本ブログでいう、for文で取得後の削除処理のような)

この辺りはまた別の記事にもまとめていけたらなと思います。

DjangoでQuerySetを試しに組み立てる際にはdjango-extensionsのshell_plusを利用しよう

Djangoを利用するとき、QuerySetを利用してSQLを組み立てるケースが多いと思います。

しかし、QuerySetの経験に乏しいと、SQLがイメージできていても、どういうQuerySetを書けば良いか、というのがまだ判断できないケースも存在すると思います。

動作確認実施時に、決められた手順で作業をするものの、そのために何度も手順を繰り返すのも効率が悪かったりします。

そういう時に便利な、django-extensionsのshell_plusについて紹介します(会社で先輩から教えてもらいました)。

django-extensionsとは

公式ドキュメント

Django拡張機能群のライブラリです。

テンプレートタグの生成を手助けしてくれるcreate_template_tagsやデバッガが組み込まれたRunServerPlusといったものが色々と用意されてるようです。

その中の1つが、今回紹介するshell_plusです。

shell_plusとは

shell_plus

Django shell with autoloading of the apps database models and subclasses of user-defined classes.

自動でアプリデータベースモデルとサブクラス(ユーザー定義クラス)を自動ロードしてくれる、とのことです。

これだけだと最初イメージが湧きませんでしたが、実際に立ち上げてみると分かりやすかったので、まずは一度立ち上げてみることにします。

shell_plusIPython bpython ptpython などにも対応しています。

The default resolution order is: ptpython, bpython, ipython, python.

との記載があり、既にインストール済みのものがあると、上記の順に優先されます。

起動すると、以下のようになります。

(venv) tatsuya@MacBook-Pro mysite % python manage.py shell_plus
# Shell Plus Model Imports
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
from tasks.models import Task
# Shell Plus Django Imports
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Avg, Case, Count, F, Max, Min, Prefetch, Q, Sum, When
from django.utils import timezone
from django.urls import reverse
from django.db.models import Exists, OuterRef, Subquery
Python 3.12.1 (v3.12.1:2305ca5144, Dec  7 2023, 17:23:38) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> 

# Shell Plus Django Importsの1行上に、私の場合は自作のTaskモデルが存在するのでTaskモデルが読み込まれていることが分かります。

また、その他にもfrom django.contrib.auth.models import Group, Permission, Userといったモデル群や、from django.db.models import Avg, Case, Count, F, Max, Min, Prefetch, Q, Sum, Whenといったものが読み込まれているので、すぐにQuerySetを実行できる準備が整っていることがわかります。

あとは、実際にQuerySetを書いていくことで、実際に実行することができます。

>>> Task.objects.filter(id=1)
<QuerySet [<Task: Task object (1)>]>

--print-sqlについて

そして、調べていく中で分かったオプションとして、--print-sqlというオプションが存在しました。

これは、DEBUG=Trueの場合に有効になる機能で、その名前の通りSQLをprintしてくれます。

例えば、以下のようになります。

>>> Task.objects.filter(id=1)
SELECT "task"."id",
       "task"."content",
       "task"."created_user_id",
       "task"."updated_user_id",
       "task"."created_at",
       "task"."updated_at"
  FROM "task"
 WHERE "task"."id" = 1
 LIMIT 21
Execution time: 0.001414s [Database: default]
<QuerySet [<Task: Task object (1)>]>
>>> 

上記のようなコードでは「なんとなく」SELECT * FROM task WHERE id = 1 のようなSQLが実行されている、と思っていても実際には内部的には違う結果を返していることが分かりました。

他にも、ちょっと複雑なQuerySetを実行する時にも便利です。QuerySetを書くとき、また書いた後にもどのようなSQLが発行されているか、確認することは大切です。

参考(所属企業ビープラウドの自走プログラマーより): 60:Django ORMでどんなSQLが発行されているか気にしよう

taskを更新したユーザーの中で、最も更新したユーザーと更新回数を取得する・・・といった具合にQuerySetを書いてみたとして。

  • 画面を開いて確認する
  • 間違っていたら修正する
  • 修正後、最後画面を開いて確認する...

というプロセスを踏むより、効率的に調べることができます。

今回で言うと、一度書いたコードは以下のようになりました。

>>> Task.objects.values('updated_user').annotate(count=Count('id')).order_by('-count').first()
SELECT "task"."updated_user_id",
       COUNT("task"."id") AS "count"
  FROM "task"
 GROUP BY "task"."updated_user_id"
 ORDER BY 2 DESC
 LIMIT 1
Execution time: 0.000209s [Database: default]
{'updated_user': 1, 'count': 4}

これを踏まえても最初、ORDER BY 2 DESC の部分でcountじゃなく2なのかと驚きましたし、どのように裏側で実行されているかをチェックするのは大事だなと思いました。

このように活用することで、より快適に効率よく進められることがわかったので、私自身も活用していこうと思います。

DjangoでDB接続する時はdbshellを使おう

DjangoにはDB接続周りの処理をラッピングしてくれるdbshellというコマンドがあります。

https://docs.djangoproject.com/en/5.0/ref/django-admin/#dbshell

使い方

python manage.py dbshell

とするとDBへ接続して対話シェルを開いた状態へ。

python manage.py dbshell 'select * from django_migrations'

のようにすると、SQLを実行して接続してくれます。

上記はSQLite3での例なので引数は不要でしたが、MySQLPostgreSQLでは以下ページに記載があるように -- 引数を付与することで -e-cなどのオプションを付与することができます。

https://docs.djangoproject.com/en/5.0/ref/django-admin/#cmdoption-dbshell-0

メリット

MySQL, PostgreSQL, SQLite3といったDBを意識せず、このコマンドでDB接続周りの処理を実施することができます。

自分はこのコマンドに気が付かず、例えばSQLite3なら

sqlite3 mysite/db.sqlite3

select * from django_migrations;

のようにコマンドを入力していました。そしてSQLite3以外のDBを利用するときには、それ以外の接続方法を調べて実行していたので、非常に楽になりました(先輩に教えてもらいました)。

dbshellを利用することで、DB接続も簡単に、かつ他のDBを利用する機会でも流用できるのでオススメです。

QuerySet.queryで手軽にSQLを確認する

DjangoSQLを確認する方法として一番手軽だなと思ったのが.queryを利用したSQL出力でした。

以下のようにQuerySetに対して.queryと記述することで実行される予定のSQLを確認することが出来ます。

python manage.py shell

>>> from snippets.models import Snippet
>>> queryset = Snippet.objects.all()
>>> print(queryset.query)
SELECT "snippets"."id", "snippets"."title", "snippets"."code", "snippets"."description", "snippets"."created_by_id", "snippets"."created_at", "snippets"."updated_at" FROM "snippets"

queryについて

そこでどういうものかちゃんと理解してみようとリファレンスを見たみたところ、public APIではないとのこと。

自分の中では「あくまでもDjangoの公開された機能ではないよ」と解釈しています。

リファレンスには以下のように説明されていました。

The query parameter to QuerySet exists so that specialized query subclasses can reconstruct internal query state. The value of the parameter is an opaque representation of that query state and is not part of a public API.

https://docs.djangoproject.com/en/5.0/ref/models/querysets/#queryset-api

では他にはどうする方法があるのか

所属企業であるビープラウドの書籍、自走プログラマーでは

  • Django Debug Toolbar
  • setting.LOGGING

に記述する、という方法が挙げられていました。 詳しくは以下を参照ください。

https://jisou-programmer.beproud.jp/DjangoORM%E3%81%A8%E3%81%AE%E4%BB%98%E3%81%8D%E5%90%88%E3%81%84%E6%96%B9/60-Django_ORM%E3%81%A7%E3%81%A9%E3%82%93%E3%81%AASQL%E3%81%8C%E7%99%BA%E8%A1%8C%E3%81%95%E3%82%8C%E3%81%A6%E3%81%84%E3%82%8B%E3%81%8B%E6%B0%97%E3%81%AB%E3%81%97%E3%82%88%E3%81%86.html

冒頭で.queryについて触れてみたものの、public APIでは無いこともあり、それだけではなく、EXPLAINの結果や実行速度なども一緒に調べることが出来るので、Django Debug Toolbarが導入されていたらそちらで確認する方が個人的にも楽かな、と思います。

本当にちょっと発行されるSQLが見たい程度であれば、.queryを利用してみる、くらいが良いのかなと思いました。