[Python] Alembicで複数に分割されたデータモデルの自動マイグレーションを実現する方法

# はじめに

大規模なWebアプリケーションやサービスの開発において、データモデルの数やその複雑さが増すにつれて、全てのモデルを一つのmodels.pyに収めるのは非効率的になることがしばしばです。そのため、データモデルを複数のファイルに分割し、管理をしやすくすることが考えられます。しかし、このようにモデルを分割してしまうと、従来のマイグレーションツールや手法がうまく機能しなくなる場合があります。

Alembicは、SQLAlchemyを用いたプロジェクトでのデータベースマイグレーションをサポートするツールとして、多くの開発者に支持されています。しかし、デフォルトの設定では、複数のファイルに分割されたモデルに対するマイグレーションをスムーズに行うのは難しいです。

この記事では、Alembicを用いて、複数のファイルに分割されたデータモデルに対するマイグレーションを、手間をかけずに自動で行う方法を解説します。これにより、データモデルの構造や数が増えても、一貫した方法でマイグレーションを行い、開発効率を維持することが可能となります。

# データモデルの分割とその必要性

Webアプリケーションやシステムの開発が進むにつれて、データモデルの数やその複雑さは増加の一途を辿ることが多いです。初期の段階ではシンプルなデータモデルからスタートするかもしれませんが、機能追加や要件変更とともに新しいモデルの追加や既存のモデルの修正が繰り返されます。

この増大するモデルの数や複雑さを一つのmodels.pyで管理し続けるのは、以下のような問題が考えられます。

1. **可読性の低下**  
   一つのファイルに多くのモデルが定義されると、特定のモデルを探したり、関連するモデル間の関係を理解するのが難しくなります。
2. **メンテナンスの困難**  
   一つのファイルに変更が集中すると、そのファイルへの変更の度にコンフリクトが発生するリスクが高まります。
3. **モジュールの再利用性の低下**  
   特定のモデルだけを再利用したい場合に、不要なモデルも一緒にインポートされることがあります。

これらの問題を解消するために、models.pyを複数のファイルに分割し、それぞれのファイルに特定の機能や領域に関連するモデルのみを定義する方法が推奨されます。これにより、各ファイルが短く、特定の目的に特化した形になるため、上記の問題点を大幅に軽減することができます。

# 前提となる環境の構築

今回の記事での手順やコードの実行には、特定の環境やツールが必要となります。
具体的には以下の通りです。

## 使用する技術スタック

- データベース：PostgreSQL 16
- 言語：Python = "3.11"
- ライブラリ/ツール：
    - SQLAlchemy：2.0.21
        - ORMツールで、Pythonでのデータベース操作を抽象化・簡易化
    - psycopg2：2.9.9
        - PostgreSQLとPythonを接続するためのドライバ
    - alembic：1.12.0
        - データベースのマイグレーションツール

## 必要なライブラリのインストール

```sh
pip install SQLAlchemy psycopg2 alembic
```

# SQLAlchemyのデータモデルの定義

## SQLAlchemyとは？

SQLAlchemyは、Pythonプログラミング言語向けのSQLツールキットおよびObject-Relational Mapping (ORM)ライブラリです。このライブラリを使用すると、SQLデータベースとのやりとりを高水準のAPIを通して実行できるため、SQLクエリを直接書くことなくデータベース操作を行うことができます。

特に、SQLAlchemyのORM機能は、データベースのテーブルとPythonのクラスを関連付けることで、データベース操作をより直感的に、Pythonicに行えるのが特徴です。これにより、データモデルの定義やクエリの作成がシンプルかつ効率的になります。

## データモデルの定義

SQLAlchemyを用いて、データベースのテーブルと対応するPythonクラスを定義する方法について解説します。
今回は、CustomerとBookという2つのテーブルを例に、モデルの定義を行います。
このモデルを利用して、Alembicを使用したマイグレーションを進めていきます。

### ディレクトリ構造

```sh
 models
 ├── __init__.py
 ├── base.py
 ├── book.py
 └── customer.py
```

### Baseモデルの定義

base.pyでは、SQLAlchemyの宣言的マッピング用の基底クラスを定義しています。
このBaseクラスは他のモデルで継承され、共通の属性やメソッドを提供します。

