正直、ここまで苦労を強いられるとは思いませんでしたので備忘録。
業務の一環で実施しようとしたことは、外部キーを持ったトランザクションに紐づいたマスタの値を取得する、他のフレームワークならそこまで苦労しない、たったそれだけのことなのです。ところが、どうもDjangoだと外部キー絡みのモデル上の使用制限とsqliteの仕様によって、大変な苦労を強いられました。
それからDjangoで混乱要素は、データベース作成のためのmodels.pyとマイグレーションの設定ファイルが混同してしまうという点です。
手順は以下のようになっています。
- 1 DBのマイグレーションとマスタ作成
- 2 トランザクションへのデータインポート
- 3 データモデルからの値の取得と出力
※以下のサンプルは業務上案件とは異なります。また、使用しているDjangoは5.1ですが、Django5は4とそこまで仕様は変わりません。
モデルを作成する
まず、モデルを作成します。紐づけたいのはstate_codeですが、ここで重要なポイントは2点です。
- マスタキーで外部参照させたいカラムに対しユニーク制御を行う
- トランザクションに関して、外部参照のカラムに対し、to_field="state_code"とする
to_fieldを省略した場合は、pk_id、つまりidが強制的に外部キーとなってしまうので、今回のように多対1の関係にあるような、任意のコードを参照したいテーブルは適しません。また、db_columnsを使用するように記述している記事も見かけますが、これはid以外を参照させたい(コード番号)などを明示的に紐づけたい場合に指定するものです。ですがsqliteの場合は、DB自体が持っている仕様によって不具合が生じる(後述します)ので使用しません。
on_deleteはDjango2からは必須となっています。該当のマスタを削除したときに、紐づいていたデータに対しどのような措置をとるかというものですが、今回のマスタは削除しないことが前提なのでmodels.PROTECTとしています(削除しようとすると、紐づいた値が確認された場合エラーとなる)。ほかによくあるプロパティとしてmodels.SET_NULL(紐づいていた外部キーをNULLにする)、models.CASCADE(紐づいていたトランザクションデータも全部削除してしまう)などがあります。
※たとえばNYという地域コードを削除した場合、それに紐づいたコード情報を持つ都市情報トランザクションのデータ(New York City、Buffalo、Rochesterなど)をどう処置するか、ということです。
from django.db import models
class mst_state
state_code = models.CharField(max_length=2,Unique=True) #ユニーク制御必須
state_name = models.CharField(max_length=30)
class trn_cities
state_code = models.ForeignKey(mst_state,to_field="state_code",on_delete=models.PROTECT)
year = models.CharFieldField(max_length=4)
city_name = models.CharField(max_length=50)
population = models.IntegerField(max_length=8)
DBのマイグレーションを行う
一旦はDBの設定を行います。今回のプロジェクトはテスト用なのでapp(任意のアプリケーション名)にしています。
#python manage.py makemigrations app
これでマイグレーションファイル(0001_initial.py)が作成されるので、ここにマスタとトランザクションのテーブルを確認します。ひとまずの留意事項として初期設定ではトランザクションの外部キー制御を行わないという点です。
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
operations = [ migrations.CreateModel(
name='mst_state',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state_code', models.CharField(max_length=2,unique=True,null=False)),
('state_name', models.CharField(max_length=30)),
],
),
migrations.CreateModel(
name='TrnApplicant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state_code', models.CharField(max_length=2)),#外部キー制御を行わない
('year', models.CharField(max_length=4))
('city_name', models.CharField(max_length=2)),
('population', models.IntegerField(max_length=8)),
],
),
]
これでマイグレーションを実行します。
#python manage.py migrate
sqlite仕様上の罠
マスタはインポートされている前提(fixtureを使った方法)で話を進めます。
ここでsqlite仕様上の罠があります。sqliteはどうも、主キーと外部キーに関して、同じカラム名を用いることができないようです。なので、sqliteでトランザクションのカラムを確認してみるとstate_code_idと末尾に_idが自動付与された状態となります。
カラムの確認
シェルでカラムを確認してみます。
#python manage.py dbshell
ここで、テーブルのカラムを確認できる以下の関数を用います。
sqlite3> pragma table_info('app_trncities')
すると、state_code_idとして末尾に外部キーが自動設定されていることがわかるはずです。マイグレーションの設定ファイルでは何番目に外部キーを持ってこようが、末尾に移動してしまうようです(これによる悪影響は調査中)。
むしろ、自動でxxx_idと付与されている方が厄介かも知れません。これがsqliteにおいてdb_columnsプロパティを指定すべきではない理由です。
※なお、ここでstate_codeがstate_code_idに変更されたからといって、データモデルを編集する必要はありません。外部キーは外部キーで、別の段階でインポートするからです。
データのインポート
今回は手作業による登録ではなく、外部ファイルからの一括インポートとなります。そのために外部キーがマスタデータのidではなく、参照コードとなった最大の理由です。
データのインポートに際しては、同様に登録処理と同じ手順となります。なお外部キーは代入不要と記述している記事を見かけますが、それはあくまでお互いが主キー(pk_id)であった場合であり、今回のように特定コードが外部キーとなっている場合は代入が別途必要です。また、登録のメソッドとして今回はsaveを用います(createを用いた方法もあります)。フォームモデルの値を一旦オブジェクトに格納し、そのオブジェクトに後で外部キーを追加するという方法でうまくいきます。
ここで、外部キーがstate_code_idに変更されている点に注意する必要があります。ちなみにモデルの段階からstate_code_idと気を利かせておいて、名称を変更しておいてもsqlite上の外部キーはstate_code_id_idとなってしまうだけです。
tbl = trn_cities() #フォームモデルから回収
tbl.state_code_id = 外部キーの値(代入先をstate_code_idにする)
tbl.save() #データ登録
フォームモデルについて
フォームモデルは、データモデルと完全一致している場合、登録や修正処理の手間が省けます(そうでない場合は、フォームモデル内に任意の値を記述して代入する必要がある)。
また、フォームモデル上には主キー、外部キーの値は不要です。もし、入れてしまうと型不一致となり処理前にエラーとなります(fieldsに明示的に記述する方法もあるが、フォーム枠が多いほど面倒になる)。そして、フォームに対してデータモデルと紐づけるためのメタクラスを追記しておく必要があります。
from django import forms
from dealers.models import trn_cities
class Reportform(form.ModelForm):
year = models.CharFieldField(max_length=4)
city_nm = models.CharField(max_length=50)
population = models.IntegerField(max_length=8)
class Meta:
model = trn_cities
fields = '__all__'
データを取得する
ここからデータを取得していきます。これをSQL上で記述するならば、以下のような結合文となります(表別名は今回あえて記述していません)
select * from mst_state join trn_cities on mst_state.state_code = trn_cities.state_code;
これをDjangoのデータモデルから動作を実現するにはselect_relatedという関数を用いるのですが、この使用条件が外部キー制御なのです。今までの面倒な作業は全部これだけのためといってもいいでしょう(他のフレームワークだと結合は至って簡単《外部キーは必須条件じゃない》なのでここまで苦労はしませんでした)。
そして、このselect_relatedの説明も大いに情報が錯綜していたのですが、代入すべき引数は共通のカラム(sqlでon句によって紐づけるカラム、ここではstate_code)です。また、select_relatedは一対多に対して用いるものであり、今回のようにトランザクションを主としてマスタ上の値を参照する(つまりは多対一)場合はprefetch_relatedでないとうまくいきません。
data = trncities.objects.prefetch_related('state_code').all()
これで、晴れてマスタの値(state_name)を取得できる…と思いきや、まだ罠が続いていました。データを取得してみようと変数dataを展開してみます。すると、紐づいたはずのstate_nameがエラーとなりました。
for d in data
print(d.state_name) #値が見つかりませんというエラーが出る
不審に思い取得したSQLを取得してみます。
print(data.query)
すると、SQLはうまく生成されているようですし、生成されたSQLを先程のシェル(dbshell)で実行してみると、データもきちんと表示されました。だとすると、データモデル上は値を取得しているはずです。
色々調べてみると、参照キーに紐づいたマスタデータは、リレーション関係にあるオブジェクトとしてプロパティが生成されるとのこと。なので
print(data.state_code.state_name)
このように、SQLの感覚だと違和感を感じるような記述(DBテーブル名ではなくて、外部参照のカラム名)で取得を試みると、晴れてマスタ上の値を取得することができました(テンプレートに展開するには同様に記述するだけです)。
ちなみにmysql(mariaDB)などの場合は、外部キー制御に際して自動の_id付与は起きないので、任意のカラムで制御可能です。
データを更新する
ここからがもっと大変でした…。理由は、外部キーで紐づいたプルダウンはうまく値が生成されないからで、なのでフォームモデルとは別個の、プルダウン用のフォームモデルを作る必要があったからです。
プルダウン用のフォーム作成
任意でプルダウンを作るにはlabel_from_instance関数を用いて、マスタ上の値を設定していきます(ChoiceFieldで作る方法もあります。その場合はvalues_list('option:value',option.text)となります)。
#外部キー用のプルダウンを作る
class MyModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return obj.d_name
class SubForm(forms.ModelForm):
state_id = MyModelChoiceField(queryset=MstState.objects.all())
class Meta:
model = MstState
fields = ['state_code']
不要なプルダウンを隠蔽する
現状のフォームだと外部キーがデータベースモデルと紐づいているせいで、フォーム上に不要なプルダウンが自動生成されます。それを表示させないように制御する必要があるので、除外指示のexcludeを用います。ただ、その除外するカラムの名称はstate_code_idではなく、紐づけ対象のstate_codeとしなければいけないようです(どうしてこんな仕様なのかよくわかりません)。
また、excludeはタプル形式で指定するようです。
class Reportform(form.ModelForm):
year = models.CharFieldField(max_length=4)
city_nm = models.CharField(max_length=50)
population = models.IntegerField(max_length=8)
class Meta:
model = trn_cities
fields = '__all__'
exclude = ('state_code',) #state_code_idではない
ちなみに任意にプルダウンを書き換える方法は、どうやってもうまくいきませんでした。
- state_code_idとすると、不完全なプルダウンが作成され、一切の編集が効いてくれない。
- state_codeとすると自由にプルダウンは作れるが、更新時にフォームモデルがcannot assign foreign keyとして制御エラーとなる。
- それ以外の変数は記述不可。
世間のQ&A解決法(特にStack Overflow)がことごとく失敗したので、これもsqliteの弊害かも知れませんが要調査項目です。
プルダウンの初期設定
プルダウンはフォームモデルとは独立したので、初期設定も任意で指定する必要があります。フォームモデルのプロパティに対し、初期値設定を行うにはinitialを用います。
※注意点として、先程の方法で作成したプルダウンは、今度はマスタidがoption上のvalueとなってるので、紐づいたマスタid取得も先程のstate_codeに紐づいたidとなります。
subform = SubForm(initial={"state_code":data.state_code.id})
これでプルダウンの初期設定ができました。
更新する
データの更新は、登録と同様に、一旦サブキーをすべてフォームモデルにセットしてから、外部キーを別途挿入する方法でうまくいきます。
ただし、トランザクションに紐づいているのはstate_codeなのに対し、postで取得しているのはstate_idであるため、get関数を用いてstate_codeを取得するようにします。
あとはサブデータのフォームを代入してから、後で外部キーを代入します。
class update(request,id):
result = get_object_or_404(TrnCities,pk=id)
upd_form = ReportForm(request.POST,instance=result)
if(upd_form.is_valid()):
ps = upd_form.save(commit=False)
fk = Mststates.objects.get(pk=request.POST['state_id']) #POSTから取得する
ps.state_code_id = fk
ps.save()
※request.POST['state_code']と、ダイレクトに代入する方法が気になるなら、随時無害化するといいでしょう。
付録(データベースを一から作り直したい場合)
今回、なおさら時間がかかってしまったのは既成のデータベースに対し、再度外部キー制御を設定する必要があるからでした。なので、DBテーブルを一旦削除して一から再作成しました。
DBのマイグレーション確認
無造作にDBを削除してしまうと、中途のマイグレーションが残っていた場合、かなり面倒なことになります。なので、中途のマイグレーションがないか確認しておきましょう(モデルを触っていたりすると、更新用のデータが作成されることがあります)。
# python manage.py makemigrations app
これでno change detect…となれば、大丈夫です。もし、作成された場合はmigrateを実行しておいてください(マイグレーションファイルはいずれ削除します)
DBテーブルの初期化
ここから大事な点はマイグレーションを初期化するということと、DBを削除することです。
# python manage.py migrate --fake app zero
これで実行されたマイグレーションはなかったことになります。
マイグレーションの確認
初期化されたかを確認します。
# python manage.py showmigrations
これでappの項目に対し、✕マークが付与されていなければ、初期化成功です。
マイグレーションファイルの削除
引き続きイニシャルファイル(0001)以外のマイグレーションファイルそのものを削除しておきます。
※例は0002_xxxxのマイグレーションファイルを削除する場合
# rm -rf app/migrations/0002....
DBテーブルの削除
DBテーブルの削除は先程のdbshellから行います。依存性の関係にエラーが起きるので、マイグレーション上からは実行しない方がいいみたいです。
# python manage.py dbshell
テーブルを確認
.tablesでDBテーブルを確認し、appmststateとapptrncitiesが存在することを確認します。
>.tables
削除コマンドの実行
削除はdrop tableで行います。
> drop table app_mststate;
> drop table app_trncities;
これで再度テーブルを確認し、削除をチェックします。
念の為、sqliteドライバも削除しておきます。
# rm -rf db.sqlite3
これでdbshell上で>.table
としても何も表示されなくなるはずです。
テーブルを再作成
今度は削除したテーブルを再作成するのですが、そのままだと存在しないテーブルに対しマイグレーションファイル0002内のaddModelを実行してしまうので、エラーとなります。なので、--fake-initialオプションを付与します。このオプションを付与することで、イニシャルマイグレーションに対し最優先処理が与えられるので、作成されていないテーブルを再作成してくれることになります。
結果、0001のcreateModelだけが実行されることになります。
# python manage.py migrate --fake-initial
※モデル情報は残してあるので、マイグレーションファイルを全削除してから
# python manage.py makemigration app
# python manage.py migrate
とする方法もあります。こうすると、最初のマイグレーションで、自動でテーブルモデルを作成してくれるので、あとはマイグレートするだけです。
記事に書いてしまえばこんなものなのですが、あまりにも仕様上の落とし穴が多く、この記事に関する作業を完遂するのにほぼ2人日かかってしまいました。どうしてDjangoはこんな仕様なんでしょうか。
また、今回、一段と時間を要した理由として、プルダウンがマスタのidではなく参照コードであったことも原因ではないかと思っています。
なので、今回のように、外部キーのn+1問題に直接関係しない、つまりトランザクション上のコードをマスタd参照するだけの場合には、外部キー制御は解除し、filterで制御した方がいいという結論に行き着きました。