0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自動マイグレーション対決:Prisma vs SQLAlchemy+Alembic 完全ガイド

Posted at

自動マイグレーション対決:Prisma vs SQLAlchemy+Alembic 完全ガイド

データベースマイグレーションは、現代のWebアプリケーション開発において避けて通れない重要な作業です。本記事では、完全自動化を謳うPrismaと、柔軟な半自動化を提供するSQLAlchemy+Alembicの自動マイグレーション機能を徹底比較します。

データベースマイグレーションとは?

データベースマイグレーションとは、データベーススキーマの変更を管理・実行するプロセスです。これを理解するために、身近な例で説明しましょう。

現実世界の例:図書館のリニューアル

imagine a library that needs renovation:

  • 新しい書棚を追加(新しいテーブル)
  • 既存の書棚に新しい区画を作成(カラム追加)
  • 児童書コーナーを移動(データ移行)
  • カード目録からデジタルシステムへ移行(構造変更)

データベースマイグレーションは、これらの変更を安全かつ確実に行うためのプロセスです。

なぜ自動マイグレーションが重要なのか?

手動でマイグレーションを作成すると:

  • 人的エラーが発生しやすい
  • 時間がかかる
  • チーム間での一貫性が保てない
  • 複雑な変更を見逃しやすい

自動マイグレーション機能により、これらの問題を大幅に軽減できます。

Prismaの完全自動化アプローチ

Prismaは「Schema-First」の哲学に基づいており、スキーマファイルの変更を検知して自動的にマイグレーションを生成します。

Prismaの仕組み

実践例:ユーザーテーブルの作成

1. 初期スキーマ定義

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

2. マイグレーション実行

$ npx prisma migrate dev --name init

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "myapp"

✔ Generated Prisma Client (4.16.2 | library) to ./node_modules/@prisma/client in 2.11s

The following migration(s) have been created and applied:

migrations/
  └─ 20231201120000_init/
    └─ migration.sql

3. 生成されたマイグレーションファイル

-- CreateTable
CREATE TABLE "User" (
    "id" SERIAL NOT NULL,
    "email" TEXT NOT NULL,
    "name" TEXT,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

Prismaでカラム追加

スキーマに新しいフィールドを追加してみましょう:

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  age       Int?     // 新しく追加
  profile   String?  // 新しく追加
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
$ npx prisma migrate dev --name add_user_fields

The following migration(s) have been created and applied:

migrations/
  └─ 20231201130000_add_user_fields/
    └─ migration.sql

生成されたマイグレーション:

-- AlterTable
ALTER TABLE "User" ADD COLUMN "age" INTEGER,
ADD COLUMN "profile" TEXT;

Prismaの利点

  1. 完全自動化: スキーマからマイグレーションまで一気通貫
  2. タイプセーフ: 生成されるクライアントも自動更新
  3. シンプル: 学習コストが低い
  4. 一貫性: チーム全体で同じマイグレーションが生成される

Prismaの制限

  1. 柔軟性の欠如: 複雑なマイグレーションには対応できない場合がある
  2. カスタマイズ困難: 生成されたSQLの細かい調整が難しい
  3. データ変換: 既存データの複雑な変換処理が苦手

SQLAlchemy+Alembicの半自動化アプローチ

AlembicはSQLAlchemyのマイグレーションツールで、--autogenerateオプションにより半自動でマイグレーションを生成します。

Alembicの仕組み

実践例:同じユーザーテーブルの作成

1. SQLAlchemyモデル定義

# models.py
from sqlalchemy import Column, Integer, String, DateTime, func
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    
    id = Column(Integer, primary_key=True, autoincrement=True)
    email = Column(String, unique=True, nullable=False)
    name = Column(String, nullable=True)
    created_at = Column(DateTime, default=func.now(), nullable=False)
    updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)

2. Alembic初期化

$ alembic init alembic
$ alembic revision --autogenerate -m "Create user table"

INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'users'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_users_email' on '['email']'

Generating /path/to/alembic/versions/001_create_user_table.py

3. 生成されたマイグレーションファイル

"""Create user table

Revision ID: 001
Revises: 
Create Date: 2023-12-01 12:00:00.000000

"""
from alembic import op
import sqlalchemy as sa

# revision identifiers
revision = '001'
down_revision = None
branch_labels = None
depends_on = None

def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('users',
        sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
        sa.Column('email', sa.String(), nullable=False),
        sa.Column('name', sa.String(), nullable=True),
        sa.Column('created_at', sa.DateTime(), nullable=False),
        sa.Column('updated_at', sa.DateTime(), nullable=False),
        sa.PrimaryKeyConstraint('id')
    )
    op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
    # ### end Alembic commands ###

def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_index(op.f('ix_users_email'), table_name='users')
    op.drop_table('users')
    # ### end Alembic commands ###

Alembicでカラム追加

# models.py(更新版)
class User(Base):
    __tablename__ = 'users'
    
    id = Column(Integer, primary_key=True, autoincrement=True)
    email = Column(String, unique=True, nullable=False)
    name = Column(String, nullable=True)
    age = Column(Integer, nullable=True)  # 新しく追加
    profile = Column(String, nullable=True)  # 新しく追加
    created_at = Column(DateTime, default=func.now(), nullable=False)
    updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
$ alembic revision --autogenerate -m "Add age and profile columns"

INFO  [alembic.autogenerate.compare] Detected added column 'users.age'
INFO  [alembic.autogenerate.compare] Detected added column 'users.profile'

Generating /path/to/alembic/versions/002_add_age_and_profile_columns.py

生成されたマイグレーション:

def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))
    op.add_column('users', sa.Column('profile', sa.String(), nullable=True))
    # ### end Alembic commands ###

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

Alembicの自動検出できないケース

Alembicが自動検出できない典型的なケース:

1. カラム名の変更

# Before
name = Column(String, nullable=True)

# After
full_name = Column(String, nullable=True)

自動生成されるマイグレーション(間違い):

def upgrade() -> None:
    op.add_column('users', sa.Column('full_name', sa.String(), nullable=True))
    op.drop_column('users', 'name')  # データが失われる!

正しい手動修正:

def upgrade() -> None:
    op.alter_column('users', 'name', new_column_name='full_name')

2. テーブル名の変更

# 手動で修正が必要
def upgrade() -> None:
    op.rename_table('users', 'app_users')

3. データ変換を伴う変更

def upgrade() -> None:
    # カラム追加
    op.add_column('users', sa.Column('status', sa.String(), nullable=True))
    
    # データ変換(手動で追加)
    op.execute("UPDATE users SET status = 'active' WHERE deleted_at IS NULL")
    op.execute("UPDATE users SET status = 'deleted' WHERE deleted_at IS NOT NULL")
    
    # NOT NULL制約追加
    op.alter_column('users', 'status', nullable=False)

Alembicの高度な設定

alembic.ini設定例

[alembic]
script_location = alembic
sqlalchemy.url = postgresql://user:pass@localhost/dbname

# カスタム比較オプション
compare_type = true
compare_server_default = true

# 無視する設定
[post_write_hooks]
hooks = black
black.type = console_scripts
black.entrypoint = black
black.options = REVISION_SCRIPT_FILENAME

env.py設定例

# alembic/env.py
from alembic import context
from sqlalchemy import engine_from_config, pool
from models import Base

# カスタム比較関数
def include_object(object, name, type_, reflected, compare_to):
    """特定のオブジェクトを無視する"""
    if type_ == "table" and name == "temp_table":
        return False
    return True

def run_migrations_online():
    configuration = context.config.get_section(context.config.config_ini_section)
    configuration['sqlalchemy.url'] = os.getenv('DATABASE_URL')
    
    connectable = engine_from_config(
        configuration,
        prefix='sqlalchemy.',
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=Base.metadata,
            include_object=include_object,
            compare_type=True,
            compare_server_default=True
        )

        with context.begin_transaction():
            context.run_migrations()