```python: base.py
"""宣言的マッピング用."""

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    """宣言的マッピングの基底クラス."""

    id: Mapped[int] = mapped_column(primary_key = True, comment = "ID")

```

### その他のモデル定義

customer.pyとbook.pyで、それぞれのテーブルに対応するモデルを定義します。

```python:customer.py
"""CustomerテーブルのORM."""

from sqlalchemy.orm import Mapped, mapped_column

from models.base import Base


class Customer(Base):
    """顧客テーブルのORM."""
    __tablename__ = "customer"

    name: Mapped[str] = mapped_column(comment="顧客の名前")

```

```python:book.py
"""BookテーブルのORM."""

from sqlalchemy.orm import Mapped, mapped_column

from models.base import Base


class Book(Base):
    """本テーブルのORM."""
    __tablename__ = "book"

    title: Mapped[str] = mapped_column(comment="本の題名")

```

# Alembicの基本的な設定と使い方

## Alembicの初期設定

Alembicは、SQLAlchemyのデータモデルの変更をデータベースに適用するためのマイグレーションツールです。ここでは、Alembicの基本的な設定方法について説明します。

### 初期化

Alembicの初期化を実施することで、マイグレーションの設定やスクリプトを格納するためのディレクトリがプロジェクト内に生成されます。
今回はmigrationsというディレクトリを生成します。

```sh
alembic init migrations
```

### 生成結果

```sh
.
├── alembic.ini # Alembicの主要な設定ファイル
└── migrations/
   ├── README
   ├── env.py # マイグレーション時に実行されるスクリプト
   ├── script.py.mako # マイグレーションスクリプトのテンプレートファイル
   └── versions # 個別のマイグレーションスクリプトが保存されるディレクトリ
```

### alembic.ini

データベースの接続情報を設定します。
sqlalchemy.urlに接続情報を記述してください。
（該当箇所を抜粋）

```alembic.ini
#TODO それぞれの接続情報に合わせて変更
sqlalchemy.url = driver://user:pass@localhost/dbname
```

# 複数のデータモデルファイルを自動でマイグレーションする設定

Alembicを使用してマイグレーションを生成する際、対象となるモデルをすべてimportしなければなりません。通常、複数のモデルファイルが存在する場合、それぞれのモデルファイルを手動でインポートする必要があります。しかし、これではモデルが追加されるたびにインポートの設定を更新する手間が生じます。

そこで、env.pyをカスタマイズすることで、指定されたディレクトリ内のすべてのモデルファイルを自動的にインポートする方法を提供します。これにより、モデルの追加や変更があっても追加の設定をすることなく、マイグレーションを行い、開発効率を維持することが可能となります。

## 自動インポートの設定

env.pyに以下の関数を追加します。
（対象の箇所を抜粋）

```python: env.py
from importlib import import_module
from pathlib import Path

from models.base import Base

def import_migration_module(module):
    """マイグレーションに含めたいモジュールをimport."""
    for file_name in (p.name for p in Path(module).iterdir() if p.is_file()):
        if file_name in {"__init__.py", "base.py"}:
            continue
        file_name = file_name.replace(".py", "")
        import_module(f"{module}.{file_name}")

# データモデルを記述しているディレクトリ名を指定
import_migration_module("models")

# 最初から書いてあるtarget_metadataに生成したBaseのmetadataを設定
target_metadata = Base.metadata

```

この関数import_migration_moduleは、指定されたモジュール（この場合は"models"ディレクトリ）内のすべてのPythonファイルをインポートします。この処理により、新たにモデルファイルが追加された際も、そのファイルは自動的にマイグレーションの対象となります。

これで、Alembicを使用した際に、modelsディレクトリ内の複数のモデルファイルが自動的にマイグレーションの対象となります。この設定により、マイグレーションの管理が大幅に効率化されます。

### 注意点

- base.py および __init__.py はインポートの対象から除外しています。これは、これらのファイルが実際のモデル定義を持たない、またはマイグレーションの際に重複する情報を持っている可能性があるためです。
- この自動インポートの設定は、モデルの追加や変更が頻繁に行われる開発初期に特に役立ちます。プロジェクトが安定してきたら、必要に応じて設定を調整することも考えられます。

# マイグレーションの実行

複数のデータモデルファイルを自動でインポートする設定が完了した後、実際にAlembicを用いてマイグレーションを実行してください。

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

