Entity Framework Coreで論理削除

# Entity Framework Coreで論理削除したい！

Entity Framework Coreの世界では標準では論理削除の機能を持っていません。
__deleteと言ったらdeleteなんだ!__ というわけで。

しかし、ORMとして論理削除ができない、というのは日本の業務システム開発の世界では、データ削除が求められる場合には致命傷になりかねないポイントでもありえます。
もちろん、削除を行うロジックで削除フラグなり削除日時をupdateすれば事足りるわけですが、できれば透過的に対応したいですね。
たとえば PHPのフレームワーク `Laravel` で利用するORM `Eloquent` では `SoftDelete` トレイトが標準で用意されていて簡単に論理削除をおこなえるようになっています。

というわけで、Entity Frameworkに論理削除機能を追加してみましょう。
## TL;DR;

- 論理削除の制御には[Microsoft.EntityFrameworkCore.Diagnostics.SaveChangesInterceptor](https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.entityframeworkcore.diagnostics.savechangesinterceptor?view=efcore-8.0) クラスを拡張実装し、`Microsoft.EntityFrameworkCore.DbContext::OnConfiguring(DbContextOptionsBuilder)` メソッドでDBコンテキストに登録する。
- selectする際のクエリを制御するためには`Microsoft.EntityFrameworkCore.DbContext::OnModelCreating(ModelBuilder)` メソッドをオーバーライドする。
- より細かな制御のため、論理削除用のインターフェース・基底クラス・属性を実装しエンティティにオプトインする。

## 環境

以下の環境を想定しています。

- .NET 8
- Entity Framework Core 8
    - データベース生成は Code Firstを前提とします。
- データベース: PostgreSQL
    - NodaTime利用 ※.NET 向けPostgresドライバでは日付の扱いに`NodaTime`が利用できます。今回はこのライブラリを利用します。

本記事では以下の内容に触れません。

- Entity Framework Coreの基本的な使い方
- ASP.net Core上でのDI
- Entity Framework CoreでPostgreSQLを利用する方法
## いざ尋常に!

さて、論理削除の詳細について記していきますが、この論理削除の仕組み自体はJetBrainにて公開されている以下の記事を参考としています。

