Alembic は Python で書かれたデータベースマイグレーションツール。 アプリケーションを開発していると、データベースのスキーマ変更はちょくちょくある。 そうした場合、よくあるパターンでは更新内容を SQL のテキストファイルで管理したりする。 それに対し、Alembic を使うとスキーマ変更を Python のソースコードとして管理できる。 これには、スキーマ変更に伴うデータの移行方法をソースコードの中に書くことができたり、データベースの内容を特定のバージョンのスキーマに進めたり戻したりするのも容易になるなど、色々なメリットがある。
今回は、簡単なアプリケーションを題材として Alembic を使ったスキーマ変更の自動検出を試してみることにする。 尚、その場合前提として O/R マッパに SQLAlchemy を使うことになる。 Alembic は動作のバックエンドとして SQLAlchemy を採用している。
今回は環境に Mac OS X を使っている。 ただし、一部の操作を除いてプラットフォームに依存するような処理は特に想定していない。
$ sw_vers ProductName: Mac OS X ProductVersion: 10.10.5 BuildVersion: 14F27
Alembic をインストールする
まずは Alembic を Python のパッケージマネージャ pip でインストールする。
$ pip install alembic
SQLAlchemy でデータベーススキーマを作る
次に、Alembic で管理するデータベーススキーマを SQLAlchemy を使って作成しよう。 作成するモデルが銀行口座を模すので、パッケージの名前は bank にしよう。
$ mkdir -p bank $ touch bank/__init__.py
bank パッケージの中に、銀行口座を模したモデルの Account クラスを定義した db モジュールを用意する。 これが Alembic の管理対象になる。
$ cat << EOF > bank/db.py #!/usr/bin/env python # -*- coding: utf-8 -*- from sqlalchemy.ext.declarative.api import declarative_base from sqlalchemy.sql.schema import Column from sqlalchemy.sql.sqltypes import Integer from sqlalchemy.sql.sqltypes import Text Base = declarative_base() # 銀行口座を模したテーブル class Account(Base): __tablename__ = 'accounts' # 主キー id = Column(Integer, primary_key=True) # 口座の持ち主 name = Column(Text, nullable=False) # 残高 cash = Column(Integer, nullable=False) EOF
Alembic を初期設定する
パッケージができたところで、次は Alembic の初期設定に入ろう。
まずは、alembic init サブコマンドを使って作業用ディレクトリを作る。 これで Alembic の動作に必要な設定ファイルなどが一通り用意される。
$ alembic init alembic Creating directory /Users/amedama/Documents/temporary/alembic/alembic ... done Creating directory /Users/amedama/Documents/temporary/alembic/alembic/versions ... done Generating /Users/amedama/Documents/temporary/alembic/alembic.ini ... done Generating /Users/amedama/Documents/temporary/alembic/alembic/env.py ... done Generating /Users/amedama/Documents/temporary/alembic/alembic/README ... done Generating /Users/amedama/Documents/temporary/alembic/alembic/script.py.mako ... done Please edit configuration/connection/logging settings in '/Users/amedama/Documents/temporary/alembic/alembic.ini' before proceeding.
例えば alembic.ini ファイルの中には SQLAlchemy のデータベース接続先 URL が記述されている。 まずは、これを有効な接続先に書き換える必要がある。
$ grep sqlalchemy.url alembic.ini sqlalchemy.url = driver://user:pass@localhost/dbname
ここからは操作をコマンドで書き換えていくが、その前に sed コマンドを入れ替えたい。 Mac OS X の場合は sed の API が GNU sed と一部異なるため。 以下のようにして Homebrew 経由で gnu-sed をインストールした上で alias を張ると良い。 もちろん、使用する環境が巷の Linux ディストリビューションであれば以下の操作は必要ない。
$ brew install gnu-sed $ alias sed="gsed"
まずは SQLAlchemy のデータベース接続先 URL を書き換える。 今回はリレーショナルデータベースに SQLite3 を使うことにしよう。 SQLite3 は Python に組み込まれているため、特に何かをインストールすることなく使用できる。 SQLite3 ではデータベースを通常のファイルとして扱うことができる。 今回は /tmp/test.db に作成することにした。
$ sed -i -e ' s:^\(sqlalchemy.url = \).*$:\1sqlite\:////tmp/test.db:g ' alembic.ini
接続先 URL が以下のように更新されていることを確認する。
$ grep sqlalchemy.url alembic.ini
sqlalchemy.url = sqlite:////tmp/test.db
ちなみに、上記の設定ファイルには SQLAlchemy に関わる他の設定も記述できる。 例えば、sqlalchemy.echo = True とかすれば操作内容の SQL がすべて確認できるようになる。
次に、スキーマ変更を自動検出するために SQLAlchemy のモデルに付属するメタデータを設定ファイルで指定する。 具体的には env.py という Python ファイルの中の target_metadata という変数だ。
$ grep ^target_metadata alembic/env.py target_metadata = None
上記の Python ファイルの中で、bank パッケージの db モジュールを読み込んで、target_metadata に指定する。
$ sed -i -e ' 2i from bank import db s:^\(target_metadata = \)None:\1db.Base.metadata: ' alembic/env.py
ファイルが正しく書き換わっていることを確認しておこう。
$ grep ^target_metadata alembic/env.py
target_metadata = db.Base.metadata
$ head -n 3 alembic/env.py
from __future__ import with_statement
from bank import db
from alembic import context
現在 (初期状態) のスキーマを自動検出する
ここまでで Alembic の初期設定が完了した。 それでは alembic revision サブコマンドを使って現在のスキーマを自動検出してみよう。 ポイントは --autogenerate オプションを付ける点だ。 これで現在のデータベースの状態と、モデルの状態を見比べて、データベースに足りない箇所を含むマイグレーション用データを作成してくれる。
$ PYTHONPATH=. alembic revision --autogenerate -m "Initial" INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.autogenerate.compare] Detected added table 'accounts' Generating /Users/amedama/Documents/temporary/alembic/alembic/versions/139d74c4a 92_initial.py ... done
上手くいけば versions ディレクトリ以下に新しく Python ファイルができているはず。
$ ls alembic/versions | grep py$
139d74c4a92_initial.py
中身は以下のようになっている。 upgrade() がスキーマ状態を新しい状態に近づける際に呼び出される関数で、downgrade() はその反対になる。 upgrade() の内容をみると、accounts テーブルが作られて、そのなかに必要なカラムが用意されることがわかる。 また、downgrade() では反対に accounts テーブルを削除している。 これらは、前述した通り Alembic がデータベースの状態とモデルの状態を比較して自動検出したもの。 もちろん、もし内容に不備がある場合には自分で編集することができる。
$ cat alembic/versions/$(ls alembic/versions | grep py$) """Initial Revision ID: 139d74c4a92 Revises: Create Date: 2015-10-08 21:21:58.478086 """ # revision identifiers, used by Alembic. revision = '139d74c4a92' down_revision = None branch_labels = None depends_on = None from alembic import op import sqlalchemy as sa def upgrade(): ### commands auto generated by Alembic - please adjust! ### op.create_table('accounts', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.Text(), nullable=False), sa.Column('cash', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id') ) ### end Alembic commands ### def downgrade(): ### commands auto generated by Alembic - please adjust! ### op.drop_table('accounts') ### end Alembic commands ###
データベースを確認すると、Alembic がスキーマの状態を管理するためのテーブル alembic_version が新たに作られていることがわかる。
$ sqlite3 /tmp/test.db SQLite version 3.8.5 2014-08-15 22:37:57 Enter ".help" for usage hints. sqlite> .tables alembic_version sqlite> .schema alembic_version CREATE TABLE alembic_version ( version_num VARCHAR(32) NOT NULL ); sqlite> .headers ON sqlite> select * from alembic_version; sqlite> .quit
それでは、作成されたファイルを元に、スキーマを最新の状態 (head) にしてみよう。 alembic upgrade サブコマンドで head を指定する。
$ PYTHONPATH=. alembic upgrade head INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Running upgrade -> 139d74c4a92, Initial
すると、データベースに accounts テーブルが新たに作成されて、alembic_version テーブルの内容も更新されていることがわかる。
$ sqlite3 /tmp/test.db SQLite version 3.8.5 2014-08-15 22:37:57 Enter ".help" for usage hints. sqlite> .tables accounts alembic_version sqlite> .schema accounts CREATE TABLE accounts ( id INTEGER NOT NULL, name TEXT NOT NULL, cash INTEGER NOT NULL, PRIMARY KEY (id) ); sqlite> .headers ON sqlite> select * from alembic_version; version_num 139d74c4a92 sqlite> .quit
スキーマを変更する
それでは、続いてスキーマの変更に入ろう。 その前に、スキーマの変更がされたときに中のデータも一緒に移行されることを確認するために行をひとつ追加しておきたい。
行を追加するためのプログラムを用意する。
$ cat << EOF > bank/insert.py #!/usr/bin/env python # -*- coding: utf-8 -*- from sqlalchemy.engine import create_engine from sqlalchemy.orm.session import sessionmaker from bank import db def main(): # データベースと接続する engine = create_engine( 'sqlite:////tmp/test.db', echo=True, ) # テーブルを作成する db.Base.metadata.create_all(engine) # セッション作成用のオブジェクトを作る # autocommit: セッションの中で必要に応じて自動で commit する # expire_on_commit: commit 時にセッションに紐付いたオブジェクトを expire する SessionMaker = sessionmaker( bind=engine, autocommit=True, expire_on_commit=False, ) # セッションを作る session = SessionMaker() # 口座をひとつ追加しておく with session.begin(subtransactions=True): account = db.Account(name='example', cash=100) session.add(account) if __name__ == '__main__': main() EOF
上記を実行して行をひとつ追加しておく。
$ PYTHONPATH=. python bank/insert.py 2015-10-08 21:25:33,227 INFO sqlalchemy.engine.base.Engine SELECT CAST('test plain returns' AS VARCHAR(60)) AS anon_1 2015-10-08 21:25:33,228 INFO sqlalchemy.engine.base.Engine () 2015-10-08 21:25:33,228 INFO sqlalchemy.engine.base.Engine SELECT CAST('test unicode returns' AS VARCHAR(60)) AS anon_1 2015-10-08 21:25:33,228 INFO sqlalchemy.engine.base.Engine () 2015-10-08 21:25:33,229 INFO sqlalchemy.engine.base.Engine PRAGMA table_info("accounts") 2015-10-08 21:25:33,229 INFO sqlalchemy.engine.base.Engine () 2015-10-08 21:25:33,232 INFO sqlalchemy.engine.base.Engine BEGIN (implicit) 2015-10-08 21:25:33,233 INFO sqlalchemy.engine.base.Engine INSERT INTO accounts (name, cash) VALUES (?, ?) 2015-10-08 21:25:33,233 INFO sqlalchemy.engine.base.Engine ('example', 100) 2015-10-08 21:25:33,234 INFO sqlalchemy.engine.base.Engine COMMIT
それでは、次にスキーマ変更に移る。 変更内容は、version_id というカラムを accounts テーブルに追加するというもの。
$ cat << EOF > bank/db.py #!/usr/bin/env python # -*- coding: utf-8 -*- from sqlalchemy.ext.declarative.api import declarative_base from sqlalchemy.sql.schema import Column from sqlalchemy.sql.sqltypes import Integer from sqlalchemy.sql.sqltypes import Text Base = declarative_base() # 銀行口座を模したテーブル class Account(Base): __tablename__ = 'accounts' # 主キー id = Column(Integer, primary_key=True) # バージョン番号 version_id = Column(Integer, nullable=False, server_default='0') # 口座の持ち主 name = Column(Text, nullable=False) # 残高 cash = Column(Integer, nullable=False) __mapper_args__ = { 'version_id_col': version_id } EOF
上記で重要なポイントは、追加するカラムに server_default というパラメータを渡しているところかな。 これは、カラムの定義にデフォルト値 (DEFAULT) を設定するという意味になる。 SQLAlchemy には default というパラメータもカラムに指定できるけど、こちらは SQLAlchemy が Python のオブジェクトを作成する際に指定するデフォルト値になっている。 両者の違いはデフォルト値の指定が SQLAlchemy (Python オブジェクト) のレベルになるか、SQL (リレーショナルデータベース) のレベルになるかという点。 server_default に渡すのは文字列になっていて、これがそのまま DEFAULT に渡されることになる。 なので、例えば扱うのが日時であれば SQL の 'NOW()' とかも使える。
更に蛇足になるけど、上記は SQLAlchemy で楽観的排他制御を使う場合に使用する設定になっている。 SQLAlchemy の楽観的・悲観的排他制御については以下を参照のこと。
それでは、上記のスキーマ変更を Alembic に自動検出させよう。 先ほどと同様に alembic revision コマンドに --autogenerate オプションをつけて実行する。
$ PYTHONPATH=. alembic revision --autogenerate -m "Add version column" INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.autogenerate.compare] Detected added column 'accounts.version_id' Generating /Users/amedama/Documents/temporary/alembic/alembic/versions/2fcba655f f6_add_version_column.py ... done
これで新しくスキーマ変更用の Python ファイルができた。
$ ls alembic/versions | grep py$ 139d74c4a92_initial.py 2fcba655ff6_add_version_column.py $ cat alembic/versions/$(ls alembic/versions | grep add) """Add version column Revision ID: 2fcba655ff6 Revises: 139d74c4a92 Create Date: 2015-10-08 21:26:09.248255 """ # revision identifiers, used by Alembic. revision = '2fcba655ff6' down_revision = '139d74c4a92' branch_labels = None depends_on = None from alembic import op import sqlalchemy as sa def upgrade(): ### commands auto generated by Alembic - please adjust! ### op.add_column('accounts', sa.Column('version_id', sa.Integer(), server_default='0', nullable=False)) ### end Alembic commands ### def downgrade(): ### commands auto generated by Alembic - please adjust! ### op.drop_column('accounts', 'version_id') ### end Alembic commands ###
もう一度 alembic upgrade サブコマンドに head を指定してスキーマを最新の状態にしてみよう。
$ PYTHONPATH=. alembic upgrade head INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Running upgrade 139d74c4a92 -> 2fcba655ff6, Add version column
データベースの内容を確認すると version_id カラムが accounts テーブルに追加されていることがわかる。 また、既存の行もデフォルト値を使って移行されている。
$ sqlite3 /tmp/test.db SQLite version 3.8.5 2014-08-15 22:37:57 Enter ".help" for usage hints. sqlite> .schema accounts CREATE TABLE accounts ( id INTEGER NOT NULL, name TEXT NOT NULL, cash INTEGER NOT NULL, version_id INTEGER DEFAULT '0' NOT NULL, PRIMARY KEY (id) ); sqlite> .headers ON sqlite> select * from accounts; id|name|cash|version_id 1|example|100|0 sqlite> select * from alembic_version; version_num 2fcba655ff6 sqlite> .quit
補足: SQLite3 では Alembic がカラムの削除を上手く処理できない
先ほどの状態から、ふと気になって alembic downgrade サブコマンドでスキーマをひとつ前の状態に戻そうとしたところ、以下のようなエラーになってしまった。
$ PYTHONPATH=. alembic downgrade -1 ... sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) near "DROP": syntax error [SQL: 'ALTER TABLE accounts DROP COLUMN version_id']
原因を調べたところ、どうやら SQLite3 は ALTER TABLE ... DROP COLUMN という形式の SQL をサポートしていないようだ。
SQL As Understood By SQLite - ALTER TABLE
SQLite3 でカラムを削除したいときには以下の手順を踏む必要があるらしい。
- まず既存のテーブルから削除したいカラムだけがないテーブルを別の名前で新たに作る
- 既存のテーブルから新しく作ったテーブルにデータを移行する
- 既存のテーブルを削除する
- 新しく作ったテーブルの名前を削除した既存のテーブルの名前のそれに変更する
つまり、Alembic と SQLite3 を組み合わせて使う場合には、カラムの追加を自動検出させた後に必ず手動で downgrade() 関数を編集してやらなきゃいけない。 あきらかにめんどくさい。
Alembic がスキーマ変更で自動検出できるもの・できないもの
もちろん Alembic のスキーマ変更の自動検出も完璧ではない。 検出できるものとできないものがあって、それについては以下のドキュメントにまとめられている。
その内容をざっくりと以下に示す。
検出できる
- テーブルの追加・削除
- カラムの追加・削除。
- カラムの nullable 状態の変更。
- インデックスおよび明示的な名前付きユニーク制約の基本的な変更 (バージョン 0.6.1 以降)
- 外部キー制約の基本的な変更 (バージョン 0.7.1 以降)
検出できる (オプション扱い)
- カラムの種類の変更
- EnvironmentContext.configure.compare_type を True にすることで有効になる
- サーバデフォルトの変更
- EnvironmentContext.configure.compare_server_default を True にすることで有効になる
- 単純なケースでは上手く動作するものの、常に正しい結果を出力できるとは限らないらしい
- Postgresql をバックエンドに使う場合は実際に同じ値になっているかを確認するために "detect" と "metadata" を呼ぶらしい
検出できない
テーブル名の変更
- これは既存のテーブルの削除と新しいテーブルの追加として表されることになる
- 名前の変更になるように手動で編集が必要になる
カラム名の変更
- テーブル名の変更と同じ
名前の付いていないユニーク制約
- 制約には UniqueConstraint('col1', 'col2', name="my_name") のようにして名前を付けておく必要がある
データベースが直接サポートしていない特殊な SQLAlchemy の型を使う場合
- 例えば Enum なんかがそれに当たるらしい
- これは、どういう風に SQLAlchemy がスキーマを生成するのか Alembic からは分からないため
将来的に検出できるようになる予定
独立した制約の追加・削除
- CHECK とか PRIMARY KEY あたりが該当する
- 一部しか実装されていないらしい
シーケンスの追加・削除
- まだ実装できていないらしい
まとめ
今回は Alembic を使ってスキーマの変更を自動検出する方法について書いた。 アプリケーションのスキーマ変更をどのように管理していくかは悩ましい問題だけど、Alembic を使うことで変更がソースコード化されて扱いやすくできる。 また、Alembic を使えばモデルとデータベースの状態からスキーマの変更を自動的に検出できるので、より手間を減らすことができるはず。
スマートPythonプログラミング: Pythonのより良い書き方を学ぶ
- 作者: もみじあめ
- 発売日: 2016/03/12
- メディア: Kindle版
- この商品を含むブログを見る