CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Alembic をプロジェクトの途中から導入する

今回は Python のデータベースマイグレーションツールの Alembic について。 Alembic を使うとデータベースのスキーマをマイグレーションスクリプトにもとづいて管理できる。 マイグレーションスクリプトというのは、スキーマのバージョンを現在の状態から進める・戻すのに必要な手順が書かれたスクリプトのこと。 このブログでも、以前に Alembic でマイグレーションスクリプトを自動生成するための方法について書いたことがある。

blog.amedama.jp

そして、今回はアプリケーションの実運用が始まってしまった後からスキーマの管理を Alembic に移行するための手順を書いてみる。 スケジュールが厳しいプロジェクトなんかだと、リリースまでにマイグレーションまで手が回らないなんてこともまあ考えられる。

今回使った環境は次の通り。 RDBMS には MySQL 5.7 を使った。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1
$ mysql --version
mysql  Ver 14.14 Distrib 5.7.13, for osx10.11 (x86_64) using  EditLine wrapper

下準備

まずは Homebrew で MySQL をインストールしておく。

$ brew install mysql

そして MySQL サーバを起動しよう。

$ mysql.server start

次に Python の O/R マッパーの SQLAlchemy と MySQL ドライバの mysqlclient をインストールしておく。

$ pip install sqlalchemy mysqlclient
$ pip list | egrep -i "(sqlalchemy|mysqlclient)"
mysqlclient (1.3.7)
SQLAlchemy (1.0.13)

今回、動作確認に使うためのデータベースを用意する。

$ mysql -u root -e "CREATE DATABASE IF NOT EXISTS migration CHARACTER SET utf8mb4"

「migration」という名前のデータベースだ。

$ mysql -u root -e "SHOW CREATE DATABASE migration"
+----------+--------------------------------------------------------------------+
| Database | Create Database                                                    |
+----------+--------------------------------------------------------------------+
| migration   | CREATE DATABASE `migration` /*!40100 DEFAULT CHARACTER SET utf8mb4 */ |
+----------+--------------------------------------------------------------------+

これで RDBMS に関しては準備ができた

テーブルとレコードを用意する

次に Alembic が導入されていない頃のプロジェクトを想定した状況を作る。 最初に SQLAlchemy でテーブル定義に対応するモデルを用意しよう。 このモデルは、ユーザを管理するための users というテーブルをひとつ持っている。

$ cat << 'EOF' > model.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 BigInteger
from sqlalchemy.sql.sqltypes import Text


Base = declarative_base()


class User(Base):
    __tablename__ = 'users'

    # 主キー
    id = Column(BigInteger, primary_key=True)
    # 名前
    name = Column(Text, nullable=False)
EOF

次に、上記のモデルを使って実際にデータベースにテーブルとレコードを追加するスクリプトを用意する。

$ cat << 'EOF' > insert.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sqlalchemy.engine import create_engine
from sqlalchemy.orm.session import sessionmaker

from model import Base
from model import User


def main():
    # エンジンを作る
    engine = create_engine(
        'mysql+mysqldb://root@localhost/migration?charset=utf8mb4',
        echo=True,
    )

    # テーブルを作る
    Base.metadata.create_all(engine)

    # セッション作成用のオブジェクトを作る
    SessionMaker = sessionmaker(
        bind=engine,
        autocommit=True,
        expire_on_commit=False,
    )

    # セッションを作る
    session = SessionMaker()
    # 最初に行をひとつ追加しておく
    with session.begin(subtransactions=True):
        user = User(name='foo')
        session.add(user)


if __name__ == '__main__':
    main()
EOF

上記のスクリプトを実行しよう。

$ python insert.py
2016-06-11 12:46:19,645 INFO sqlalchemy.engine.base.Engine SHOW VARIABLES LIKE 'sql_mode'
2016-06-11 12:46:19,646 INFO sqlalchemy.engine.base.Engine ()
2016-06-11 12:46:19,649 INFO sqlalchemy.engine.base.Engine SELECT DATABASE()
...(省略)...
2016-06-11 12:46:19,721 INFO sqlalchemy.engine.base.Engine INSERT INTO users (name) VALUES (%s)
2016-06-11 12:46:19,721 INFO sqlalchemy.engine.base.Engine ('foo',)
2016-06-11 12:46:19,726 INFO sqlalchemy.engine.base.Engine COMMIT

これで Alembic を導入する前のデータベースの状態が用意できた。

