ITの窓辺から

三流IT技術者の日常

Django ManyToManyFieldはどちらのモデルで指定すべきか

多対多関係のテーブルを作る場合、DjangoではManyToManyFieldを使用します。するとDjangoは元々の多対多関係のテーブルと一対多関係になるような中間テーブルを自動的に生成します。この時、どちらのテーブルにManyToManyFieldを指定するかいつも悩みます。それについてちょっと調べてみましたので、この記事ではその結果を書いておきます。

結論から言うと、基本的にどちらに書いても変わらないような気がします。影響が出るとしてもテーブル構造やインデックス次第、という身も蓋もない話です。

実は、ManyToManyFieldの使い方をすぐに忘れてしまうので言語化しておこうと思い、ついでに動作を確認した結果を書いているだけだったりします。

環境は以下のとおりです。
OS : macOS(intel CPU) Big Sur
Python : バージョン3.8.5 (venv環境)
Django : バージョン3.2.5
データベース : sqlite3

調査

Djangoのsettingsにログの設定を行い、発行されるSQLを見てみました。

モデル

以下のようなシンプルなモデルを用意してみました。

class Menu(models.Model):
    name = models.CharField(max_length=64)
    ing = models.ManyToManyFiled(Ingredient, related_name='Ing')

class Ingredient(models.Model):
    name = models.CharField(max_length=64)

食事メニューのテーブルと材料のテーブルです。
食事メニューのテーブルは、例えばシチュー、カレー、豚汁といったものがレコードになり、材料のテーブルは豚肉、じゃがいも、味噌といったものがレコードになります。この例では食事メニューの方にManyToManyFieldを指定しています。

マイグレーションを行うと中間テーブルが生成されます。これで食事メニューテーブルと材料テーブルの関連付けができているわけです。中間テーブルのカラムには双方のテーブルの主キーに対する外部キー制約がかかります。また、中間テーブルのインデックスは双方のテーブルの主キーに対応するカラムどちらに対しても張られます。

ここまで来たら、マネージャを使うことでモデルの操作が可能になります。モデルの操作は食事メニューからも材料からも可能です。操作に関して覚えておくことは、ManyToManyFieldを指定したテーブルともう片方のテーブルで若干マネージャでの指定の仕方が異なることです。

下準備です。

# 食事メニューインスタンスを作成
m1 = Menu.objects.create(name='Curry') #カレー
m2 = Menu.objects.create(name='Misoshiru') #豚汁

# 材料インスタンスを作成
i1 = Ingredient.objects.create(name='Pork') #豚肉
i2 = Ingredient.objects.create(name='Tofu') #豆腐
i3 = Ingredient.objects.create(name='Miso') #味噌

ManyToManyFieldを指定した方からの操作

つまり食事メニューを1個選んで、そこに含まれる材料を取り扱う場合です。豚汁(m2)に材料を追加してみます。この場合、マネージャの使い方としては素直にManyToManyを指定したカラムを指定するだけです。具体的には以下のようにします。

m2.ing.add(i1) #豚汁の材料として豚肉を追加
m2.ing,add(i2) #豚汁の材料として豆腐を追加
m1.ing.add(i1) #カレーの材料として豚汁を追加

人間の理解としてはコメントで書いたとおりですが、Django的には中間テーブルにINSERTする動作をします。

豚汁の材料を全て取得するには以下のようにします。

m2.ing.all() #豚汁の材料を全て表示

こうするとQuerySetとして中間テーブルで豚汁に相当するレコードの材料カラムに対応する材料テーブルのレコードが返されます。今回は豚肉と豆腐です。SQL的には食事メニューと中間テーブルの内部結合したものから、豚汁のレコードのみ切り出したものを返す動作をします。

マネージャには他のメソッドとして削除や置換が用意されています。いずれも中間テーブルに対するレコード操作であり、中間テーブルは各カラムにインデックスが張られているので通常はパフォーマンスは十分発揮できると考えられます。

ManyToManyFieldを指定していない方からの操作

逆からの操作もできます。材料の方を起点に、この材料を使って作るメニューを操作するような場合です。基本的にはManyToManyFieldを指定した方と同じ操作が可能ですが、マネージャでの指定の仕方が違います。具体的には以下のようにします。