詳細比較:同じ変更を両ツールで実行

実際の開発でよくある変更を両ツールで実行して比較してみましょう。

シナリオ1:新しいテーブル追加

Prismaの場合

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
$ npx prisma migrate dev --name add_post_table

生成されるSQL:

-- CreateTable
CREATE TABLE "Post" (
    "id" SERIAL NOT NULL,
    "title" TEXT NOT NULL,
    "content" TEXT NOT NULL,
    "published" BOOLEAN NOT NULL DEFAULT false,
    "authorId" INTEGER NOT NULL,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

Alembicの場合

class Post(Base):
    __tablename__ = 'posts'
    
    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String, nullable=False)
    content = Column(String, nullable=False)
    published = Column(Boolean, default=False, nullable=False)
    author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
    created_at = Column(DateTime, default=func.now(), nullable=False)
    updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
    
    author = relationship("User", back_populates="posts")
$ alembic revision --autogenerate -m "Add post table"

生成されるPython:

def upgrade() -> None:
    op.create_table('posts',
        sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
        sa.Column('title', sa.String(), nullable=False),
        sa.Column('content', sa.String(), nullable=False),
        sa.Column('published', sa.Boolean(), nullable=False),
        sa.Column('author_id', sa.Integer(), nullable=False),
        sa.Column('created_at', sa.DateTime(), nullable=False),
        sa.Column('updated_at', sa.DateTime(), nullable=False),
        sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
        sa.PrimaryKeyConstraint('id')
    )

シナリオ2:インデックス追加

Prismaの場合

model Post {
  // ... other fields
  title     String
  published Boolean  @default(false)
  
  @@index([published])
  @@index([title])
}
$ npx prisma migrate dev --name add_post_indexes

生成されるSQL:

-- CreateIndex
CREATE INDEX "Post_published_idx" ON "Post"("published");

-- CreateIndex
CREATE INDEX "Post_title_idx" ON "Post"("title");

Alembicの場合

class Post(Base):
    # ... other fields
    title = Column(String, nullable=False, index=True)
    published = Column(Boolean, default=False, nullable=False, index=True)

または手動でインデックス定義:

def upgrade() -> None:
    op.create_index('ix_posts_published', 'posts', ['published'])
    op.create_index('ix_posts_title', 'posts', ['title'])

シナリオ3:カラム名変更(最大の違い)

Prismaの場合

Prismaはカラム名変更を直接検出できません:

model User {
  id       Int     @id @default(autoincrement())
  email    String  @unique
  fullName String? // nameから変更
  // ...
}

結果:データが失われる可能性のあるマイグレーションが生成される

-- DropColumn
ALTER TABLE "User" DROP COLUMN "name";
-- AddColumn
ALTER TABLE "User" ADD COLUMN "fullName" TEXT;

回避策:手動でマイグレーションを編集する必要がある

Alembicの場合

初回自動生成:

def upgrade() -> None:
    op.add_column('users', sa.Column('full_name', sa.String(), nullable=True))
    op.drop_column('users', 'name')  # 危険!

手動修正(推奨):

def upgrade() -> None:
    op.alter_column('users', 'name', new_column_name='full_name')

パフォーマンス比較

マイグレーション生成速度

Prisma

  • 小規模プロジェクト: 1-3秒
  • 中規模プロジェクト: 3-10秒
  • 大規模プロジェクト: 10-30秒
# 時間計測例
$ time npx prisma migrate dev --name test_migration
real    0m4.123s
user    0m2.456s
sys     0m0.234s

Alembic

  • 小規模プロジェクト: 0.5-2秒
  • 中規模プロジェクト: 2-5秒
  • 大規模プロジェクト: 5-15秒
