前回の記事でSQLAlchemyのCoreについてざっくりおさえたので、今回はSQLAlchemy ORMの方をやっていく。

SQLAlchemy ORM

SQLAlchemy ORMSQLAlchemy Coreの上に作られたコンポーネント。 SQLAlchemy Coreはユーザが自分でSQL文を構築して実行するのを助けてくれるのに対して、SQLAlchemy ORMはSQLAlchemyがSQL文を構築してくれる感じ。

SQLAlchemy ORMを使ってPythonのクラスとRDBのテーブルとのマッピングを定義してやると、そのクラスのインスタンスへの変更をSQLAlchemyがトラッキングしてくれて、透過的にRDBのレコードに反映してくれる。

マッピングの定義

PythonのクラスとRDBのテーブルとのマッピングはDeclarativeというAPIで定義する。 マッピングを定義するクラスはDeclarativeのBaseクラスを継承させる必要があるので、まずはそのBaseクラスを作る。

orm/base.py:

from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

Baseクラスは、継承したクラスとかマッピングの定義とかを覚えてくれるので、普通は1つのアプリでは1つのBaseクラスだけを作って使いまわして、マッピングを集中管理させる。

マッピングを定義するには、以下のようにBaseクラスを継承したクラス(mapped class)作って、テーブル名やカラムを定義すればいい。

orm/user.py:

from sqlalchemy import Column, Integer, String
from orm.base import Base

class User(Base):
    __tablename__ = 'user'

    id = Column(Integer, primary_key=True)
    name = Column(String)

このクラス定義は、Userクラスがuserというテーブルと関連付いていて、userテーブルはidnameカラムを持つ、という意味。 Userクラスをインスタンス化すると、そのプロパティとしてidnameにアクセスできて、それらへの変更はインスタンスによってトラッキングされ、userテーブルに反映するためのSQL文が自動で構築される、という寸法。 因みに__init__()メソッドは自動で作ってくれるので、自分で書かなくてもUser(id=1, name='kaitoy')みたいにインスタンス化できる。

userテーブルの情報(テーブルメタデータ)はUser.__table__にバインドされるTableオブジェクトに保持されて、そのTableオブジェクトはBase.metadataにバインドされるMetaDataに登録される。 このBase.metadataからDDL文を発行してRDBにテーブルを作ることができる。

orm/__init__.py:

from sqlalchemy import create_engine
from orm.base import Base

# UserクラスをBaseクラスに登録する
import orm.user

engine = create_engine("postgresql://admin:passwd@localhost/test_db")

# Baseクラスに登録されたmapped classのテーブルを作る
Base.metadata.create_all(engine)

Session

SQLAlchemy ORMではmapped classのインスタンスをいじることでSQL文が実行されるわけだけど、コネクションやトランザクションはどうなっているのかという話。

SQLAlchemy CoreではConnectionとかTransactionを明示的に作ってやっていたけど、SQLAlchemy ORMでは代わりにSessionというのを使う。 Sessionは任意の数のmapped classのインスタンスと紐づき、(大抵は)一つのTransactionを保持する。

session-overview.png

Sessionを使うには、前節のBaseと同様に、まずSessionクラスを作る。 このクラスも普通は1つのアプリで1つ作ればいい。 SessionクラスにはEngineインスタンスを持たせる。

orm/__init__.py:

 from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
 from orm.base import Base

 # UserクラスをBaseクラスに登録する
 import orm.user

 engine = create_engine("postgresql://admin:passwd@localhost/test_db")

 # Baseクラスに登録されたmapped classのテーブルを作る
 Base.metadata.create_all(engine)

+Session = sessionmaker(bind=engine)

Sessionクラスは普通にインスタンス化して使う。

main.py:

from orm import Session

session = Session()

Sessionオブジェクトを作っても、実際に使うまではRDBとのコネクションは張らない。

SessionによるINSERT

この記事の冒頭で、オブジェクトへの変更をSQLAlchemy ORMが透過的にRDBに反映してくれるというのを書いたけど、それが適用されるのはSessionオブジェクトに紐づけられた(i.e. アタッチされた)mapped classのオブジェクトだけ。

上記Userクラスのコンストラクタを呼べばmapped classのオブジェクトができるけど、その時点ではSessionオブジェクトにはアタッチされていない。 アタッチするにはadd()する。

main.py:

 from orm import Session
+from orm.user import User

 session = Session()
+user = User(id=1, name='kaitoy')
+session.add(user)

この時点でもまだコネクションは張られないし、RDBにレコードが追加されることもない。 userオブジェクトを永続化するには、flush()が必要。

