🗄️

SQLAlchemyとAlembicによるマイグレーションの使い方(備忘録)

2024/08/11に公開

SQLAlchemyでモデル定義を行い、Alembicでマイグレーションを行うやり方のメモ。ちなみにモジュールのインストールとバージョン管理はPoetryを使ってます。

Alembicのインストール

Poetryでツールをインストールする。

> poetry add --dev alembic
> alembic --version
alembic 1.13.2

Alembicの初期設定

Poetryでプロジェクトを作成した状態からスタートする場合、まずAlembicの初期設定でマイグレーションする環境を整える。

とりあえずこんなプロジェクト構成として話を進める。modelsディレクトリ配下にモデルクラスのソースファイルを管理することを前提に話を進める。

project
├ models
│ ├ __init__.py
│ ├ base_models.py
│ └ samples.py
└ pyproject.toml

projectフォルダ直下で、alembic initの引数にフォルダ名を指定して実行する。プロジェクトで1回だけやればOK。migrationsディレクトリが作成されて配下に、マイグレーションに必要なプログラムやディレクトリが作成されて、カレントディレクトリにalembic.iniが作成される。

以降のalembicコマンドはこのalembic.iniがあるディレクトリで実行する。

alembic init migrations
project
├ migrations
│ ├ versions
│ ├ env.py
│ └ script.py.mako
├ models
│ ├ __init__.py
│ ├ base_models.py
│ └ samples.py
├ alembic.ini
└ pyproject.toml

project/alembic.iniを開いて、稼働中のDBの接続先を指定する。
(DBユーザやパスワードなどは自身の環境に合わせる)

# api/alembic.ini
sqlalchemy.url = mysql+pymysql://user:password@host:port/database

migrations/env.pyを開いて、アプリケーションのモデルクラスの Base.metada を設定。

# env.py
from models.base_models import Base
target_metadata = Base.metadata

migrations/env.pyからモデルクラスを自動で検出できるように、models/init.pyに配下のクラスのインポート定義を追加しておく。今後モデルを定義するPythonファイルを増やす場合も、都度追加しておくとよい。

# models/__init__.py
from .base_models import *
from .sample import *

とりあえず、最低限の設定はこれでOK。

テーブルモデルを定義する

project/models/samples.pyにテーブルモデルを定義していく。機能ごとにモデルファイルを分けることももちろん可能で、都度models/init.pyにインポートを記述しておくこと。

今回はとりあえずサンプルのモデルクラス定義してマイグレートしてみる。

  • Sample1, Sample2テーブルを作成する
  • Sample2とSample1は、N:1のリレーション関係になっている
  • Sample1, Sample2はcreated_at, updated_atというタイムスタンプカラムを持っている。
# models/sample.py
from sqlalchemy import Column, String, Integer, DateTime, JSON, text, Index, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from sqlalchemy.ext.declarative import declared_attr
from datetime import datetime
from models.base_models import Base

class TimestampMixin(object):
  @declared_attr
  def created_at(cls):
    return Column(DateTime, default=datetime.now(), nullable=False)
  @declared_attr
  def updated_at(cls):
    return Column(DateTime, default=datetime.now(), onupdate=datetime.now(), nullable=False)
  
class Sample1(Base, TimestampMixin):
  __tablename__ = "samples"
  id = Column(Integer, primary_key=True, index=True)
  name = Column(String(50), unique=True)
  # relationshipには自動JOIN、Cascade設定などあるが、基本ON。
  # したくなければ、そもそもrelationship()定義しなくていい
  posts = relationship("Sample2",back_populates="owner")
  
  # Index定義 Column(xxx, index=True)でも単体インデックスが指定できる
  # こちらの定義の場合は、複数組み合わせのIndex定義ができる
  __table_args__ = (
    Index('ix_sample1_name', 'name'),
  )

class Sample2(Base, TimestampMixin):
  __tablename__ = "samples2"
  id = Column(Integer, primary_key=True, index=True)
  content = Column(String(1000), nullable=True) 
  # ondelete=で親が削除されたら自動的に削除, ondelete="SET NULL"もある
  sample1_id = Column(Integer, ForeignKey('samples.id', ondelete="CASCADE"))

マイグレーションファイルの生成

以下のように--autogenerateオプションを付けて実行すると、DBとの差分をみて生成する。-m v0.01はコメントラベルのようなもので、マイグレーションファイル名603b1576a020_v0_01.pyの一部で利用される。migrations/versionsフォルダの下に作成される。

> alembic revision --autogenerate -m v0.01
INFO  [alembic.runtime.migration] Context impl MySQLImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'samples'
INFO  [alembic.autogenerate.compare] Detected added index ''ix_sample1_name'' on '('name',)'
INFO  [alembic.autogenerate.compare] Detected added index ''ix_samples_id'' on '('id',)'
INFO  [alembic.autogenerate.compare] Detected added table 'samples2'
INFO  [alembic.autogenerate.compare] Detected added index ''ix_samples2_id'' on '('id',)'
  Generating /../project/migrations/versions/603b1576a020_v0_01.py ...  done