# 時間計測例
$ time alembic revision --autogenerate -m "test migration"
real    0m2.345s
user    0m1.234s
sys     0m0.123s

マイグレーション実行速度

両ツールとも実際のSQL実行時間に大差はありませんが、Prismaは追加でクライアント生成が必要です。

チーム協業での比較

Prismaでの協業

利点

  • 一貫性: 全員が同じマイグレーションを生成
  • シンプル: 学習コストが低い
  • 自動化: 手動介入が少ない

課題

  • マージコンフリクト: スキーマファイルでのコンフリクト
  • カスタマイズ困難: 特殊要件に対応しにくい

ワークフロー例

# 開発者A
git pull origin main
npx prisma migrate dev  # 最新マイグレーションを適用
# スキーマ変更
npx prisma migrate dev --name add_feature_a
git add . && git commit -m "feat: add feature A schema"
git push origin feature-a

# 開発者B
git pull origin main
git checkout feature-b
npx prisma migrate dev  # 基本マイグレーションを適用
# スキーマ変更
npx prisma migrate dev --name add_feature_b

マージ時の対処:

git merge main
# schema.prismaでコンフリクトが発生
# 手動でスキーマを統合
npx prisma migrate dev --name merge_features

Alembicでの協業

利点

  • 柔軟性: カスタマイズが容易
  • レビュー可能: 生成されたマイグレーションを詳細レビューできる
  • 細かい制御: 複雑な要件に対応可能

課題

  • 複雑性: 学習コストが高い
  • 人的エラー: 手動修正時のミス

ワークフロー例

# 開発者A
git pull origin main
alembic upgrade head  # 最新マイグレーションを適用
# モデル変更
alembic revision --autogenerate -m "add feature A"
# 生成されたマイグレーションをレビュー・修正
git add . && git commit -m "feat: add feature A migration"

# プルリクエストでマイグレーションをレビュー
# マージ後
alembic upgrade head

実世界のシナリオ

シナリオA:スタートアップの快速開発

要件:

  • 素早いプロトタイピング
  • 小さなチーム(2-5人)
  • シンプルなスキーマ変更が中心

Prismaが適している理由:

// プロトタイプ段階でのシンプルな変更
model User {
  id    Int    @id @default(autoincrement())
  email String @unique
  name  String
}

// 機能追加時
model User {
  id      Int     @id @default(autoincrement())
  email   String  @unique
  name    String
  avatar  String? // すぐに追加できる
  premium Boolean @default(false) // すぐに追加できる
}

実行:

npx prisma migrate dev --name add_user_features
# 数秒で完了、即座に開発継続可能

シナリオB:大企業のレガシーシステム移行

要件:

  • 複雑なデータ変換
  • 段階的な移行
  • ダウンタイム最小化
  • 詳細な制御が必要

Alembicが適している理由:

def upgrade() -> None:
    # ステップ1: 新しいカラムを追加(NULLable)
    op.add_column('users', sa.Column('new_status', sa.String(), nullable=True))
    
    # ステップ2: 既存データを段階的に変換
    connection = op.get_bind()
    
    # バッチ処理で大量データを安全に変換
    batch_size = 1000
    offset = 0
    
    while True:
        result = connection.execute(
            f"""
            UPDATE users 
            SET new_status = CASE 
                WHEN deleted_at IS NOT NULL THEN 'deleted'
                WHEN last_login > NOW() - INTERVAL '30 days' THEN 'active'
                ELSE 'inactive'
            END
            WHERE id > {offset} AND id <= {offset + batch_size}
            AND new_status IS NULL
            """
        )
        
        if result.rowcount == 0:
            break
        offset += batch_size
        
        # 進行状況をログ出力
        print(f"Processed {offset} users...")
    
    # ステップ3: NOT NULL制約を追加
    op.alter_column('users', 'new_status', nullable=False)
    
    # ステップ4: 古いカラムを削除(次のマイグレーションで)
    # op.drop_column('users', 'deleted_at')  # 次回のマイグレーションで実行