$ mysql -u root -D migration -e "DESC users"
+-------+------------+------+-----+---------+----------------+
| Field | Type       | Null | Key | Default | Extra          |
+-------+------------+------+-----+---------+----------------+
| id    | bigint(20) | NO   | PRI | NULL    | auto_increment |
| name  | text       | NO   |     | NULL    |                |
+-------+------------+------+-----+---------+----------------+
$ mysql -u root -D migration -e "SELECT * FROM users\G" 
*************************** 1. row ***************************
  id: 1
name: foo

Alembic を導入する

さて、ここからは Alembic を導入するフェーズに入る。

$ pip install alembic
$ pip list | grep -i alembic
alembic (0.8.6)

ここからの作業は、先ほど用意した Python スクリプトと同じ場所で進める。

$ ls
__pycache__ insert.py   model.py

まずは alembic コマンドを使って必要なファイルセットを一式用意しよう。

$ alembic init alembic

これでディレクトリには alembic.ini というファイルと alembic というディレクトリができるはず。

$ ls
__pycache__ alembic     alembic.ini insert.py   model.py

ここからは Alembic の設定ファイルを編集していくんだけど、その前に GNU sed が入っていなければインストールしておく。 Mac の sed はオプションが GNU 版と違うので。

$ brew install gnu-sed
$ alias sed='gsed'

まずは alembic.ini の中にある sqlalchemy.url という項目を編集する。 必要に応じて接続用 URL のアカウントなどは適宜変更する。

$ sed -i -e 's!^sqlalchemy\.url = .*$!sqlalchemy.url = mysql+mysqldb://root@localhost/migration?charset=utf8mb4!' alembic.ini
$ grep 'sqlalchemy\.url' alembic.ini
sqlalchemy.url = mysql+mysqldb://root@localhost/migration?charset=utf8mb4

次に Alembic に管理対象のモデルを教えてやる。 これには alembic/env.py の target_metadata にモデルが継承しているオブジェクトの metadata メンバを指定する。

$ sed -i -e '
  2i import model
  s:^\(target_metadata = \)None:\1model.Base.metadata:
' alembic/env.py
$ head -n 3 alembic/env.py
from __future__ import with_statement
import db
from alembic import context
$ grep ^target_metadata alembic/env.py
target_metadata = model.Base.metadata

これで Alembic の下準備ができた。

マイグレーションスクリプトを用意する

次は Alembic のマイグレーションスクリプトを用意する。 これはデータベースに何もないまっさらな状態から users テーブルがある状態にするためのスクリプト。

最初のリビジョンのマイグレーションスクリプトを生成する。

$ PYTHONPATH=. alembic revision -m "Initial"

これで、なんか適当なリビジョンが振られたスクリプトができる。

$ ls alembic/versions 
6b7569a12df9_initial.py __pycache__

生成されたスクリプトにマイグレーションの内容を記述する。

$ cat alembic/versions/6b7569a12df9_initial.py 
"""Initial

Revision ID: 6b7569a12df9
Revises: 
Create Date: 2016-06-11 13:39:23.954411

"""

# revision identifiers, used by Alembic.
revision = '6b7569a12df9'
down_revision = None
branch_labels = None
depends_on = None

from alembic import op
import sqlalchemy as sa


def upgrade():
    op.create_table('users',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('name', sa.Text(), nullable=False),
        sa.PrimaryKeyConstraint('id')
    )



def downgrade():
    op.drop_table('accounts')

ちなみに、マイグレーションスクリプトの内容はある程度まで Alembic で自動化で生成することもできる。 ただし、今回は既にデータベースの状態とモデルの状態が一致してしまっている。 そのため自動で生成するときはまっさらなデータベースを別に用意する必要がある。

blog.amedama.jp

スキーマの管理を Alembic に移行する

さて、ここまでで全ての下準備が整った。 いよいよデータベースのスキーマを Alembic に移行してみよう。

Alembic では、今のスキーマの状態を alembic_version というテーブルで管理している。 もちろん、現状ではスキーマを Alembic で管理していないので、このテーブルがない。 そこで、プロジェクトの途中から管理を Alembic に移すときは手動でこのテーブルを作ってやれば良い。

まずは、次のようにしてテーブルとレコードを追加する。 レコードに入れるリビジョン番号は、今のデータベースの状態が Alembic のマイグレーションスクリプトをどこまで適用したかを示している。 今のデータベースの状態は、先ほど用意したマイグレーションスクリプトが既に適用された状態と捉えることができる。 要するに、ここには先ほど用意したマイグレーションスクリプトのリビジョンを指定すれば良い。

$ mysql -u root -D migration -e "CREATE TABLE alembic_version(version_num varchar(32) NOT NULL)"
$ mysql -u root -D migration -e "INSERT INTO alembic_version(version_num) values ('6b7569a12df9')"

