チケット#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そのものに手を入れることも考えてみたい。