生成されたマイグレーションファイルの中身。

**upgrade()downgrade()**にテーブル、カラムの作成や削除が定義される。ここに何も出力されない場合、モデルクラスをAlembicが検知できてないので注意する。models/init.pyにインポートを追加すれば検知されるはず。

#603b1576a020_v0_01.py
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '603b1576a020'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('samples',
    sa.Column('id', sa.Integer(), nullable=False),
    sa.Column('name', sa.String(length=50), nullable=True),
    sa.Column('created_at', sa.DateTime(), nullable=False),
    sa.Column('updated_at', sa.DateTime(), nullable=False),
    sa.PrimaryKeyConstraint('id'),
    sa.UniqueConstraint('name')
    )
    op.create_index('ix_sample1_name', 'samples', ['name'], unique=False)
    op.create_index(op.f('ix_samples_id'), 'samples', ['id'], unique=False)
    op.create_table('samples2',
    sa.Column('id', sa.Integer(), nullable=False),
    sa.Column('content', sa.String(length=1000), nullable=True),
    sa.Column('sample1_id', sa.Integer(), nullable=True),
    sa.Column('created_at', sa.DateTime(), nullable=False),
    sa.Column('updated_at', sa.DateTime(), nullable=False),
    sa.ForeignKeyConstraint(['sample1_id'], ['samples.id'], ondelete='CASCADE'),
    sa.PrimaryKeyConstraint('id')
    )
    op.create_index(op.f('ix_samples2_id'), 'samples2', ['id'], unique=False)
    # ### end Alembic commands ###

def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_index(op.f('ix_samples2_id'), table_name='samples2')
    op.drop_table('samples2')
    op.drop_index(op.f('ix_samples_id'), table_name='samples')
    op.drop_index('ix_sample1_name', table_name='samples')
    op.drop_table('samples')
    # ### end Alembic commands ###

Alembicでマイグレーションファイルの内容をDBに反映する

alembic upgrade headで最新の状態までマイグレーションファイルの内容を反映する。

> 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  -> 603b1576a020, v0.01

ちゃんと反映されているか、実際にDBを確認する。

alembicには、他にも現在のバージョン(alembic current)、履歴(alembic history)を確認できるコマンドがある。

ちなみにbaseというのがマイグレーション管理上の起点で、init直後の場所だと思えばよい。下記の場合だと、baseから603b1576a020に更新されておりこれがheadになっている。gitのリポジトリのリビジョン管理をイメージして良いと思う。

> alembic current
603b1576a020 (head)

>  alembic history
<base> -> 603b1576a020 (head), v0.01

モデルのダウングレード

ダウングレードの例について。下記の用にbase => v0.01 => v0.02にDBが更新(カラムを追加)されているとする。v0.02でDBを更新したものの、ミスがありやり直したいというシナリオを考える。
(gitの場合だと、git resetのような感じ)

> alembic history

alembic history
603b1576a020 -> 159a664aaafb (head), v0.02
<base> -> 603b1576a020, v0.01

v0.02から1つ前のバージョンにダウングレードには、alembic downgradeコマンドを使う。

引数に1つ前を意味する-1か、リビジョンIDを指定して実行すると、DBに即座に反映される。

> alembic downgrade -1
または
> alembic downgrade 603b1576a020

DBを見るとカラム追加が取り消されている。

alembic historyコマンドで履歴を確認すると、履歴そのものは残っていて、現在のカレントがv0.01になっている。

履歴はローカルのmigrations/versions配下のファイルで管理されており、ダウングレードしてもversions以下の差分ファイルが削除されるわけではない。

> alembic history
603b1576a020 -> 159a664aaafb (head), v0.02
<base> -> 603b1576a020, v0.01

> alembic current
603b1576a020

なので、ダウングレードしたらversions配下の対象ファイルも削除しておく必要があるし、逆に履歴管理されているファイルを削除してしまうと辻褄が合わなくなるので注意する。
これは、Djangoのmigrationコマンドも同等である。

リビジョンをv0.001にダウングレードしたので、159a664aaafbに該当するファイルをversionsフォルダから削除する。再度historyを見ると、ファイルが削除されて603b1576a020が履歴の先頭になるので以下の結果になる。

> alembic history
<base> -> 603b1576a020 (head), v0.01

> alembic current
603b1576a020

その他のコマンドメモ

特定のバージョンにダウングレードする

> alembic downgrade <version_id>

全てのマイグレーションを取り消す。DBがテーブル追加前の状態になっている。

> alembic downgrade base

Discussion