```sh
alembic revision --autogenerate -m "マイグレーションの内容"
```

このコマンドは、現在のデータモデルの状態とデータベースの現在のスキーマを比較し、変更点をもとに新しいマイグレーションファイルを生成します。

## マイグレーションファイルの保存場所

マイグレーションファイルは、Alembicの初期設定時に生成されるmigrationsディレクトリの下のversionsディレクトリに保存されます。

```
.
├── alembic.ini # Alembicの主要な設定ファイル
└── migrations/
   ├── README
   ├── env.py # マイグレーション時に実行されるスクリプト
   ├── script.py.mako # マイグレーションスクリプトのテンプレートファイル
   └── versions # 個別のマイグレーションスクリプトが保存されるディレクトリ
      └── ccc7574b06ce_.py # 今回生成されたマイグレーションファイル（人によってファイル名は異なります）
```

## 生成されるマイグレーションファイルの内容

先程、複数のファイルに分割したCustomerとBookのデータモデルに対して、Alembicを使ってマイグレーションファイルを生成しました。生成されたマイグレーションファイルccc7574b06ce_.pyの内容を確認すると、以下の特徴が確認できます。

1. **テーブルの作成**  
   upgrade関数内でop.create_tableメソッドを用いて、bookテーブルとcustomerテーブルが作成されています。これにより、新しくデータベースにこれらのテーブルが追加されます。
2. **テーブルのカラム**  
   それぞれのテーブルには、先程のデータモデル定義で指定した属性（カラム）が反映されています。例えば、bookテーブルにはtitleとid、customerテーブルにはnameとidが定義されています。
3. **テーブルの削除**  
   downgrade関数内でop.drop_tableメソッドを使用して、bookテーブルとcustomerテーブルが削除されます。これはマイグレーションをロールバックする際の動作を指定しています。

```python:ccc7574b06ce_.py
"""empty message

Revision ID: ccc7574b06ce
Revises: 
Create Date: 2023-10-09 07:33:39.816194

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'ccc7574b06ce'
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('book',
    sa.Column('title', sa.String(), nullable=False, comment='本の題名'),
    sa.Column('id', sa.Integer(), nullable=False, comment='ID'),
    sa.PrimaryKeyConstraint('id')
    )
    op.create_table('customer',
    sa.Column('name', sa.String(), nullable=False, comment='顧客の名前'),
    sa.Column('id', sa.Integer(), nullable=False, comment='ID'),
    sa.PrimaryKeyConstraint('id')
    )
    # ### end Alembic commands ###


def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_table('customer')
    op.drop_table('book')
    # ### end Alembic commands ###

```

## マイグレーションの適用

生成したマイグレーションファイルを実際のデータベースに適用するには、以下のコマンドを使用します。

```sh
alembic upgrade head
```

このコマンドは、現在のデータベースのバージョンから最新のマイグレーション（head）まで全ての変更を適用します。

# まとめ

Alembicは、SQLAlchemyと組み合わせて使用することで、大規模なデータモデルの管理やマイグレーションを効率的に実施することができます。特に、データモデルが複数のファイルに分割されている場合でも、適切な設定を行うことで、スムーズなマイグレーションの自動生成が可能です。

本記事を通じて、以下の重要なポイントを学びました。

1. **データモデルの分割の必要性**  
   大規模なプロジェクトにおいて、一つのファイルに全てのモデルを収め続けることの問題点と、それを解消するための分割のアプローチ。
2. **SQLAlchemyでのデータモデルの定義**  
   データベース操作の抽象化を行うSQLAlchemyでのデータモデルの定義方法。
3. **Alembicの基本的な設定と使い方**  
   マイグレーションツールとしてのAlembicの基本設定とその使い方。
4. **複数のモデルファイルの自動インポート**  
   env.pyのカスタマイズを通じて、複数のモデルファイルを自動的にインポートし、マイグレーションの対象とする方法。
5. **マイグレーションの実行**
   設定した環境でのマイグレーションファイルの生成から、実際のデータベースへの適用方法。

開発の現場では、データモデルの変更や追加が頻繁に行われることが多いです。その都度、手動でのマイグレーションや設定の変更は大きな手間となります。この記事で紹介した方法を活用することで、効率的なデータモデルの管理とマイグレーションが可能となり、開発の生産性を高めることができるでしょう。