i1.Ing.all() #豚肉を使用する食事メニューを全て表示

ManyToManyFieldを指定した時に設定したrelated_nameの値を使用します。related_nameを指定しない場合、<モデル名>_setという値を使用します。個人的にはrelated_nameをちゃんと指定しておく方が後々に分かりやすくなると思います。

上記のように指定するとSQL的には材料と中間テーブルを内部結合したものから、豚肉のレコードのみ切り出したものを返す動作になります。先ほどとの違いは内部結合するテーブルと切り出し(WHERE)に使うテーブルです。

まとめ

さて、どちらのケースであっても中間テーブルのレコードに登録される値は各テーブルの主キーではありますが、標準ではDjangoはインデックスを張ってくれないので必要に応じてインデックスを作っておかないとデータベース操作性能に影響が出る可能性があります。ただ、ManyToManyFieldをどちらに設定すべきかという当初の疑問の解答としてはどちらでも良い、ということになりそうです。

強いて言うとコードの保守性を考えた時に、どちらに作成するのか一定の基準が設計書やコーディング規約で示されていることが望ましいような気はします。

DjangoでLDAP認証 その7 機能のテスト

前回でとりあえずDjangoの認証にActive Directoryを使用したLDAP認証を実装したのでした。

realizeznsg.hatenablog.com

今回はこれらの認証におけるテストを行います。はじめはPythonのunittestで諸々やろうとしていたのですが、調べるとDjangoにunittestを拡張した独自実装があるようだったのでこちらを使ってみることにします。通常のunittestに加えてDjangoのテストを行うための便利機能が多数ついているようなので、思っていたよりは楽にテストが書けそうです。正直、標準のunittestの場合、テストのテストコードがいるなと思っていたレベルです。しかし、実際のDjangoのテスト機能はあくまでプロジェクト内のコードの動作正常性を担保するためのもののようなので、Webサーバ等の関連要素を含めたテストを行う場合は別のテスト機能を使用した方が良いように思われます。

ファイル名とテスト用クラス内のテストメソッドに「test」という文字列が入っていると自動的にテスト対象として収集されるようです。テストコードが増えてきたら、test用のディレクトリを作ってその中に複数のテストコードファイルを作成するのがとりあえず普通のやり方と思われます。

いつもの注意書きですが、この記事はWebアプリケーションとDjangoの初心者が調べたことをメモするものであり、正しい解説記事を目的としていないのでご注意ください。

環境は以下のとおりです。
OS : macOS(intel CPU) Big Sur
Python : バージョン3.8.5 (venv環境)
Django : バージョン3.2.5
Webサーバ : Djangoに組み込みのもの
ldap3 : バージョン2.9.1
LDAPサーバ : Active Directory (Windows Server2016)

テスト設計

ホワイトボックステストが難しい

少し調べたところ、Djangoで用意されているテストツールだけで認証バックエンド自体の挙動をテストすることは少し面倒そうでした。というのも、認証バックエンドのコードが正しく動作することを確認するためには、(少なくとも今回のコードにおいては)テスト実行時にテスト用のユーザをLDAPサーバ側に作成する必要があるからです。

もちろん究極的にはテスト実行は問題ないはずですが、そもそもこの汎用的なテストコードからLDAPサーバに接続してアカウントを作成したりアカウントの設定を変更するにはLDAP BINDに使用するユーザにかなりの権限を与える必要があり、セキュリティ的な観点からは全くやる気にならないです。Active Directoryならドメイン管理者かアカウント管理者相当の権限が必要です。また、Django内のユーザーモデルのデータベースがテストのたびに汚れることも個人的には少し気になります。要するにホワイトボックステストがやりづらいです。

もしこのあたりのテスト自動化も行うなら、アカウント管理者相当の権限を付与し、このアカウントがLDAPサーバにアクセスできる方法を限定した上で、このテストの実行を許可制かつ監査ログに操作内容を明確に記録し、所定以外の操作が行われた時はアラートを出すようにしておくべきかなと思いました。

今回のテスト概要

