チケット#373(Add support for multiple-column primary keys)については、Djangoの以下Wikiに詳しく書いてある。
https://code.djangoproject.com/wiki/MultipleColumnPrimaryKeys
これに関する自分の意見と、django-compositepk-modelではどのように実装したかを述べる。
0. django-compositepk-modelについて
複合主キーの対応については、Djangoのモデルに手を入れるのが良い実装になると思うが、対応されるのかもわからない状況で待てない。Djangoから分離して独自ブランチで進めるという手もあるが、先々のメンテナンスを考えるとそれも良くない。
ということで、拡張クラスとしてパッケージにした。直接手が入れられないため、1行を変更するために、オーバーライドして元ソースを丸ごとコピーして直すという間抜けな実装もあるが、基本的な部分は上手くフックできていると思う。
GitHub上にテスト用のデータ設定済みのSQLiteのデータも含まれていて、Admin画面も実行できるので、興味のある人は動かしてみて下さい。
https://github.com/Arisophy/django-compositepk-model/
1. WikiのMajor Issuesについて
https://code.djangoproject.com/wiki/MultipleColumnPrimaryKeys#MajorIssues
1.1 _meta.pkについて
1. A number of APIs use “obj._meta.pk” to access the primary key, on the assumption it is a single field (for example, to do “pk=whatever” lookups). A composite PK implementation would need to emulate this in some way to avoid breaking everything.
Django Community Wiki
1) multi field対応
CPKModelでは、_meta.pkに設定するものを、複数フィールドをまとめるCompositePkというFieldのサブクラスに変更した。
class CompositeKey(Field):
def __init__(self, keys, primary=False):
names = tuple((f.name for f in keys))
join_name = CPK_SEP.join(names)
db_columns = tuple((f.db_column if f.db_column else f.name for f in keys))
db_join_column = "(" + ",".join(db_columns) + ")" """! カンマ区切りに !"""
super().__init__(
name=join_name,
primary_key=primary,
unique=True,
)
self.keys = keys
self.attname = join_name
self.column = join_name
self.names = names
self.model = keys[0].model
def get_col(self, alias, output_field=None):
return CompositeCol(alias, self, output_field)
class CompositePk(CompositeKey):
def __init__(self, keys):
super().__init__(keys, primary=True)
:
class CPkModelBase(ModelBase):
""" Metaclass for CompositePkModel."""
def __new__(cls, name, bases, attrs, **kwargs):
:
super_new = super().__new__(cls, name, tuple(modelbases), attrs, **kwargs)
meta = super_new._meta
pkeys = tuple(f for f in meta.local_concrete_fields if f.primary_key)
# change attributes
if len(pkeys) > 1:
super_new.has_compositepk = True
meta.pk = CompositePk(pkeys) """! _meta.pkにCompositePkを設定 !"""
setattr(super_new, "pk", CPkModelMixin.cpk)
setattr(super_new, "_get_pk_val", CPkModelMixin._get_cpk_val)
setattr(super_new, "_set_pk_val", CPkModelMixin._set_cpk_val)
setattr(super_new, meta.pk.attname, None)
setattr(super_new, "_check_single_primary_key", CPkModelMixin._no_check)
setattr(super_new, "delete", CPkModelMixin.delete)
else:
super_new.has_compositepk = False
setattr(super_new, "get_pk_lookups", CPkModelMixin.get_pk_lookups)
meta.base_manager._queryset_class = CPkQuerySet
meta.default_manager._queryset_class = CPkQuerySet
super_new.pkeys = pkeys
super_new.pkvals = CPkModelMixin.pkvals
super_new._meta = meta
return super_new
class CPkModel(CPkModelMixin, Model, metaclass=CPkModelBase):
pass
従来のModelとCpkModelの設定値の違いは以下のようになる。※テスト画面から「CheckKeys」からも確認できる。
| # | Musician(Model) | Album(CPkModel) |
| obj.pk | 1 | 1,1 |
| _meta.pk | test.Musician.id | test.Album.artist,album_no |
| _meta.pk Class | AutoField | CompositePk |
| _meta.pk.keys | – | (<django.db.models.fields.related.ForeignKey: artist>, <django.db.models.fields.IntegerField: album_no>) |
| _meta.pk.name | id | artist,album_no |
| _meta.pk.attname | id | artist,album_no |
| _meta.pk.column | id | artist,album_no |
| _meta.pk.names | – | (‘artist’, ‘album_no’) |
| _meta.pk.model | Musician object | Album object |
| _meta.pk.Col | Col(Musician, test.Musician.id) | CompositeCol(Album, test.Album.artist,album_no) |
2) pk=”whatever” lookupsの対応
[pk=”whatever” lookups]のほとんどは、最終的には、Query.add_qで処理される。そこで、CompositePkが作った特殊なpkと、それに対応する複数の値を持つ”whatever”をここでうまく処理すればいい。
方針としては、以下で対応した。
2-1) 複合主キーの条件をadd_qの中で複数条件に変更
例えば、”pk=obj.pk”という条件は以下のように変更する。
Albumの場合、_meta.pk.attnameから左辺は、”artist,album_no”となる。右辺のobj.pkは”1,1″のような形になる。両辺をカンマで分解して二つのQ条件にする。
Q(artisit='1') & Q(album_no='1')
このクエリは、最終的に、以下のようなSQL条件文になる。
"Album"."artist_id" = 1 AND "Album"."album_no" = 1
実装している部分は、以下である。
class CPkQueryMixin():
:
###########################
# override
###########################
:
def add_q(self, q_object):
:
def make_q(keys, vals):
q = Q()
for key, val in zip(keys, vals):
q.children.append((key, val))
return q
assert isinstance(obj, (Q, tuple))
if isinstance(obj, Q):
:
else:
# When obj is tuple,
# obj[0] is lhs(lookup expression)
# pk and multi column with lookup 'in' is nothing to do in this, it will change in 'names_to_path'.
# obj[1] is rhs(values)
# valeus are separated in this method.
names = obj[0].split(LOOKUP_SEP)
if ('pk' in names and self.model.has_compositepk) or CPK_SEP in obj[0]:
# When composite-pk or multi-column
if len(names) == 1:
# change one Q to multi Q
keys = separate_key(self, obj[0]) """! キーを分ける !"""
vals = separate_value(keys, obj[1]) """! 値を分ける !"""
if len(keys) == len(vals):
return make_q(keys, vals) """! make multi conditions !"""
else:
raise ProgrammingError("Parameter unmatch : key={} val={}".format(keys, vals))
else:
# check the last name
last = names[-1]
if last == 'in':
:
elif last == 'pk' or CPK_SEP in last:
# change one Q to multi Q
# example: ('relmodel__id1,id2', (valule1,value2))
# |
# V
# ('relmodel__id1', valule1)
# ('relmodel__id2', valule2)
before_path = LOOKUP_SEP.join(names[0:-1])
cols = separate_key(self, last)
keys = [before_path + LOOKUP_SEP + col for col in cols] """! キーを分ける !"""
vals = separate_value(cols, obj[1]) """! 値を分ける !"""
return make_q(keys, vals) """! make multi conditions !"""
else:
# another lookup is not supported.
raise NotSupportedError("Not supported multi-column with '{}' : {}".format(last,obj[0]))
return obj
new_q = transform_q(q_object)
super().add_q(new_q)
2-2) 複数カラムIN句の対応
IN句については、pkはadd_qで加工はしないで、そのままCompositePkのままにする。値については、カンマ区切りのものは分解してタプルにする。
例えば、”pk__in=[‘1,JP’,’1,US’,’2,JP’,]”という条件は以下のように変更される。
pk__in=[(1,'JP'),(1,'US'),(2,'JP')])
DBカラム名を作成するColクラスは、CompositePkにおいては、CompositeColサブクラスにて処理されて、以下表現を返す。
("CompanyBranch"."company_id", "CompanyBranch"."country_code")
最終的に、以下のようなSQL条件文になる。
("CompanyBranch"."company_id", "CompanyBranch"."country_code") IN ((1,"JP"), (1,"US"), (2,"JP"))
なお、主キーだけでなく、任意のカラムでもカンマ区切りで複数指定ができるようにした。
class CompositeCol(Col):
def __init__(self, alias, target, output_field=None):
super().__init__(alias, target, output_field)
self.children = [Col(alias, key, output_field) for key in target.keys]
def as_sql(self, compiler, connection):
sqls = []
for child in self.children:
sql, _ = child.as_sql(compiler, connection)
sqls.append(sql)
return "(%s)" % ",".join(sqls), []
class CompositeKey(Field):
:
def get_col(self, alias, output_field=None):
return CompositeCol(alias, self, output_field) """! CompositeColを返す !"""
class CPkQueryMixin():
:
###########################
# override
###########################
:
def names_to_path(self, names, opts, allow_many=True, fail_on_missing=False):
meta = self.get_meta()
first_name = names[0]
# name[0] is Multi-Column ?
if (first_name == 'pk' and self.model.has_compositepk) or CPK_SEP in first_name:
# get CompisteKey
ckey = meta.pk
if first_name != 'pk' and first_name != ckey.name:
# IF Not PK, make another CompositeKey
cols = [meta.get_field(col) for col in first_name.split(CPK_SEP)]
ckey = CompositeKey(cols) """! 複数カラムをCompositeKeyでまとめる !"""
lookups = names[1:] if len(names) > 1 else []
return [], ckey, (ckey,), lookups
else:
return super().names_to_path(names, opts, allow_many, fail_on_missing)
1.2 A number of things use (content_type_id, object_pk) tuples
2. A number of things use (content_type_id, object_pk) tuples to refer to some object — look at the comment framework, or the admin log API. Again, a composite PK system would need to somehow not break this.
Django Community Wiki
“object_pk” が CompositePkに変わっても、問題ないと思う。実際、CompositePkを使うCPkModelをAdmin画面で使っても問題は特に起きていない。
1.3 Admin URL
3. Admin URLs; they’re of the form “/app_label/module_name/pk/”; there would need to be a way to map URLs to objects with a set of columns for the primary key.
Django Community Wiki
CompositePkは、カンマ区切りの文字を返す。URLはエスケープされて問題なく使えている。


1.4 結論
_meta.pkを、CompositePkという複数フィールドを管理するサブクラスに置き換えるという考え方で、Major Issuesを簡単に解決できた。
2. 今後の課題
django-compositepk-modelの課題、制約は以下。
https://github.com/Arisophy/django-compositepk-model#limitations
https://github.com/Arisophy/django-compositepk-model/issues
CPkForegnKey以外は、Djangoのソースを直接いじれば、簡単に解決できる問題だと思う。
現状のままでも、自分のプロジェクトでは、今のところ問題なく使えている。また、今は、リリースに向けてパフォーマンス見直しおしており、ORMのリレーションは使わずに、逆にRawクエリに直しているところで、CPkForegnKeyは無くても困っていない。
いつか余裕ができたら、CPkForegnKeyの追加、あるいは、Djangoそのものに手を入れることも考えてみたい。