🐥

Prismaによる既存DBのリレーション構造改善 - 安全なスキーマ移行の実践

に公開

はじめに

「1つの親に複数の子を持たせたい」「リレーションに追加情報を持たせたい」―これはDB設計でよくある要件変更です。本記事では、Prismaを使用している本番アプリケーションで、単純な1対1の自己参照関係から、より柔軟な多対多関係へとデータモデルを安全に移行した実例を紹介します。型安全なORMであるPrismaの強みを最大限に活かしながら、ダウンタイムなく構造を進化させる方法を解説します。

問題の背景

私たちのアプリケーションでは、「プロジェクト」と「タスク」という関係があり、以下のような構造になっていました。

model Project {
  id        Int         @id @default(autoincrement())
  type      ProjectType @default(Main)
  
  // 自己参照の関係
  parentId  Int?        @unique
  parent    Project?    @relation(name: "ProjectHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
  child     Project?    @relation(name: "ProjectHierarchy")
  
  // その他のフィールド
  title     String
  status    String
  // ...
}

enum ProjectType {
  Main
  Sub
}

この設計では、@unique制約により、1つのMainプロジェクトに対して1つのSubプロジェクトしか関連付けられませんでした。

新しい要件

ビジネス要件の変化により、以下の機能が必要になりました。

  • 1つのメインプロジェクトに複数のサブプロジェクトを関連付ける
  • サブプロジェクトのステータス管理の拡張
  • 共有・依頼フローの追加
  • 複数のサブプロジェクトから1つを選定する機能

Prismaを活用した新しい設計

Prismaの強力なリレーション機能を活用し、以下のように設計を変更しました。

model Project {
  id        Int         @id @default(autoincrement())
  type      ProjectType @default(Main)
  title     String
  status    String
  
  // 多対多の関係
  asMainProjects ProjectRelation[] @relation("MainProject")
  asSubProjects  ProjectRelation[] @relation("SubProject")
}

model ProjectRelation {
  id        Int      @id @default(autoincrement())
  
  // リレーション
  mainProjectId Int
  mainProject   Project @relation("MainProject", fields: [mainProjectId], references: [id], onDelete: Cascade)
  
  subProjectId  Int
  subProject    Project @relation("SubProject", fields: [subProjectId], references: [id], onDelete: Cascade)
  
  // 関係に関する追加情報
  sharedAt    DateTime? // 共有日時
  requestedAt DateTime? // 依頼日時
  isSelected  Boolean   @default(false) // 選定フラグ
  
  @@unique([mainProjectId, subProjectId])
  @@index([mainProjectId])
  @@index([subProjectId])
}

この設計では、ProjectRelation中間テーブルを導入することで、より柔軟な関係性を表現できるようになりました。

Prismaを活用した段階的リファクタリング手順

1. 事前準備とプラン作成

最初に、変更の影響範囲を特定し、Prismaの機能を活用した移行計画を立てました。
Prisma Studioを使ったデータの調査
Prismaの型システムを活用したコード参照箇所の特定

マイグレーション戦略の選択

「追加→移行→削除」という3段階のアプローチを採用しました。これは「Blue-Green Deployment」の考え方に近く、古い構造と新しい構造を一時的に共存させることで、リスクを最小化します。
別の選択肢として「一度にすべて変更する」アプローチもありましたが、運用中のシステムへの影響を考慮し、段階的な方法を選びました。マイグレーション戦略の選択は、システムの規模や重要度、開発チームの状況に応じて検討すべき重要なポイントです。

2. 新しいテーブルの追加とデータ移行

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

Prismaの--create-onlyフラグを使用して、マイグレーションファイルを生成しました。

npx prisma migrate dev --name add_project_relation --create-only

データ移行ロジックの追加

生成されたSQLファイルにデータ移行ロジックを追加。

-- 新しいテーブルの作成
CREATE TABLE "ProjectRelation" (
  "id" SERIAL PRIMARY KEY,
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "mainProjectId" INTEGER NOT NULL,
  "subProjectId" INTEGER NOT NULL,
  "sharedAt" TIMESTAMP(3),
  "requestedAt" TIMESTAMP(3),
  "isSelected" BOOLEAN NOT NULL DEFAULT false,
  CONSTRAINT "ProjectRelation_mainProjectId_fkey" FOREIGN KEY ("mainProjectId") REFERENCES "Project"("id") ON DELETE CASCADE,
  CONSTRAINT "ProjectRelation_subProjectId_fkey" FOREIGN KEY ("subProjectId") REFERENCES "Project"("id") ON DELETE CASCADE,
  CONSTRAINT "ProjectRelation_mainProjectId_subProjectId_key" UNIQUE ("mainProjectId", "subProjectId")
);

-- インデックスの作成
CREATE INDEX "ProjectRelation_mainProjectId_idx" ON "ProjectRelation"("mainProjectId");
CREATE INDEX "ProjectRelation_subProjectId_idx" ON "ProjectRelation"("subProjectId");

-- 既存データの移行
INSERT INTO "ProjectRelation" (
  "mainProjectId",
  "subProjectId",
  "sharedAt",
  "requestedAt",
  "isSelected"
)
SELECT 
  p.id as "mainProjectId",
  s.id as "subProjectId",
  s."createdAt" as "sharedAt",
  s."createdAt" as "requestedAt",
  true as "isSelected"
FROM "Project" p
JOIN "Project" s ON s."parentId" = p.id
WHERE p."type" = 'Main' AND s."type" = 'Sub';

3. コード更新プロセスとTypeScriptの活用

型システムを活用した変更箇所の特定

Prismaの大きな利点の一つは強力な型システムです。これを活用して更新が必要なコード箇所を効率的に特定しました。

    1. まず、schema.prismaから古いリレーションをコメントアウトし、新しいリレーションのみを追加
model Project {
  // ...
  // 古いリレーションをコメントアウト
  // parentId  Int?       @unique
  // parent    Project?    @relation(name: "ProjectHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
  // child     Project?    @relation(name: "ProjectHierarchy")
  
  // 新しいリレーション
  asMainProjects ProjectRelation[] @relation("MainProject")
  asSubProjects  ProjectRelation[] @relation("SubProject")
  // ...
}
    1. 新しいPrisma Clientを生成して型情報を更新
npx prisma generate
    1. 発生したTypeScriptの型エラーを確認し、更新が必要な箇所を特定
      このアプローチでは、型システムがリファクタリングのガイドとして機能します。コンパイルエラーが出る箇所がそのまま修正が必要な箇所となります。

4. 段階的なコード更新

クエリコードの更新

特定された箇所のコードを修正し、新しいリレーション構造を使用するように更新しました。

// 古いPrismaクエリ
const project = await prisma.project.findUnique({
  where: { id: mainProjectId },
  include: { child: true }
});
const subProject = project.child;

// 新しいPrismaクエリ
const project = await prisma.project.findUnique({
  where: { id: mainProjectId },
  include: { 
    asMainProjects: {
      include: { subProject: true }
    }
  }
});
const subProjects = project.asMainProjects.map(rel => rel.subProject);

両方のリレーションの一時的な共存

すべてのコード更新が完了した後、デプロイまでの移行期間中はデータの整合性を保つため、一時的に両方のリレーションを有効にしました。

model Project {
  // ...
  // 古いリレーションを一時的に復活
  parentId  Int?       @unique
  parent    Project?    @relation(name: "ProjectHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
  child     Project?    @relation(name: "ProjectHierarchy")
  
  // 新しいリレーション
  asMainProjects ProjectRelation[] @relation("MainProject")
  asSubProjects  ProjectRelation[] @relation("SubProject")
  // ...
}

新しいPrisma Clientを生成して型情報を更新

npx prisma generate

移行中のアプリケーション動作保証

移行期間中も既存のコードが正常に動作するよう、両方のリレーション構造を一時的に維持し、段階的に新しい構造への移行を進めました。TypeScriptの型チェックを活用することで、未修正のコードを確実に検出できました。

5. 最終的なスキーマクリーンアップ

すべてのコードが新しいリレーションを使用するように更新された後、別のプルリクエストで古いリレーションを完全に削除しました。これには、schema.prismaから古いリレーションフィールドを削除し、標準のPrismaマイグレーションコマンドを実行しました。

schema.prismaから古いリレーションを削除

model Project {
  id        Int         @id @default(autoincrement())
  type      ProjectType @default(Main)
  title     String
  status    String
  
  // 新しいリレーションのみ残す
  asMainProjects ProjectRelation[] @relation("MainProject")
  asSubProjects  ProjectRelation[] @relation("SubProject")
  // その他のフィールド...
}

マイグレーションの実行

npx prisma migrate dev --name remove_old_project_hierarchy

Prismaが自動的に適切なSQLを生成し、古いカラムと制約を削除します。これにより、データベーススキーマがクリーンアップされ、新しい構造のみになりました。

成果と効果

このリファクタリングによって以下の成果が得られました:

  • サブプロジェクトの追加が自由に行えるようになり、プロジェクト管理の柔軟性が向上
  • リレーションに追加情報(共有日時、選定状態など)を持たせることで、業務フローを正確にモデル化
  • パフォーマンスの向上:以前は複数のクエリが必要だった操作が、1回のクエリで完結するように
  • コードの保守性向上:新しいモデルは拡張性が高く、今後の要件変更にも対応しやすい設計に

Prismaを使ったリファクタリングから学んだこと

1. Prismaの型安全性を最大限に活用

Prismaの提供する型安全性は、リファクタリング中に非常に強力なツールとなりました。型エラーが自動的に修正が必要な箇所を教えてくれるため、見落としを防ぐことができました。

2. Prismaマイグレーションの柔軟性

Prismaの--create-onlyオプションは、自動生成されたSQL上に独自のロジックを追加できるため、複雑なデータ移行にも対応できました。標準的なマイグレーションでは自動化を活かし、カスタマイズが必要な場合は手動調整するという使い分けが効果的です。

3. Prisma Studioによるデータ確認

移行作業中、Prisma Studioを活用してデータの状態を視覚的に確認できたことで、ミスを早期に発見できました。

npx prisma studio

4. PrismaとTypeScriptの相乗効果

PrismaとTypeScriptの組み合わせは、大規模なリファクタリングにおいて非常に効果的でした。コンパイル時のチェックにより、ランタイムエラーを防ぐことができました。

まとめ

Prismaを活用した段階的なリファクタリングアプローチにより、運用中のアプリケーションのデータ構造を安全に進化させることができました。特に、Prismaの型システムと柔軟なマイグレーション機能は、このような作業において非常に強力なツールです。
複雑なリレーションの変更が必要な場合でも、適切な計画と実行により、ダウンタイムやデータロスのリスクを最小限に抑えながら、アプリケーションを進化させることができるのです。

Discussion