適当おじさんの適当ブログ

技術のことやゲーム開発のことやゲームのことなど自由に雑多に書き連ねます

Python ORMで1対1の関連を定義したときのメモ

PythonのORMで1対1の関連を定義して、DBから値を取り出した時のメモです。以下のような usersテーブル と contactsテーブル を例に、1対1の関連を定義する方法と違いを調べました。

f:id:subarunari:20191104010649p:plain

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.contactUsers.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_onebelongs_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}}'