EFCore でレコードに対するトリガーを実行する

# はじめに

レコードが更新されたタイミングで最終更新日を更新したいとか、異なるテーブルや RDB 以外のデータソースに履歴を追加したいとか、更新時にメールを飛ばしたいとかいった処理を実装しようとした場合、更新処理の中にそれらの処理を追加していくと、本来やりたい処理以外のコード多くなり、コードの見通しが悪くなることがあります。

この記事では EFCore で Rails の ActiveRecord のコールバックのように、データの更新ライフサイクルをフックして追加の処理を定義する方法について説明します。

# 方針

レコードの更新時に追加の処理を実行するには、EFCore 5 で追加された、SaveChanges の実行時に発火する[`インターセプター`](https://docs.microsoft.com/ja-jp/ef/core/logging-events-diagnostics/interceptors)を利用するか、これまで通り DataContext の SaveChanges をオーバーライドして DataContext の更新時の処理を記述する事で対応できます。

今回は次の 3 つの方法で更新日付や作成日付を外側から更新します。

- DataContext の拡張
- インターセプター
- EntityFrameworkCore.Triggered

エンティティーの定義は次のようになっています。

```cs:ISupportTimeStamps.cs
public interface ISupportTimeStamps
{
    DateTime Updated { get; set; }
    DateTime Created { get; set; }
}
```

```cs:User.cs
public class User: ISupportTimeStamps
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
    public DateTime Updated { get; set; }
    public DateTime Created { get; set; }
}
```
かなり手抜きですが User の更新処理は次のようになっています。更新処理では Updated や Created といったタイムスタンプ項目を直接更新していない点に注目してください。

```cs:UserRepository.cs
public async Task Update(string name, int age)
{
    var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == id);
    if (user == null)
        return;
    user.Name = name;
    user.Age = age;
    await _context.SaveChangesAsync();
}
```

引数に渡された DataContext が持つエンティティーから、変更があったものを抽出してタイムスタンプ項目をサポートしている（ISupportTimeStamps インターフェイスを実装している）エンティティーに絞って日付の更新を行うヘルパーメソッドも定義しておきます。

```cs:SupportTimeStampHelper.cs
public class SupportTimeStampHelper
{
    public static void UpdateTimeStamps(DbContext dbContext, DateTime now)
    {
        foreach (var entity in dbContext.ChangeTracker.Entries())
        {
            if (!(entity.Entity is ISupportTimeStamps e))
                continue;
            switch (entity.State)
            {
                case EntityState.Added:
                    e.Created = now;
                    e.Updated = now;
                    break;
                case EntityState.Modified:
                    e.Updated = now;
                    break;
            };
        }
    }
}
```

# DataContext の拡張で実現する例

DataContext の SaveChanges メソッドや SaveChangesAsync メソッドを override して保存時に追加の処理を実行します。

```cs:ProducerDbContext.cs
public class ProducerDbContext: DbContext
{
    public ProducerDbContext(DbContextOptions<ProducerDbContext> options)
        : base(options)
    { }

    public DbSet<User> Users { get;set; }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
    {
        SupportTimeStampHelper.UpdateTimeStamps(this, DateTime.Now);
        return base.SaveChangesAsync(cancellationToken);
    }
}
```

特定のプロジェクトに対して、アドホックに追加の操作を追加したい場合、この手段が一番お手軽ですね。

# インターセプターで実現する例

[`インターセプター`](https://docs.microsoft.com/ja-jp/ef/core/logging-events-diagnostics/interceptors)を利用する場合は、インターセプトしたい操作ごとのインターフェイスを実装し、DataContext の設定時にそのインターセプターを登録します。

どのようなインターセプターが定義されているかは、ドキュメントの[データベースの傍受](https://docs.microsoft.com/ja-jp/ef/core/logging-events-diagnostics/interceptors#database-interception)を確認してください。ただし、今回説明する SaveChanges へのインターセプトは EF Core 5 からの機能なので注意してください。

SaveChanges 時の操作をインターセプトしたい場合は[`ISaveChangesInterceptor`](https://docs.microsoft.com/ja-jp/dotnet/api/microsoft.entityframeworkcore.diagnostics.isavechangesinterceptor?view=efcore-5.0)インターフェイスを実装していけばよいのですが、実装を省略できる[`SaveChangesInterceptor`](https://docs.microsoft.com/ja-jp/dotnet/api/microsoft.entityframeworkcore.diagnostics.savechangesinterceptor?view=efcore-5.0)クラスがあるのでこれを使っていきましょう。

DataContext の SaveChanges には、同期版の SaveChanges と非同期版の SaveChangesAsync の 2 種類があります。[`SaveChangesInterceptor`](https://docs.microsoft.com/ja-jp/dotnet/api/microsoft.entityframeworkcore.diagnostics.savechangesinterceptor?view=efcore-5.0)では同期版と非同期版それぞれのメソッドに対して SaveChanges 呼び出し時の処理を実装していきます。

```cs:TimeStampInterceptor.cs
public class TimeStampInterceptor: SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        eventData.Context.ChangeTracker.DetectChanges();
        SupportTimeStampHelper.UpdateTimeStamps(eventData.Context, DateTime.Now);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = new CancellationToken())
    {
        eventData.Context.ChangeTracker.DetectChanges();
        SupportTimeStampHelper.UpdateTimeStamps(eventData.Context, DateTime.Now);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}
```

DataContext の初期化時に AddInterceptors メソッドで作成したインターセプターを追加します。

```cs:Startup.cs
var connectionString =  Configuration.GetConnectionString("producerDb");
services.AddDbContext<ProducerDbContext>((provider, options)=>
        {
            options
                .UseMySql(connectionString, new MySqlServerVersion("5.6"))
                .AddInterceptors(new TimeStampInterceptor());
        });
```

# EntityFrameworkCore.Triggered で実現する例

続いて、[`EntityFrameworkCore.Triggered`](https://github.com/koenbeuk/EntityFrameworkCore.Triggered) を見ていきましょう。こちらはインターセプターと違い、EF Core 5 以前でも利用できますが、EF Core によってインストールするバージョンを切り替える必要があるので注意してください。

| EF Core のバージョン | EntityFrameworkCore.Triggered のバージョン | 備考                     |
| -------------------- | ------------------------------------------ | ------------------------ |
| 3.1                  | 1.x                                        | DataContext の拡張が必要 |
| 5.0                  | 2.x                                        |                          |
| 6.0                  | 3.x                                        | プレリリース             |

今回は EF Core 5 ベースのプロジェクトに追加するので`2.3.2`を追加しました。

```
> Install-Package EntityFrameworkCore.Triggered -Version 2.3.2
```

[`EntityFrameworkCore.Triggered`](https://github.com/koenbeuk/EntityFrameworkCore.Triggered) は、次の 2 つのインターフェイスを実装しインタセプター同様 DataContext 初期化時にトリガーを追加することで動作します。

- IBeforeSaveTrigger<T>
- IAfterSaveTrigger<T>

今回は保存前をインターセプトして日付を更新したいので`IBeforeSaveTrigger<T>`を実装しています。

```cs:TimeStampTrigger.cs
public class TimeStampTrigger: IBeforeSaveTrigger<ISupportTimeStamps>
{
    public async Task BeforeSave(
        ITriggerContext<ISupportTimeStamps> context,
        CancellationToken cancellationToken)
    {
        var d = DateTime.Now;
        switch (context.ChangeType)
        {
            case ChangeType.Added:
                context.Entity.Created = d;
                context.Entity.Updated = d;
                break;
            case ChangeType.Modified:
                context.Entity.Updated = d;
                break;
        }
        await Task.CompletedTask;
    }
}
```

AddInterceptors をコメントアウトして、代わりに UseTriggers で追加したトリガーを設定します。

```cs:Startup.cs
var connectionString =  Configuration.GetConnectionString("producerDb");
services.AddDbContext<ProducerDbContext>((provider, options)=>
        {
            options
                .UseMySql(connectionString, new MySqlServerVersion("5.6"))
                // .AddInterceptors(new TimeStampInterceptor());
                .UseTriggers(configure =>
                {
                    configure.AddTrigger<Data.Triggers.TimeStampTrigger>();
                })
        });
```

他の 2 つの例と比べて特定のエンティティーに対して、拡張が容易になっているのが良いですね！
トリガーの登録方法や優先順位、そのほかの機能などはライブラリの作者が EF Core の開発陣とディスカッションしている動画がとても参考になるのでぜひ視聴してみてください。

- [Youtube presentation - Interview by the EF Core team](https://www.youtube.com/watch?v=Gjys0Yebobk&t=671s)

# おわりに

EF Core のエンティティー更新時に処理を挟みこむ方法を 3 つ解説してきました。更新処理本体に手を入れず共通的な処理を実装する方法としてとても便利です。ただ使いすぎるとどこで何をやっているのかわからなくなったり、予期せぬエラーに悩まされたりします。用法容量を守って使っていきましょう。