レコードを追加できたら alembic コマンドで upgrade head サブコマンドを実行してみよう。 これは Alembic のマイグレーションスクリプトを最新の状態までデータベースに適用することを意味している。

$ PYTHONPATH=. alembic upgrade head
INFO  [alembic.runtime.migration] Context impl MySQLImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.

ここで上記のように、特に何も実行されなければ上手くいっている。

ちなみに、間違えてレコードを追加する前に上記のコマンドを実行してしまっても問題はない。 まずは、何やら例外が出て焦るかもしれない。

$ PYTHONPATH=. alembic upgrade head
...(省略)...
sqlalchemy.exc.OperationalError: (_mysql_exceptions.OperationalError) (1050, "Table 'users' already exists") [SQL: '\nCREATE TABLE users (\n\tid INTEGER NOT NULL AUTO_INCREMENT, \n\tname TEXT NOT NULL, \n\tPRIMARY KEY (id)\n)\n\n']

上記は alembic_version テーブルがないことで Alembic がデータベースがまっさらな状態と考えてテーブルを作ろうとしているために発生したエラーだ。

alembic_version テーブル自体は上記のコマンドで作られる。

$ mysql -u root -D migration -e "CREATE TABLE alembic_version(version_num varchar(32) NOT NULL)"
ERROR 1050 (42S01) at line 1: Table 'alembic_version' already exists

つまり、落ち着いて現在のリビジョンをレコードに追加することで Alembic に今データベースがどの状態にあるかを教えてやれば良い。

$ mysql -u root -D migration -e "INSERT INTO alembic_version(version_num) values ('6b7569a12df9')"

スキーマを更新してみる

Alembic に管理が移行できたところで、試しにスキーマを更新してみよう。

例えば users テーブルに年齢 (age) を入れるカラムを追加してみよう。 既存のユーザがマイグレーションするときは null を入れることにする。

$ cat << 'EOF' > model.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 BigInteger
from sqlalchemy.sql.sqltypes import Integer
from sqlalchemy.sql.sqltypes import Text


Base = declarative_base()


class User(Base):
    __tablename__ = 'users'

    # 主キー
    id = Column(BigInteger, primary_key=True)
    # 名前
    name = Column(Text, nullable=False)
    # 年齢
    age = Column(Integer, nullable=True)
EOF

今度は Alembic のスキーマの自動検出機能を使ってみよう。

$ PYTHONPATH=. alembic revision --autogenerate -m "Add age column"

するとマイグレーションスクリプトが追加されたことがわかる。

$ ls alembic/versions 
6b7569a12df9_initial.py        e64c12b8698d_add_age_column.py
__pycache__

内容を確認すると users テーブルに age カラムを追加・削除するスクリプトが生成されている。

$ cat alembic/versions/e64c12b8698d_add_age_column.py 
"""Add age column

Revision ID: e64c12b8698d
Revises: 6b7569a12df9
Create Date: 2016-06-11 14:20:27.826977

"""

# revision identifiers, used by Alembic.
revision = 'e64c12b8698d'
down_revision = '6b7569a12df9'
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('users', sa.Column('age', sa.Integer(), nullable=True))
    ### end Alembic commands ###


def downgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.drop_column('users', 'age')
    ### end Alembic commands ###

早速、このスクリプトを使ってデータベースを更新してみよう。

$ PYTHONPATH=. alembic upgrade head
INFO  [alembic.runtime.migration] Context impl MySQLImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 6b7569a12df9 -> e64c12b8698d, Add age column

すると確かにデータベースのスキーマが更新されている。

$ mysql -u root -D migration -e "DESC users"
+-------+------------+------+-----+---------+----------------+
| Field | Type       | Null | Key | Default | Extra          |
+-------+------------+------+-----+---------+----------------+
| id    | bigint(20) | NO   | PRI | NULL    | auto_increment |
| name  | text       | NO   |     | NULL    |                |
| age   | int(11)    | YES  |     | NULL    |                |
+-------+------------+------+-----+---------+----------------+

既存のユーザについては age に null が入った。

$ mysql -u root -D migration -e "SELECT * FROM users"
+----+------+------+
| id | name | age  |
+----+------+------+
|  1 | foo  | NULL |
+----+------+------+

もし、マイグレーションしたときに特定の値を入れなおすみたいな処理がしたければマイグレーションスクリプトにそれを記述すれば良い。 Alembic のマイグレーションスクリプトはあくまで、ただの Python モジュールに過ぎない。 だから、どんな処理を書いても構わない。

まとめ

  • プロジェクトの途中からでも Alembic を導入してスキーマ管理を移行できる
  • それには alembic_version というテーブルのレコードを手作業で用意すれば良い