main.py:

 from orm import Session
 from orm.user import User

 session = Session()
 user = User(id=1, name='kaitoy')
 session.add(user)
+session.flush()

flush()した時点でコネクションが(張られていなければ)張られて、ConnectionオブジェクトがSessionオブジェクトに紐づき、またトランザクションが(すでに開始していなければ)開始して、TransactionのオブジェクトがSessionオブジェクトに紐づく。 さらにSessionオブジェクトにアタッチされたオブジェクトの変更が計算されてRDBに反映される。 上記の例だと、新たなuserオブジェクトが追加されているので、RDBにはUserテーブルへのINSERTが発行される。

flush()しただけだとまだトランザクションが閉じてないので、コミットもしてやる必要がある。

main.py:

 from orm import Session
 from orm.user import User

 session = Session()
 user = User(id=1, name='kaitoy')
 session.add(user)
 session.flush()
+session.commit()

実際は、commit()するとflush()もされるので、flush()は省略できる。

main.py:

 from orm import Session
 from orm.user import User

 session = Session()
 user = User(id=1, name='kaitoy')
 session.add(user)
-session.flush()
 session.commit()

commit()するとトランザクションが閉じて、ConnectionがSessionオブジェクトからコネクションプールに返される。

SessionによるSELECT

INSERTしたレコードを取得するには、SessionオブジェクトからQueryオブジェクトを作って、クエリを記述して実行する。

main.py:

 from orm import Session
 from orm.user import User

 session = Session()
 user = User(id=1, name='kaitoy')
 session.add(user)
 session.commit()
+
+retrieved_user = session.query(User).filter_by(name='kaitoy').first()
+session.commit()

Queryを使ったクエリの書き方はここでは解説しないけど、SQLAlchemy Coreと違って、上記のようにメソッドチェインでオブジェクト志向な感じで書ける。 クエリを発行した時点、つまり上の例だとfirst()が実行された時点でSessionオブジェクトがConnectionをプールから取得し、トランザクションを開始するので、後でちゃんとSessionをcommit()してConnectionをプールに返してやるのが肝要。

クエリを発行するタイミングで内部でflush()も実行されるので、session.add(user)のあとのsession.commit()は無くてもいい。 (実際には後述のSessionのautoflush設定によって挙動が変わる。)

main.py:

 from orm import Session
 from orm.user import User

 session = Session()
 user = User(id=1, name='kaitoy')
 session.add(user)
-session.commit()

 retrieved_user = session.query(User).filter_by(name='kaitoy').first()
 session.commit()

最後のcommit()の後など、アタッチされたオブジェクトのプロパティにトランザクションの外でアクセスすると、RDBのレコードとの同期を取るために自動でトランザクションが開始されてSELECTが発行されるので注意が必要。 プロパティにアクセスしたら、トランザクションが不用に続かないように、Session.commit()してトランザクションを閉じて、コネクションをプールに返してやるべし。 Sessionのexpire_on_commitをFalseにすることで、このような挙動を避けることもできる。

因みに、取得したretrieved_userは、userと同じ参照になっていて、つまりretrieved_user is userTrueになる。 これはSessionオブジェクトがidentity mapというのを管理していて、RDBの一つのレコードに対しては、一つのPythonオブジェクトしか作らないようにしているため。

SessionによるUPDATE

アタッチされたオブジェクトのプロパティをいじってコミットすれば、レコードのUPDATEができる。

main.py:

 from orm import Session
 from orm.user import User

 session = Session()
 user = User(id=1, name='kaitoy')
 session.add(user)

 retrieved_user = session.query(User).filter_by(name='kaitoy').first()
+retrieved_user.name = 'hogehoge'
 session.commit()

SessionによるDELETE

アタッチされたオブジェクトをSession.delete()に渡すと、レコードのDELETEができる。

main.py:

 from orm import Session
 from orm.user import User

 session = Session()
 user = User(id=1, name='kaitoy')
 session.add(user)

 retrieved_user = session.query(User).filter_by(name='kaitoy').first()
 retrieved_user.name = 'hogehoge'
 session.commit()
+
+session.delete(retrieved_user)
+session.commit()

実際にRDBにDELETEが発行されるのはflush時なので、上記コードだと最後のsession.commit()のタイミング。

Session.delete()はメソッド名はSession.add()と対になってる風だけど、オブジェクトの状態を削除にマークするだけで、Sessionからオブジェクトをデタッチするわけではない。 (デタッチはSession.expunge()。)

Sessionのautocommit

これまでに説明したように、SQLAlchemy ORMではトランザクションは必要に応じて自動で開始してくれる。 SQLAlchemy Coreでは自分でConnection.begin()してトランザクションを明示的に開始しないといけないのとは対照的だ。