アカウント操作をPythonから行うのはLDAPとLDAP3モジュールの話が中心になり、Djangoとはあまり関係ない話になるので、この記事では基本的にブラックボックステストによる動作確認とします。また、Selenium等を使ったクライアントサイドからのテストは行いません。

具体的には、事前にDjango利用のためのセキュリティグループを振ったテストアカウントをLDAPサーバに事前に作成しておきます。Djangoパーミッションも使用しているのでこれに応じたアカウントも用意します。そのアカウントとパスワードをテストコードに静的に入力して認証を行い、想定したデータ/ページを返すかテストするイメージです。

事前の動作検証

Clientオブジェクトのloginメソッド

Djangoに用意されているdjang > testモジュールにClientというクラスがあります。このクラスのメソッドにloginというものがあるようで、このメソッドにユーザ情報を渡すことで認証動作を発生させることができるようです。このメソッドは認証に成功するとTrueを返すとのことです。

公式のドキュメントにはあまり詳細な説明がなくて動作の理解に苦慮したので、諸々動作確認した結果、私が勝手に想像していた動作とは異なることがわかりました。具体的には、loginメソッドによる認証動作を行うことで認証状態の維持に必要なクッキーを取得、その状態でClientオブジェクトのgetメソッドを使用することで要認証ページのHTTPレスポンスを取得、というのを想像していました。

とりあえず2個の誤解がありました。
1つ目は、要認証としているビューが返すHTTPレスポンスは、認証済み状態であってもHTTP302を返すことです。このためassertEqualを使用して要認証ページのアクセス結果がHTTPステータスコード200であることを確認できません。リダイレクトを使用しているページ用に、assertRedirectsもありますが、どうもこれが認証ページだと正しく動かず、assertRedirects内で使用するターゲットURLが期待したものになりません。

これらから予想するに、loginメソッドは使用したクレデンシャル及び認証バックエンドで認証が成功するかどうかを確認するためのもので、その後のテストを対象としたものではないのではないか、というものです。

つまりテスト方針としては、

  • 認証成功パターンのビューテストケース
    • 認可制御の各パターンのビューテストケース
  • 認証失敗パターンのビューテストケース

というものを考えることになると思われます。認証成功パターンではloginメソッドの返り値がTrue、認証失敗パターンでは戻り値がFalseになることが正解になるようなテストを書きます。このチェックにはassertTrueとassertFalseを使用するのが良さそうです。さらに、認証成功パターンでは認可制御によってテストが枝分かれします。

と書いてみたものの、イマイチ腑に落ちないんですよね・・・。loginメソッドでログインした後に諸々のビューの動作テストが行えるほうが個人的には直感的です。もし有識者の方が見ていたら実際のところを教えて頂けると幸いです。

Clientオブジェクトのforce_loginメソッド

loginメソッドが単純に認証可否の動作をすることに対して、指定したユーザクレデンシャルで認証成功したことにして動作を確認するforce_loginメソッドというものがあるようです。これを使って要認証ページのHTTPレスポンスを見たところ、無事に意図した内容になっていました。実際にWebブラウザでアクセスすると前述の通りHTTP302が返るのですが、force_loginを使用した場合はこの302はスキップされるようです。

loginメソッドが引数にクレデンシャルをキーワード引数として自由に指定可能になっていることに対して、force_loginでは引数がユーザーモデルになっています。つまり、force_login実行時にはDjango内にアカウントが作成済みである必要があるということか・・・?

loginとforce_loginの検証

force_loginメソッドをテストコードのほぼはじめに実行する内容で試しまして、既にDjango内に作成してあるユーザモデルのオブジェクトを使用すると「User matching query does not exitst.」エラーが返りました。

次にloginメソッドで認証を行った後、同じClientオブジェクトとloginメソッドで使用したユーザ名でユーザーモデルから標準マネージャで取得したユーザーモデルオブジェクトでforce_loginメソッドを使ったところ、エラーは表示されませんでした。

実はこれまでインターネットで読んでいたDjangoのテストコードの動作の説明では、実際のテーブルにデータを書き込みしてロールバックするのが基本動作だと書いてあったような気がしておりとても困惑しています。ユーザーモデルだけは例外なのか、そもそも認識が間違っているのか・・・・。