シナリオC:複数環境での運用

Prismaの環境管理

# 開発環境
DATABASE_URL="postgresql://dev:password@localhost:5432/myapp_dev"
npx prisma migrate dev

# ステージング環境
DATABASE_URL="postgresql://staging:password@staging-db:5432/myapp_staging"
npx prisma migrate deploy

# 本番環境
DATABASE_URL="postgresql://prod:password@prod-db:5432/myapp_prod"
npx prisma migrate deploy

Alembicの環境管理

# alembic.ini
[alembic]
script_location = alembic

[development]
sqlalchemy.url = postgresql://dev:password@localhost:5432/myapp_dev

[staging]
sqlalchemy.url = postgresql://staging:password@staging-db:5432/myapp_staging

[production]
sqlalchemy.url = postgresql://prod:password@prod-db:5432/myapp_prod
# 環境別実行
alembic -x environment=development upgrade head
alembic -x environment=staging upgrade head
alembic -x environment=production upgrade head

マイグレーションロールバック戦略

Prismaのロールバック

Prismaは直接的なロールバック機能を提供していませんが、以下の方法があります:

方法1: マイグレーション履歴の確認

# マイグレーション履歴確認
npx prisma migrate status

# Database: myapp_dev
# Migration status: All migrations have been applied.
# 
# 20231201120000_init
# 20231201130000_add_user_fields
# 20231201140000_add_post_table

方法2: データベースリセット(開発環境のみ)

npx prisma migrate reset
# ⚠️ 全データが削除されます!

方法3: 手動ロールバック

-- 手動でロールバック用SQLを作成
DROP TABLE "Post";
ALTER TABLE "User" DROP COLUMN "age";
ALTER TABLE "User" DROP COLUMN "profile";

Alembicのロールバック

Alembicは強力なロールバック機能を提供:

# 現在のマイグレーション状態確認
alembic current
# 002 (head)

# 履歴確認
alembic history --verbose
# 002 -> 001 (head), Add age and profile columns
# 001 -> None, Create user table

# 1つ前のマイグレーションにロールバック
alembic downgrade -1

# 特定のマイグレーションにロールバック
alembic downgrade 001

# 全てロールバック
alembic downgrade base

ロールバック用の関数例

def upgrade() -> None:
    op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))
    op.add_column('users', sa.Column('profile', sa.String(), nullable=True))

def downgrade() -> None:
    op.drop_column('users', 'profile')
    op.drop_column('users', 'age')

本番環境デプロイメントの考慮事項

Prismaの本番デプロイ

CI/CDパイプライン例

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Generate Prisma Client
        run: npx prisma generate
      
      - name: Run migrations
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
      
      - name: Deploy application
        run: npm run deploy

ゼロダウンタイム戦略

# 1. 新しいカラムを追加(NULLable)
# schema.prisma
model User {
  email    String  @unique
  name     String?
  newEmail String? // 新しいカラム
}

# 2. アプリケーションデプロイ(両方のカラムを使用)
# 3. データ移行
# 4. 古いカラムを削除する次のマイグレーション

Alembicの本番デプロイ

ブルーグリーンデプロイメント対応

def upgrade() -> None:
    # フェーズ1: 後方互換性を保つ変更
    op.add_column('users', sa.Column('new_email', sa.String(), nullable=True))
    
    # データ移行(バックグラウンドで実行可能)
    op.execute("""
        UPDATE users SET new_email = email 
        WHERE new_email IS NULL
    """)

def downgrade() -> None:
    op.drop_column('users', 'new_email')

# 次のマイグレーション(アプリケーション更新後)
def upgrade() -> None:
    # NOT NULL制約追加
    op.alter_column('users', 'new_email', nullable=False)
    
    # 古いカラム削除
    op.drop_column('users', 'email')
    
    # カラム名変更
    op.alter_column('users', 'new_email', new_column_name='email')