[The .NET Tools Blog - How to Implement a Soft Delete Strategy with Entity Framework Core - JetBrains](https://blog.jetbrains.com/dotnet/2023/06/14/how-to-implement-a-soft-delete-strategy-with-entity-framework-core/)

ただし、この記事では無条件に全エンティティへの論理削除を行う形となってしまいます。

> このテーブルは論理削除でいいけれど、こっちのテーブルは物理削除でいいんだよなあ……

> 大抵のケースではこのテーブルは論理削除でいいのだけれども、この画面だけは地理削除で制御したいなあ……

といったケースをクリアできないため、このあたりは追加対応が必要です。

### 論理削除できるエンティティを構成する

論理削除できるエンティティを作成するため、部品として以下の4つを実装します。

|    クラス・インターフェース     | 用途                   |
| :-----------------: | -------------------- |
|   ISoftDeletable    | 論理削除可能エンティティインターフェース |
| SoftDeletableEntity | 論理削除可能エンティティ基底クラス    |
| SoftDeleteAttribute | 論理削除の戦略を指定する属性       |
| SoftDeleteStrategy  | 論理削除の戦略              |

`SoftDeleteAttribute`の属性が持っている機能を`ISoftDeletable` `SoftDeleteEntity` にもたせてもよさそうですが、今回は別個のものとして実装します。
#### `ISoftDeletable` インターフェース

``` csharp
using NodaTime;

namespace FilnK.Database.SoftDelete.Entity;

/// <summary>
/// 論理削除可能エンティティ インターフェース
/// </summary>
public interface ISoftDeletable
{
    /// <summary>
    /// 削除日時
    /// </summary>
    public LocalDateTime? DeletedAt { get; set; }

    /// <summary>
    /// 強制的に物理削除を行うかどうか
    /// </summary>
    public bool ForceDelete { get; set; }

}
```

`ISoftDeletable` では論理削除に必要なプロパティを2つ定義しています。

- 削除日時: 論理削除した日時。ここはboolのフラグでも代替できます。
- 強制削除フラグ: __いいからDeleteするのだ!__ という強い意志を示すためのプロパティ

#### `SoftDeletableEntity` 抽象クラス

`SoftDeletableEntity` では `ISoftDeletable` を実装し、Entity Framework Core向けの制御を追加します。


``` csharp
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;

namespace FilnK.Database.SoftDelete.Entity;

/// <summary>
/// 論理削除可能なエンティティ 基底クラス
/// </summary>
public abstract class SoftDeletableEntity : ISoftDeletable
{
    /// <inheritdoc/>
    [Column("deleted_at", Order = 50000)]
    [Comment("削除日時")]
    public LocalDateTime? DeletedAt { get; set; } = null;

    /// <inheritdoc/>
    [NotMapped]
    public bool ForceDelete { get; set; } = false;
}
```

`ISoftDeletable` で指定のプロパティを実装します。
`DeletedAt` プロパティは実際のカラムとして機能させるため、 `Column` 属性と `Comment` 属性を追加します。
`ForceDelete` プロパティはあくまでエンティティ制御のためのプロパティ、実際のカラムとしては不要であるため、`NotMapped` 属性でエンティティ生成時のカラムマップから除外します。

#### `SoftDelete` 属性 / `SoftDeleteStrategy` 列挙型

論理削除の戦略を指定する属性です。
属性値には `SoftDeleteStrategy` 列挙型を実装・使用します。

``` csharp
namespace FilnK.Database.SoftDelete.Entity.Attribute;

/// <summary>
/// 論理削除を行うことを示す属性
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class SoftDeleteAttribute : System.Attribute
{
    /// <summary>
    /// 論理削除の戦略
    /// </summary>
    public SoftDeleteStrategy Strategy { get; init; }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="strategy">論理削除の戦略</param>
    public SoftDeleteAttribute(SoftDeleteStrategy strategy = SoftDeleteStrategy.Both)
    {
        Strategy = strategy;
    }
}

/// <summary>
/// 論理削除戦略
/// </summary>
public enum SoftDeleteStrategy
{
    /// <summary>
    /// なにもしない（物理削除）
    /// </summary>
    None,
    /// <summary>
    /// 保存時に論理削除・選択時に論理削除を考慮
    /// </summary>
    Both,
    /// <summary>
    /// 保存時に論理削除・選択時に論理削除を考慮しない
    /// </summary>
    OnlyOnSave,
    /// <summary>
    /// 保存時に物理削除・選択時に論理削除を考慮
    /// </summary>
    OnlyOnSelect,
}
```

`SoftDeleteStrategy` 列挙型は以下のマトリクスを選択するために使用します。


| 列挙子                             | SaveChange[Asyc]時に<br />論理削除 | select時に<br />論理削除を<br />考慮 |
| ------------------------------- | ---------------------------- | --------------------------- |
| SoftDeleteStrategy.None         | ✗                            | ✗                           |
| SoftDeleteStrategy.Both         | ○                            | ○                           |
| SoftDeleteStrategy.OnlyOnSave   | ○                            | ✗                           |
| SoftDeleteStrategy.OnlyOnSelect | ✗                            | ○                           |

### 論理削除できるDBコンテキストを構成する

前項にて論理削除ができるエンティティを構成しました。
次に論理削除が動作するDBコンテキストを構成していきます。

必要となる部品は以下の2つです。

|      クラス・インターフェース      | 用途                              |
| :--------------------: | ------------------------------- |
| SoftDeleteInterceptor  | 論理削除を行う`SaveChangesInterceptor` |
| SoftDeletableDbContext | 論理削除可能な`DbContext`              |
#### `SoftDeleteInterceptor` クラス

``` csharp
using FilnK.Database.SoftDelete.Entity;
using FilnK.Database.SoftDelete.Entity.Attribute;

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

using NodaTime;

namespace FilnK.Database.SoftDelete.Interceptor;

/// <summary>
/// 論理削除インターセプタ
/// </summary>
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
    /// <inheritdoc/>
    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        SavingChangesImpl(eventData);

        return base.SavingChanges(eventData, result);
    }

    /// <inheritdoc/>
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        SavingChangesImpl(eventData);

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }


    /// <summary>
    /// 保存処理前処理
    /// </summary>
    /// <remarks>削除扱いのデータで論理削除を想定するエンティティは論理削除に変更する</remarks>
    /// <param name="eventData">DBコンテキストのデータ</param>
    protected virtual void SavingChangesImpl(DbContextEventData eventData)
    {
        if (eventData.Context is null)
        {
            return;
        }

        var currentTimestamp = LocalDateTime.FromDateTime(DateTime.Now);
        foreach (var entry in eventData.Context.ChangeTracker.Entries())
        {
            // エンティティの状態が『削除』であるかどうか
            // オプト・インされたエンティティであるかどうか
            if (entry.State == EntityState.Deleted && entry.Entity is ISoftDeletable softDeletable)
            {
                // 強い意志があればそのまま
                if (softDeletable.ForceDelete)
                {
                    continue;
                }

                // attributeから戦略を確認
                // 論理削除の取り扱いである場合は状態を『変更』にして削除日時に現在日時を設定
                var softDeleteAttr = Attribute.GetCustomAttribute(softDeletable.GetType(), typeof(SoftDeleteAttribute));
                if (softDeleteAttr is SoftDeleteAttribute attr &&
                    new[] { SoftDeleteStrategy.Both, SoftDeleteStrategy.OnlyOnSave }.Contains(attr.Strategy))
                {
                    entry.State = EntityState.Modified;
                    softDeletable.DeletedAt = currentTimestamp;
                }
            }
        }
    }
}
```

Entity Framework Core上の `SaveChanges[Async]` メソッドが呼び出された際に割り込み処理をするロジックの実装には [Microsoft.EntityFrameworkCore.Diagnostics.SaveChangesInterceptor](https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.entityframeworkcore.diagnostics.savechangesinterceptor?view=efcore-8.0) を基底クラスとしたクラスを実装します。

`SaveChangesInterceptor` クラスでは2つのメソッドのオーバーライドが必要となります。

- `SaveChanges`
- `SaveChangesAsync`

それぞれが`SaveChanges[Async]` メソッドに対応するメソッドとなるので、それぞれから論理削除を制御するメソッド `SavingChangesImpl` を呼び出しています。

`SavingChangesImpl` メソッド内で DbContext上のChangeTrackerとして記録されている変更エンティティを走査し、

1. エンティティの操作が『削除』であるか
2. オプト・インされたエンティティなのか
3. 強制削除フラグが立っていないか
4. 削除時の論理削除を考慮する必要があるか

を判定し、最終的に操作を『変更』に切り替え、削除日時プロパティに現在日時を設定しています。

#### `SoftDeletableDbContext` クラス

いよいよEntity Framework Core で最も大事なクラス、DbContextを構成していきます。

ここではインターセプターの登録とselect時の挙動を実装します。

``` csharp
using System.Linq.Expressions;
using System.Reflection;

using FilnK.Database.SoftDelete.Entity;
using FilnK.Database.SoftDelete.Entity.Attribute;
using FilnK.Database.SoftDelete.Interceptor;

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.ValueGeneration;

namespace FilnK.Database.SoftDelete;

/// <summary>
/// 論理削除可能DBコンテキスト
/// </summary>
public abstract class SoftDeletableDbContext : DbContext
{

    /// <inheritdoc/>
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder);

        // インターセプタの設定
        optionsBuilder.AddInterceptors(new SoftDeleteInterceptor());
    }

    /// <inheritdoc/>
    protected override void OnModelCreating(ModelBuilder builder)
    {
        // 論理削除の取扱い
        this.HandleSoftDeletes(ref builder);

        base.OnModelCreating(builder);
    }

    /// <summary>
    /// 論理削除の取り扱い
    /// </summary>
    /// <param name="modelBuilder">モデルビルダ</param>
    protected virtual void HandleSoftDeletes(ref ModelBuilder builder)
    {
        foreach (var entityItem in builder.Model.GetEntityTypes())
        {

            // SoftDeleteAttributeの取扱い
            if (Attribute.GetCustomAttribute(entityItem.ClrType.GetTypeInfo(), typeof(SoftDeleteAttribute))
                is SoftDeleteAttribute softDelete)
            {
                // entityItemが論理削除可能であること
                // select時に論理削除を考慮する戦略であること
                if (entityItem.ClrType.IsAssignableTo(typeof(ISoftDeletable)) &&
                    new[] { SoftDeleteStrategy.Both, SoftDeleteStrategy.OnlyOnSelect }.Contains(softDelete.Strategy))
                {
                    // e => e.DeletedAt == null を表す式木
                    var param = Expression.Parameter(entityItem.ClrType);
                    var prop = Expression.PropertyOrField(param, nameof(ISoftDeletable.DeletedAt));
                    var entityNotDeleted = Expression.Lambda(Expression.Equal(prop, Expression.Constant(null)), param);

                    entityItem.SetQueryFilter(entityNotDeleted);
                }
            }
        }
    }
}
```

インターセプターの登録は `OnConfiguring` メソッド内で `Microsoft.EntityFrameworkCore.DbContextOptionsBuilder::AddInterceptors` メソッドで登録を行います。

selectを行う際の論理削除の考慮（SQL的には `deleted_at is null`）を制御するには `OnModelCreating` メソッド内で制御します。今回は論理削除の考慮を `HandleSoftDeletes` メソッドとして実装し、呼び出す形にしています。

`HandleSoftDeletes` メソッドは `SoftDeleteInterceptor::SavingChangesImpl` メソッドと似通った処理です。

DbContext上定義されているエンティティを走査し、

1. オプト・インされたエンティティなのか
2. select時に論理削除を考慮する必要があるか

を判定し、クエリにを生成する式木に `e => e.DeletedAt == null`と同等の式木を追加します。

## いざ使ってみる!

さて、部品を一通り作りましたので今度は実際に使ってみましょう。サンプルとして以下のエンティティ・DBコンテキストを使ってみます。

``` csharp
using FilnK.Database.SoftDelete.Entity;
using FilnK.Database.SoftDelete.Entity.Attribute;

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

[Table("t_soft_deletables")]
[Comment("論理削除エンティティ")]
[SoftDelete]
public ckas SoftDeletable: SoftDeletableEntity
{
    [Column(name: "id", Order = 10)]
    [Comment("ID")]
    public int Id { get; set; }

    [Column(name: "value", Order = 20)]
    [Comment("値")]
    public string Value { get; set; } = null!;
}

[Table("t_hard_deletables")]
[Comment("物理削除エンティティ")]
[SoftDelete(SoftDeleteStrategy.None)]
public ckas HardDeletable: SoftDeletableEntity
{
    [Column(name: "id", Order = 10)]
    [Comment("ID")]
    public int Id { get; set; }

    [Column(name: "value", Order = 20)]
    [Comment("値")]
    public string Value { get; set; } = null!;
}

[Table("t_soft_deletable_on_saves")]
[Comment("保存時のみ論理削除考慮エンティティ")]
[SoftDelete(SoftDeleteStrategy.OnlyOnSave)]
public ckas SoftDeletableOnSave: SoftDeletableEntity
{
    [Column(name: "id", Order = 10)]
    [Comment("ID")]
    public int Id { get; set; }

    [Column(name: "value", Order = 20)]
    [Comment("値")]
    public string Value { get; set; } = null!;
}

[Table("t_soft_deletable_on_selects")]
[Comment("Select時のみ論理削除考慮エンティティ")]
[SoftDelete(SoftDeleteStrategy.OnlyOnSelect)]
public ckas SoftDeletableOnSelect: SoftDeletableEntity
{
    [Column(name: "id", Order = 10)]
    [Comment("ID")]
    public int Id { get; set; }

    [Column(name: "value", Order = 20)]
    [Comment("値")]
    public string Value { get; set; } = null!;
}

```

``` csharp
using FilnK.Database.SoftDelete;

public class PgContext : SoftDeletableDbContext
{
    public DbSet<SoftDeletable> SoftDeletables { get; set; } = default!;
    public DbSet<HardDeletable> HardDeletables { get; set; } = default!;
    public DbSet<SoftDeletableOnSave> SoftDeletableOnSaves { get; set; } = default!;
    public DbSet<SoftDeletableOnSelect> SoftDeletableOnSelects { get; set; } = default!;
}
```

`PgContext` クラスを何らかの方法でインスタンス化し、`context`変数に格納しているとしたとき、ロジックとSQLは以下のようになります。

### `SoftDeletable`

``` csharp

// SELECT
var records = context.SoftDeletables.ToArray();
// select * from t_soft_deletables where deleted_at is null

// DELETE
var firstEntity = context.SoftDeletables.First();
context.SoftDeletables.Remove(firstEntity);
context.SaveChanges();
// update t_soft_deletables set deleted_at = '2024-03-03 17:00:00.000000' where id = 1

// DELETE + 強制削除
var firstEntity = context.SoftDeletables.First();
firstEntity.ForceDelete = true;
context.SoftDeletables.Remove(firstEntity);
context.SaveChanges();
// delete t_soft_deletables where id = 1

```

### `HardDeletable`

``` csharp

// SELECT
var records = context.HardDeletable.ToArray();
// select * from t_hard_deletables

// DELETE
var firstEntity = context.HardDeletable.First();
context.HardDeletable.Remove(firstEntity);
context.SaveChanges();
// delete t_hard_deletables where id = 1

// DELETE + 強制削除
var firstEntity = context.HardDeletable.First();
firstEntity.ForceDelete = true;
context.HardDeletable.Remove(firstEntity);
context.SaveChanges();
// delete t_hard_deletables where id = 1

```

### `SoftDeletableOnSave`

``` csharp

// SELECT
var records = context.SoftDeletableOnSave.ToArray();
// select * from t_soft_deletable_on_saves

// DELETE
var firstEntity = context.SoftDeletableOnSave.First();
context.HardDeletable.Remove(firstEntity);
context.SaveChanges();
// update t_soft_deletable_on_saves set deleted_at = '2024-03-03 17:00:00.000000' where id = 1

// DELETE + 強制削除
var firstEntity = context.SoftDeletableOnSave.First();
firstEntity.ForceDelete = true;
context.HardDeletable.Remove(firstEntity);
context.SaveChanges();
// delete t_soft_deletable_on_saves where id = 1

```

### `SoftDeletableOnSelect`

``` csharp

// SELECT
var records = context.SoftDeletableOnSelect.ToArray();
// select * from t_soft_deletable_on_selects where deleted_at is null

// DELETE
var firstEntity = context.SoftDeletableOnSelect.First();
context.HardDeletable.Remove(firstEntity);
context.SaveChanges();
// delete t_soft_deletable_on_selects where id = 1

// DELETE + 強制削除
var firstEntity = context.SoftDeletableOnSelect.First();
firstEntity.ForceDelete = true;
context.HardDeletable.Remove(firstEntity);
context.SaveChanges();
// delete t_soft_deletable_on_selects where id = 1

```

## まとめ

Entity Framework は長い歴史を持つORMです。
それこそCode Firstの概念がないころから存在しているものです。
過去の積み重ねがあるがゆえに、昨今登場してきたORMと比べると、標準機能では実装がなかったり、機能を実現するにしても込み入った実装が必要だったりします。

ただ論理削除したいだけでしたが、これだけの実装をしないと実現できない……

しかし、複雑さは逆に言えばきめ細やかさにもつながるため、一度仕組みを把握してしまえばできることが一気に増えると思います。
今回利用した`SaveChangesInterceptor`も、Entity Framework上に用意されているインターセプターのごく一部です。インターセプターというコンセプト自体は汎用的に作られているため、論理削除以外にも様々な処理を行えます。

深いぜ、Entity Framework……