クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

Prismaでデータ移行した話

こんにちは、 @ttaka_66 です。

CROWDWORKSコンサルティングの業務効率化を目的とした自社システムを開発しています。自社システムではO/RマッパーにPrismaを利用しています。今回はスキーマ変更を伴うデータ移行を行ったので、それについて記載します。

背景

背景として、以前リンクスエージェントで利用していたシステムをCROWDWORKSコンサルティングで使えるように改修しています。

リンクスエージェントでは、クライアントとワーカーのマッチングを支援する事業であったため、クライアントとワーカーが1対1で対応する契約を結びます。

一方でCROWDWORKSコンサルティングでは、クライアントの経営課題解決を目的としたコンサルティング事業のため、クライアントとクラウドワークスが直接契約するケースがあります。また、課題解決にワーカーの方に支援いただくケースがあり、クライアントとワーカーが1対多になるケースも多く発生します。

リンクスエージェント向けにシステムを利用していた際は、1つの契約テーブルにクライアントとの契約情報とワーカーとの契約情報のフィールドを持たせていましたが、クライアントとワーカーが1対1ではないケースが発生することとなったのでスキーマを変えることにしました。

※ 現在リンクスエージェントはサービスを終了しています。

変更点

1つだった契約テーブルをクライアント・ワーカー共通項目、クライアント契約情報、ワーカー契約情報の3つのテーブルに分割しました。

今回は説明簡略化のために以下の例を用います。

  • 契約情報(contracts)を契約共通項目(contracts)、クライアント契約情報(contract_clients)、ワーカー契約情報(contract_workers)に分割
  • 契約開始日(start_date)と契約終了日(end_date)は契約共通項目
  • 業務委託料(commission_fee)はクライアントとワーカーでそれぞれ持つ

手順

リリースを3つのステップに分けて移行をしました。

ステップ1スキーマは削除せずに新スキーマで必要となるフィールドを追加し、アプリケーションからのINSERT、UPDATE、DELETEは新旧両方のスキーマに適応されるようにする

ステップ2 アプリケーションで表示している(SELECT)部分に新スキーマの情報を使うようにする

ステップ3スキーマのフィールドとアプリケーション側の旧スキーマに対するINSERT、UPDATE、DELETEロジックの削除する

ステップを3つに分けた理由を以下です。

  • 既存の契約情報も新しいスキーマに移す必要があったので、漏れがあったときのために元の情報を一定期間保持しておきたかった
  • 契約情報は多くの情報に関連しているため、間違っていたときに途中で修正できるようなブレークポイントをおきたかった
  • 業務に利用しているので、できればシステムを停止せずに移行したかった

対応

具体的に対応したことをコードを用いて記載します。

ステップ1

schema.prismaに新スキーマのフィールドを定義してMigrateを実行

model Contract {
  id                  Int      @id @default(autoincrement())'
  startDate           DateTime @map(name: "start_date") @db.Date
  endDate             DateTime @map(name: "end_date") @db.Date
  commissionFeeClient Int      @map(name: "commission_fee_client")
  commissionFeeWorker Int      @map(name: "commission_fee_worker")
  ...
+ contractClient      ContractClient?
+ contractWorker      ContractWorker?

  @@map(name: "contracts")
}

+model ContractClient {
+ id            Int      @id @default(autoincrement())
+ contract      Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
+ contractId    Int      @unique @map(name: "contract_id")
+ commissionFee Int      @map(name: "commission_fee")
+ ...
+
+ @@map(name: "contract_clients")
+}

+model ContractWorker {
+ id            Int      @id @default(autoincrement())
+ contract      Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
+ contractId    Int      @unique @map(name: "contract_id")
+ commissionFee Int      @map(name: "commission_fee")
+ ...
+
+ @@map(name: "contract_workers")
+}

新旧テーブル両方でINSERT or UPDATE or DELETE(ここではINSERT)

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

...

prisma.contract.create({
  data: {
    startDate: startDateValue,
    endDate: endDateValue,
    commissionFeeClient: commissionFeeClientValue,
    commissionFeeWorker: commissionFeeWorkerValue,
+   contractClient: {
+     create: {
+       commissionFee: commissionFeeClientValue,
+       ...restContractClient
+     },
+   },
+   contractWorker: {
+     create: {
+       commissionFee: commissionFeeWorkerValue,
+       ...restContractWorker,
+     },
+   },
     ...rest,
  }
});

ステップ1をリリースした段階で既存のデータを新スキーマのフィールドに追加

+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+async function main(): Promise<void> {
+  await prisma.$transaction(
+    async (tx) => {
+      const contracts = await tx.contract.findMany();
+      for (const contract of contracts) {
+        const contractClient = await tx.contractClient.create({
+          data: {
+            contract: { connect: { id: contract.id } },
+            commissionFee: contract.comissionFeeClient,
+            ...,
+          },
+        });
+
+        const contractWorker = await tx.contractWorker.create({
+          data: {
+            contract: { connect: { id: contract.id } },
+            comissionFee: contract.comissionFeeWorker,
+            ...,
+          },
+        });
+      }
+    }
+  );
+}
+
+main()
+  .catch(async (e) => {
+    console.error(e);
+    process.exit(1);
+  })
+  .finally(async () => await prisma.$disconnect());

ステップ2

スキーマの方のデータを取得するように変更

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

...

prisma.contract.findUnique({
  where: {
    id: someId
  },
+ include: {
+   contractWorker: true
+ },
});

DBから取得したデータを利用する側のプログラムで新スキーマのフィールド(上記のコードで追加したincludeの部分)を使うように修正

※ 利用する側のプログラムはシステムアーキテクチャによって違うのでコード例は割愛させてください

ステップ3

schema.prismaにカラム削除を記述してMigrateを実行

model Contract {
  id                  Int      @id @default(autoincrement())
  startDate           DateTime @map(name: "start_date") @db.Date
  endDate             DateTime @map(name: "end_date") @db.Date
- commissionFeeClient Int      @map(name: "commission_fee_client")
- commissionFeeWorker Int      @map(name: "commission_fee_worker")
  ...
  contractClient      ContractClient?
  contractWorker      ContractWorker?

  @@map(name: "contracts")
}

アプリケーション側の旧スキーマのフィールドに対するINSERT、UPDATE、DELETEロジックの削除(ここではINSERT)

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

...

prisma.contract.create({
  data: {
    startDate: startDateValue,
    endDate: endDateValue,
-   commissionFeeClient: commissionFeeClientValue,
-   commissionFeeWorker: commissionFeeWorkerValue,
    contractClient: {
      create: {
        commissionFee: commissionFeeClientValue,
        ...restContractClient
      },
    },
    contractWorker: {
      create: {
        commissionFee: commissionFeeWorkerValue,
        ...restContractWorker,
      },
    },
     ...rest,
  }
});

所感

本記事では簡略化して記載しましたが、実際は契約情報や利用されている箇所は多く変更したコードもそれなりに多くなりました(トータルで追加5595行、削除2695行)が、ほぼ漏れがなくコード変更することができました。

これは、DBとの接続部分と取得データの利用部分でテストコードを記述することで、Prismaがしっかりエラーを出してくれたので実現できたと考えています。

それでも多少の漏れは発生しましたが、サービスに影響を及ぼすことなくステップ間でリカバリすることができました。

本記事が誰かのお役に立てれば大変嬉しく思います。

© 2016 CrowdWorks, Inc., All rights reserved.