ベストプラクティス

Prismaのベストプラクティス

1. 段階的なスキーマ変更

// ❌ 一度に大きな変更
model User {
  id          Int     @id @default(autoincrement())
  email       String  @unique
  profile     Profile @relation(fields: [profileId], references: [id])
  profileId   Int     @unique
}

model Profile {
  id      Int    @id @default(autoincrement())
  bio     String
  avatar  String
  user    User?
}

// ✅ 段階的な変更
// Step 1: Profileテーブルを追加
model Profile {
  id      Int    @id @default(autoincrement())
  bio     String
  avatar  String
}

// Step 2: リレーション追加
model User {
  id          Int      @id @default(autoincrement())
  email       String   @unique
  profileId   Int?     @unique  // 最初はoptional
  profile     Profile? @relation(fields: [profileId], references: [id])
}

2. マイグレーション名の命名規則

# ✅ 良い例
npx prisma migrate dev --name add_user_profile_relation
npx prisma migrate dev --name add_post_published_index
npx prisma migrate dev --name update_user_email_unique_constraint

# ❌ 悪い例
npx prisma migrate dev --name update
npx prisma migrate dev --name fix
npx prisma migrate dev --name changes

3. 環境変数の管理

# .env.local(開発環境)
DATABASE_URL="postgresql://dev:password@localhost:5432/myapp_dev"

# .env.staging
DATABASE_URL="postgresql://staging:password@staging-db:5432/myapp_staging"

# .env.production
DATABASE_URL="postgresql://prod:password@prod-db:5432/myapp_prod"

Alembicのベストプラクティス

1. 自動生成後の必須チェック

# 生成されたマイグレーションの確認ポイント
def upgrade() -> None:
    # ✅ チェック1: データ損失がないか
    op.add_column('users', sa.Column('full_name', sa.String(), nullable=True))
    # データ移行を追加
    op.execute("UPDATE users SET full_name = name WHERE full_name IS NULL")
    # NOT NULL制約(必要に応じて)
    op.alter_column('users', 'full_name', nullable=False)
    
    # ✅ チェック2: インデックスが適切か
    op.create_index('ix_users_full_name', 'users', ['full_name'])
    
    # 古いカラム削除は慎重に
    op.drop_column('users', 'name')  # 本当に必要?

def downgrade() -> None:
    # ✅ チェック3: ロールバックが正しく動作するか
    op.add_column('users', sa.Column('name', sa.String(), nullable=True))
    op.execute("UPDATE users SET name = full_name WHERE name IS NULL")
    op.drop_index('ix_users_full_name', table_name='users')
    op.drop_column('users', 'full_name')

2. カスタム比較関数の活用

# alembic/env.py
def include_object(object, name, type_, reflected, compare_to):
    """
    カスタムオブジェクト比較関数
    """
    # テンポラリテーブルを無視
    if type_ == "table" and name.startswith("temp_"):
        return False
    
    # 特定のインデックスを無視
    if type_ == "index" and name.startswith("pg_"):
        return False
    
    # ビューは無視
    if type_ == "table" and hasattr(object, 'info') and object.info.get('is_view'):
        return False
    
    return True

def compare_type(context, inspected_column, metadata_column, inspected_type, metadata_type):
    """
    カスタム型比較関数
    """
    # PostgreSQLのVARCHARとTEXTを同じとして扱う
    if isinstance(inspected_type, VARCHAR) and isinstance(metadata_type, Text):
        return False
    
    # デフォルトの比較
    return None

3. 複雑なマイグレーションのテンプレート

