PythonのORMで1対1の関連を定義して、DBから値を取り出した時のメモです。以下のような usersテーブル と contactsテーブル を例に、1対1の関連を定義する方法と違いを調べました。
ORMごとに1対1の関連を定義する
使用したPythonのORMとバージョンは以下になります。
ORM | バージョン |
---|---|
SQLAlchemy | 1.3.10 |
peewee | 3.11.2 |
Orator | 0.9.9 |
users から値を1つ取り出すと、それに対応する contacts の値をただ1つ取り出せるのが望ましい挙動です。それぞれのORMでこの挙動を実現できることを確認していきます。
SQLAlchemy
relationship
関数で関連を定義できます。関連を定義する際に uselist=False
オプションを指定することで、1対1の関係として定義できます。SQLAlchemyでusers と contacts のモデルを定義すると以下のようになります。
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
contact = relationship("Contact", back_populates="user", uselist=False)
class Contact(Base):
__tablename__ = "contacts"
id = Column(Integer, primary_key=True)
address = Column(String)
email = Column(String)
user_id = Column(Integer, ForeignKey('users.id'), unique=True)
relationship
の詳細は、SQLAlchemy - one to one - 公式ドキュメント を参照ください。1対1であることをちゃんと定義するために、user_id
にユニーク制約を設けています。
試しに users テーブルのデータを1つ取り出して、contactを取得してみます。配列でなく、単一の値として取得できていることが確認できます。
>>> type(session.query(User).first().contact)
<class 'sa.models.Contact'>
peewee
peewee は、ForeignKeyField
で関連を定義します。 ただし、関連が1対1であることをオプション等で明示できません。以下のように、リストの値を1つ返すプロパティを定義しておく必要があります。これについては公式ドキュメントには明示されていませんが、GitHubのissue に記載されていました。
class User(ModelBase):
class Meta:
table_name = "users"
name = CharField()
@property
def contact(self):
return self.contacts.get()
class Contact(ModelBase):
class Meta:
table_name = "contacts"
address = CharField()
email = CharField()
users = ForeignKeyField(User, backref="contacts", unique=True)
プロパティを設定したとしても、User.contacts
の定義が上書きされるわけではないので、contact
にも contacts
にもアクセスできる状態となります。 User.contact
と Users.contacts
にそれぞれアクセスしてみると、以下のようになります。
>>> user = User.get()
>>> type(user.contact)
<Model: Contact>
>>> type(user.contacts)
<class 'peewee.ModelSelect'>
>>> type(user.contacts[0])
<Model: Contact>
この状態が問題になることはあまりありませんが、ちょっとややこしい状態ではあります。後述する model_to_dict
などを使った時に少し厄介な状態です。
playhouse.shortcuts.model_to_dict
playhouseには、peeweeの便利関数が色々と含まれています。 model_to_dict
は、peeweeのモデルオブジェクトを辞書形式に変換してくれる便利関数です。上記のUserクラスに対して、この関数を使うと以下のようになります。
>>> from playhouse.shortcuts import model_to_dict
>>> user = User.select().join(Contact).get()
>>> model_to_dict(user, backrefs=True)
{'id': 1, 'name': 'Bob', 'contacts': [{'id': 1, 'address': 'Tokyo', 'email': 'sample@email.com'}]}
この結果にはプロパティである contact
が含まれません。 contacts
がリストとして取得されているのがわかります。この関数で次のような値を得られると嬉しいのですが、現状はそうなっておりません。
{'id': 1, 'name': 'Bob', 'contact': {'id': 1, 'address': 'Tokyo', 'email': 'sample@email.com'}}
残念なことに出力形式を変更することはできません。contact
をリストでない形式で出力したいのであれば、便利関数を使わずに自前で辞書形式に変換する必要があります。
Orator
Oratorは has_one
と belongs_to
デコレータを付与して、1対1の関連を定義できます。外部キーのカラム名などによってはデコレータにオプションを付与する必要がありますが、今回の場合は必要ありません。
class User(Model):
@has_one
def contact(self):
return Contact
class Contact(Model):
@belongs_to
def user(self):
return User
他のORMと同様に contact の値を取得してみます。
>>> user = User.first()
>>> user.contact
<ora.models.Contact object at 0x107727d10>
serialize() と to_json()
Oratorにもモデルを別の形式に変換できる便利関数があります。serialize()
は辞書型に、 to_json()
はJSON形式の文字列に変換できる関数です。peeweeとは異なり、1対1の関係である contact
を単一の値として出力できています。
>>> user = User.with_('contact').first()
>>> user.serialize()
{'id': 1, 'name': 'Bob', 'contact': {'id': 1, 'address': 'Tokyo', 'email': 'sample@email.com', 'user_id': 1}}
>>> user.to_json()
'{"id": 1, "name": "Bob", "contact": {"id": 1, "address": "Tokyo", "email": "sample@email.com", "user_id": 1}}'