🌊

Prismaで論理削除を実現する方法(Prisma Client Extensions)

に公開

株式会社Another worksでCTOをしている塩原です。

今回はPrismaで論理削除を実現する方法について他の方の参考になればと思い記事として共有します。

私たちの自社サービスは元々Railsで実装されており、その後Node.jsに移管する際にTypeORMを利用し、現在Prismaに移行中といった状態です。

また今後の方針としては、可能な限り論理削除テーブルを減らす、もしくは無くしていく予定です。

現在は論理削除テーブルが一部存在するため、Prismaに移行するにあたり適用する必要があり今回の対応を行いました。

私たちが以前使っていたTypeORMでは、論理削除が標準でサポートされていました。

  • アノテーションを付けるだけで論理削除テーブルとして設定できる
  • デフォルトで削除済みレコードは取得対象から外れる
  • softDeleteという関数で削除すると物理削除ではなく deleted_at が埋まる

一方でPrismaではその機能がなかったため、追加で実装する必要性が発生しました。

Prismaには論理削除が用意されていない

Prismaには 論理削除の公式機能は存在しません

そのため、よくある選択肢は次のどれかになります。

  • すべてのクエリで where: { deleted_at: null } を書く
  • Repository層を作ってラップする
  • Prisma Client Extensions を使う ← 今回はこれ

今回は Prisma Client Extensions を使って、以下の挙動を実現します。

  • find系では自動で deleted_at IS NULL を付与
  • delete系は deleted_at を更新するだけ
  • 呼び出し側のコードは意識しなくてよい

実装例

import { PrismaClient } from '@prisma/client'

/**
 * 論理削除対象のテーブル
 */
const modelsWithDeletedAt = [
  'users',
  'messages',
]

// ベースのPrismaクライアント(拡張なし)
const prismaClientBase = new PrismaClient({
  datasources: {
    db: {
      url: `${dbType()}://${dbUserName()}:${dbPassword()}@${dbHost()}:${dbPort()}/${database()}`,
    },
  },
})

// 論理削除拡張を適用したPrismaクライアント
export const prismaClient = new PrismaClient({
  datasources: {
    db: {
      url: 'xxx',
    },
  },
}).$extends({
  query: {
    $allModels: {
      async findMany({ model, args, query }) {
        if (modelsWithDeletedAt.includes(model)) {
          args.where = { deleted_at: null, ...args.where }
        }
        return query(args)
      },

      async count({ model, args, query }) {
        if (modelsWithDeletedAt.includes(model)) {
          args.where = { deleted_at: null, ...args.where }
        }
        return query(args)
      },

      async delete({ model, args, query }) {
        if (modelsWithDeletedAt.includes(model)) {
          return prismaClientBase[model].update({
            ...args,
            data: { deleted_at: new Date() },
          })
        }
        return query(args)
      },
      
      // 他の関数も同様に拡張
    },
  },
})

実装の解説

find系

await prismaClient.users.findMany()

WHERE deleted_at IS NULL

が自動で付与される。

delete系

await prismaClient.users.delete({ where: { id: 1 } })

  • 物理削除されない
  • deleted_at に現在時刻が入る

呼び出し側は論理削除を意識しなくていい

Repository層やService層で、以下のようなコードを書かなくて良くなります。

where: { deleted_at: null }

注意点・デメリット

もちろん注意点もあります。
例えば、users を取得する際に messages を include した場合、messages 側の deleted_at は自動では考慮されません。必要に応じて個別に where 条件を指定する必要があります。

とはいえ、実務でのコスパはかなり良い方法だと思っています。

まとめ

  • TypeORMには論理削除が標準で用意されている
  • Prismaには用意されていない
  • Prisma Client Extensions を使えば実現可能
  • 呼び出し側を汚さずに論理削除を扱える

Prismaを使っていて「論理削除どうしよう…」となっている人の参考になれば嬉しいです。

Discussion