実は、歴史的事情でSessionにもbegin()メソッドがあって、役割もConnection.begin()と同じ。 現在ではSessionのbegin()はほぼautocommitモードでしか使わないものだけど、autocommitモードは非推奨になっててデフォルトでオフで、SQLAlchemy v2.0では廃止になるので忘れていい。

トランザクションの開始はSQLAlchemy ORMに任せて、Session.begin()も忘れていい。

Sessionのautoflush

autocommitと似てるような似てないようなautoflushというものがある。 これもSessionの機能の一つで、デフォルトではオンだけど、オフにすると、Queryオブジェクトでクエリを発行する際にSession.flush()が実行されなくなる。

それが何を意味するかというと、Sessionにadd()したオブジェクトとか、アタッチされたオブジェクトに加えた変更とかが、コミットするまではクエリ結果に反映されないということ。

main.py:

from orm import Session
from orm.user import User

session = Session(autoflush=False)  # autoflushをオフにしたSessionを作成。
user = User(id=1, name='kaitoy')
session.add(user)

# session.flush()しない。
# session.commit()もしない。

# クエリ発行時にはflush()は実行されない。
retrieved_user = session.query(User).filter_by(name='kaitoy').first()

これはautoflushをオフにしたSessionを使った例だけど、明示的にflush()を呼んでないのでretrieved_userはNoneになる。

大抵のユースケースではautoflushはオンのままでいいけど、パフォーマンスチューニングのためにオフにするケースがあるかもしれない。

scoped_session

実際にSessionを使ってアプリを書こうとすると、Sessionオブジェクトをいつ作っていつ破棄するのか(i.e. Sessionのスコープ)に迷うはず。

基本的な原則は、Sessionスコープの開始と終了は、そのSessionを使ってRDBアクセスする関数の外にするということ。 SQLAlchemy Coreの記事のトランザクション管理のところにも書いた話と似ていて、コードにすると以下のような感じ。

main.py:

from orm import Session
from orm.user import User

def delete_user(session, name):
  user = session.query(User).filter_by(name=name).first()
  session.delete(user)

def main():
  session = Session()
  try:
    delete_user(session, 'kaitoy')  # 外で作ったSessionオブジェクトを渡す。
    session.commit()  # SessionのコミットはSQL文を実行する関数の外でやる。
  except:
    session.rollback()
    raise
  finally:
    session.close()

Webアプリにおいては、Sessionのスコープはリクエストのスコープと同じにしておけば間違いない。 つまり、リクエストを受け付けたらSessionオブジェクトを作って、レスポンスを返す時に(必要に応じてコミットして)破棄すればいい。 このパターンを簡単に実現するために、よくscoped_sessionが使われる。 scoped_sessionを使うには、以下のようにSessionクラス作ればいいだけ。

orm/__init__.py:

 from sqlalchemy import create_engine
 from sqlalchemy.orm import sessionmaker
+from sqlalchemy.orm import scoped_session
 from orm.base import Base

 # UserクラスをBaseクラスに登録する
 import orm.user

 engine = create_engine("postgresql://admin:passwd@localhost/test_db")

 # Baseクラスに登録されたmapped classのテーブルを作る
 Base.metadata.create_all(engine)

-Session = sessionmaker(bind=engine)
+Session = scoped_session(sessionmaker(bind=engine))

このようにして作ったSessionクラスは、何度インスタンス化しても同一のオブジェクトが返る。 つまり、Session() is Session()Trueになる。 なのでmain.pyは以下のように書けるようになる。

main.py:

 from orm import Session
 from orm.user import User

-def delete_user(session, name):
+def delete_user(name):
+  session = Session()
   user = session.query(User).filter_by(name=name).first()
   session.delete(user)

 def main():
   session = Session()
   try:
-    delete_user(session, 'kaitoy')  # 外で作ったSessionオブジェクトを渡す。
+    delete_user('kaitoy')  # 外で作ったSessionオブジェクトを渡さなくてもいい。
     session.commit()  # SessionのコミットはSQL文を実行する関数の外でやる。
   except:
     session.rollback()
     raise
   finally:
     session.close()

正確に言うと、何度インスタンス化しても同一のオブジェクトが返るというのは、同一のスレッド内での話。 別のスレッドでは別のオブジェクトになるので、スレッド間でSessionオブジェクトが共有されることはない。 (Sessionはスレッドセーフではない。)

Sessionオブジェクトを作り直したい場合は、Session.remove()を実行すれば既存のやつをクローズして破棄できる。 Webアプリにおいては、レスポンスを返す時にSession.remove()しておけばいい。