logoutメソッドを使用した後にforce_loginしてもエラーは表示されないのでClientオブジェクトの状態の問題ではなく、テスト動作中に使用されるユーザーモデルの状態に原因がありそうです。

loginメソッドで使用するアカウントがどこで作成済みであるかによってforce_loginメソッドの結果がどのように変わるか調べたところ、結果は以下の通りでした。尚、両方のメソッドで同じアカウント名を使用しています。

LDAP Django 結果
アカウント有 アカウント有 成功
アカウント有 アカウント無 成功
アカウント無 アカウント有 失敗
アカウント無 アカウント無 失敗

今度はloginメソッドとforce_loginメソッドで使用するアカウント名を変えてみました。LDAP側にアカウントがない場合は前述の通り絶対に失敗するので、loginメソッドで使用するアカウントはLDAPに作成済みのパターンのみ試します。

login force_login 結果
アカウントA LDAP上のみにあるアカウントB 失敗
アカウントA LDAPDjangoにあるアカウントB 失敗
アカウントA Django上のみにあるアカウントB 失敗
アカウントA LDAPDjangoどちらにもないアカウントB 失敗

これらの結果から、テストの時に仮のユーザーモデル用のテーブルが出来上がっており、loginメソッドによって実行される認証バックエンドにより仮テーブルにその時使用されたユーザーが登録され、force_loginではそのユーザーモデルのみ使用できる、という動作をする可能性が高そうです。

尚、今回は認証バックエンドをカスタマイズしており、認証バックエンド内にユーザーモデルにユーザ情報を登録する処理があります。標準認証アプリではユーザ登録処理はないので、テストの中でユーザ登録処理が必要になりそうです。調べてみると、標準マネージャのcreate_userというメソッドを使うことで実現できそうです。試していませんが。

とりあえず今回は調査はここまでにして、ユーザーモデルに関してはテスト実行時に特別なテーブルが作成される説を仮採用して先に進みたいと思います。いずれ全貌が明らかになることを期待して・・・。

実際のテストコード

かなり事前調査が長くなりましたが、それに反して実際のテストコードはシンプルです(単純にビューが少なく機能も少ないから当然なのですが)。大体以下のようなイメージです。認証後の実際の動作テストにはステータスコード、内容、テンプレート使用等必要なものを書きます。

UserModel = get_user_model()

class PageViewByLdapAuthTest(Testcase):
    def setUp(self):
        test_client_1 = Client()
        test_client_2 = Client()
        ....
        #認証成功パターン
        assertTrue(test_client_1.login(username='USER01', password='PASS1')
        assertTrue(test_client_2.login(username='USER02', password='PASS2')

        #認証失敗パターン
        assertFalse(test_client_3.login(username='USER03', password='PASS3')

    def test_auth_valid_page_view(self):
        #認証成功パターンのテスト
        test_client_1.force_login(UserModel.objects.get(username='USER01'))
        response1 = test_client_1.get('VIEW PATH')
        assertContains(〜〜〜〜〜〜〜)

    def test_auth_invalid_page_view(self):
        #認証失敗パターンのテスト (内容は省略)

アカウント情報の食わせ方は工夫の余地があると思いますのでセキュリティやその後の運用に配慮するのが良さそうです。コード内にLDAPサーバに存在するアカウントのパスワード直書きはやばいのでご注意下さい。

終わりに

今回のテストコードは調査に本当に時間がかかりました。特にテストコードにおけるテーブルの取り扱いが混乱のもとになっています。DjangoLDAP認証を行う構成では、多分私以外の初学者も大体同じところで引っかかるのではないだろうか・・・と自分を擁護してみます。もしそういう人がいてこの記事にたどり着けた場合は考え方の補強の一助になれば良いなと思うところです。

DjangoLDAP認証に関する一連の記事は今回で終了です。前回の記事でも書きましたが、認証以外のところでも相当理解が甘かった部分が判明したり、新たな疑問が出てきたところが多数あったりもするので、引き続き確認した内容をまとめていきたいと思います。