"""Complex migration template

Revision ID: xxx
Revises: yyy
Create Date: 2023-12-01 12:00:00

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import Session

# revision identifiers
revision = 'xxx'
down_revision = 'yyy'
branch_labels = None
depends_on = None

def upgrade() -> None:
    # フェーズ1: 構造変更
    print("Phase 1: Schema changes...")
    op.add_column('users', sa.Column('temp_status', sa.String(), nullable=True))
    
    # フェーズ2: データ移行
    print("Phase 2: Data migration...")
    bind = op.get_bind()
    session = Session(bind=bind)
    
    try:
        # バッチ処理でデータ移行
        batch_size = 1000
        offset = 0
        
        while True:
            users = session.execute(
                sa.text("""
                    SELECT id, old_status 
                    FROM users 
                    WHERE temp_status IS NULL 
                    AND id > :offset 
                    ORDER BY id 
                    LIMIT :limit
                """),
                {"offset": offset, "limit": batch_size}
            ).fetchall()
            
            if not users:
                break
            
            for user in users:
                new_status = map_old_to_new_status(user.old_status)
                session.execute(
                    sa.text("UPDATE users SET temp_status = :status WHERE id = :id"),
                    {"status": new_status, "id": user.id}
                )
            
            session.commit()
            offset = users[-1].id
            print(f"Processed batch ending at ID {offset}")
        
        # フェーズ3: 制約追加
        print("Phase 3: Adding constraints...")
        op.alter_column('users', 'temp_status', nullable=False, new_column_name='status')
        op.drop_column('users', 'old_status')
        
    except Exception as e:
        session.rollback()
        print(f"Migration failed: {e}")
        raise
    finally:
        session.close()

def map_old_to_new_status(old_status):
    """ステータス変換ロジック"""
    mapping = {
        0: 'inactive',
        1: 'active',
        2: 'suspended',
        None: 'unknown'
    }
    return mapping.get(old_status, 'unknown')

def downgrade() -> None:
    print("Rolling back complex migration...")
    # 逆の処理を実装
    op.add_column('users', sa.Column('old_status', sa.Integer(), nullable=True))
    
    # データを戻す処理
    bind = op.get_bind()
    session = Session(bind=bind)
    
    reverse_mapping = {
        'inactive': 0,
        'active': 1,
        'suspended': 2,
        'unknown': None
    }
    
    users = session.execute(sa.text("SELECT id, status FROM users")).fetchall()
    for user in users:
        old_value = reverse_mapping.get(user.status)
        session.execute(
            sa.text("UPDATE users SET old_status = :old_status WHERE id = :id"),
            {"old_status": old_value, "id": user.id}
        )
    
    session.commit()
    session.close()
    
    op.drop_column('users', 'status')

よくある落とし穴と対策

Prismaの落とし穴

1. マイグレーション順序の問題

// ❌ 問題のある変更
model User {
  id    Int    @id @default(autoincrement())
  email String @unique
  posts Post[] // リレーション追加
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  authorId Int
  author   User   @relation(fields: [authorId], references: [id])
}

実行結果:

Error: The relation field `posts` on the `User` model is missing an opposite relation field on the `Post` model.

✅ 正しい修正:

model User {
  id    Int    @id @default(autoincrement())
  email String @unique
  posts Post[] // back_populatesを指定
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  authorId Int
  author   User   @relation(fields: [authorId], references: [id])
}

2. データ型変更の危険性

// Before
model Product {
  id    Int    @id @default(autoincrement())
  price Int    // 整数
}

// After
model Product {
  id    Int     @id @default(autoincrement())
  price Decimal // 小数点対応
}

生成されるマイグレーション(データ損失の可能性):

-- AlterTable
ALTER TABLE "Product" ALTER COLUMN "price" SET DATA TYPE DECIMAL;

✅ 安全な移行戦略:

// Step 1: 新しいカラム追加
model Product {
  id       Int      @id @default(autoincrement())
  price    Int      // 既存
  priceNew Decimal? // 新しいカラム
}

// Step 2: データ移行後、古いカラム削除
model Product {
  id    Int     @id @default(autoincrement())
  price Decimal // priceNewから名前変更
}

Alembicの落とし穴

1. 自動生成の過信

# ❌ 自動生成されたマイグレーション(問題あり)
def upgrade() -> None:
    op.drop_table('old_users')  # データ損失!
    op.create_table('users',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('email', sa.String(), nullable=False),
        sa.PrimaryKeyConstraint('id')
    )

✅ 手動レビューで修正:

def upgrade() -> None:
    # 1. 新しいテーブル作成
    op.create_table('users',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('email', sa.String(), nullable=False),
        sa.PrimaryKeyConstraint('id')
    )
    
    # 2. データ移行
    op.execute("""
        INSERT INTO users (id, email)
        SELECT id, email_address FROM old_users
    """)
    
    # 3. 古いテーブル削除(次のマイグレーションで)
    # op.drop_table('old_users')  # 後で実行

2. 循環依存の問題

# ❌ 問題のあるマイグレーション順序
def upgrade() -> None:
    # 外部キー制約を先に作成してしまう
    op.create_foreign_key(
        'fk_posts_user_id', 'posts', 'users', ['user_id'], ['id']
    )
    # テーブルの作成が後になってエラー
    op.create_table('users', ...)

✅ 正しい順序:

def upgrade() -> None:
    # 1. 参照される側のテーブルを先に作成
    op.create_table('users', ...)
    
    # 2. 参照する側のテーブルを作成
    op.create_table('posts', ...)
    
    # 3. 外部キー制約を追加
    op.create_foreign_key(
        'fk_posts_user_id', 'posts', 'users', ['user_id'], ['id']
    )

選択の決定ガイド

プロジェクト特性による選択

機能比較表

項目 Prisma Alembic 勝者
学習コスト ⭐⭐⭐⭐⭐ ⭐⭐⭐ Prisma
自動化レベル ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ Prisma
カスタマイズ性 ⭐⭐ ⭐⭐⭐⭐⭐ Alembic
複雑な変更対応 ⭐⭐ ⭐⭐⭐⭐⭐ Alembic
ロールバック ⭐⭐ ⭐⭐⭐⭐⭐ Alembic
実行速度 ⭐⭐⭐ ⭐⭐⭐⭐ Alembic
チーム協業 ⭐⭐⭐⭐ ⭐⭐⭐ Prisma
エラー回復 ⭐⭐ ⭐⭐⭐⭐ Alembic

決定フローチャート

まとめ

データベースマイグレーションの自動化は、現代のWebアプリケーション開発において必須の要素です。PrismaとSQLAlchemy+Alembicは、それぞれ異なるアプローチで問題解決を図っています。

Prismaを選ぶべき場合

  • スタートアップや小規模チーム: 素早いプロトタイピングが必要
  • シンプルなアプリケーション: 複雑なスキーマ変更が少ない
  • 学習コスト重視: チーム全体のスキルレベルを考慮
  • 一貫性重視: 全員が同じマイグレーションを生成したい

Alembicを選ぶべき場合

  • エンタープライズアプリケーション: 複雑な要件やレガシーシステム
  • カスタマイズ重要: 細かい制御や特殊な処理が必要
  • チーム経験豊富: SQLやデータベース設計に詳しいメンバー
  • パフォーマンス重視: 大規模データベースでの運用

最終的な推奨事項

  1. プロジェクト初期段階: 要件の複雑さを正確に評価する
  2. チーム能力: メンバーのスキルレベルを考慮する
  3. 将来性: アプリケーションの成長に応じた拡張性を検討する
  4. 運用体制: デプロイメント戦略とマイグレーション管理体制を整備する

どちらのツールも優秀ですが、プロジェクトの特性とチームの状況に応じて適切に選択することが成功の鍵となります。また、選択したツール経特性を理解し、ベストプラクティスに従って運用することで、安全で効率的なデータベースマイグレーションを実現できます。


この記事では、両ツールの実践的な使用例を通して、それぞれの特徴と適用場面を詳しく解説しました。プロジェクトの成功のために、適切なツール選択の参考にしていただければ幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?