CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Alembic でデータベースのスキーマ変更を自動検出する

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 の楽観的・悲観的排他制御については以下を参照のこと。

blog.amedama.jp

それでは、上記のスキーマ変更を 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 でカラムを削除したいときには以下の手順を踏む必要があるらしい。

  1. まず既存のテーブルから削除したいカラムだけがないテーブルを別の名前で新たに作る
  2. 既存のテーブルから新しく作ったテーブルにデータを移行する
  3. 既存のテーブルを削除する
  4. 新しく作ったテーブルの名前を削除した既存のテーブルの名前のそれに変更する

つまり、Alembic と SQLite3 を組み合わせて使う場合には、カラムの追加を自動検出させた後に必ず手動で downgrade() 関数を編集してやらなきゃいけない。 あきらかにめんどくさい。

Alembic がスキーマ変更で自動検出できるもの・できないもの

もちろん Alembic のスキーマ変更の自動検出も完璧ではない。 検出できるものとできないものがあって、それについては以下のドキュメントにまとめられている。

http://alembic.readthedocs.org/en/latest/autogenerate.html#what-does-autogenerate-detect-and-what-does-it-not-detect

その内容をざっくりと以下に示す。

検出できる

  • テーブルの追加・削除
  • カラムの追加・削除。
  • カラムの 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 を使えばモデルとデータベースの状態からスキーマの変更を自動的に検出できるので、より手間を減らすことができるはず。