EntityFramework Coreの一括更新

## はじめに

EFCoreでテーブルの値を更新したり削除したりする場合は、一度読み込んだDataContextに対してデータを編集しSaveChangesで書き戻してあげる必要がありました。EFCore7ではデータを一括で更新する[ExecuteUpdate(Async)](https://learn.microsoft.com/ja-jp/ef/core/what-is-new/ef-core-7.0/whatsnew#basic-executedelete-examples)と[ExecuteDelete(Async)](https://learn.microsoft.com/ja-jp/ef/core/what-is-new/ef-core-7.0/whatsnew#basic-executedelete-examples)という2つ(4つ)のメソッドが追加されています。

https://learn.microsoft.com/ja-jp/ef/core/what-is-new/ef-core-7.0/whatsnew#executeupdate-and-executedelete-bulk-updates

今回はこれまでのEFCoreでの一括更新と、ExecuteUpdateを使った一括更新の2つを確認していきます。

## 今回利用するライブラリとか

```sh
> dotnet package add Pomelo.EntityFrameworkCore.MySql
> dotnet package add EFCore.NamingConventions
> dotnet package add Z.EntityFramework.Plus.EFCore
```

## 今回の対象となるDataContext

Entityはタイムスタンプ関連の項目とRowVersionを持っています。
DataContextは検索時にDeleteAtを考慮して、論理削除済みのデータは除外するように設定しています。

```cs
public class SampleBlog : IHasTimestamp
{
    public int Id { get; set; }
    public string Title { get; set; } = null!;

    public DateTimeOffset? CreatedAt { get; set; }
    public DateTimeOffset? UpdatedAt { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }

    [Timestamp]
    [ConcurrencyCheck]
    public byte[] RowVersion { get; set; } = null!;
}

public class AppDbContext : DbContext
{
    public DbSet<SampleBlog> SampleBlogs => Set<SampleBlog>();
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<SampleBlog>().HasQueryFilter(b => b.DeletedAt == null);
    }
}
```

プログラム開始時にはGlobalSaveChangesInterceptorを適用して、IHasTimestampなEntityが更新対象の場合、タイムスタンプ項目を更新しつつ、削除の場合はDeleteを更新に差し替えています。

```cs:Program.cs
builder.Services.AddDbContext<AppDbContext>((context, options) =>
{
    var loggerFactory = context.GetRequiredService<ILoggerFactory>();
    var connectionString = builder.Configuration.GetConnectionString("rdb_write");
    options.UseMySql(
            connectionString,
            ServerVersion.Parse("8.0"))
        .UseLoggerFactory(loggerFactory)
        .UseSnakeCaseNamingConvention();
    options.AddInterceptors(new GlobalSaveChangesInterceptor(new DateTimeService()));
});
```

<details>
<summary>IHasTimestamp, DateTimeService, GlobalSaveChangesInterceptorの実装</summary>
<div>

```cs
public interface IHasTimestamp
{
    DateTimeOffset? CreatedAt { get; set; }
    DateTimeOffset? UpdatedAt { get; set; }
    DateTimeOffset? DeletedAt { get; set; }
}
public interface IDateTimeService
{
    DateTimeOffset Now { get; }
}
public class DateTimeService : IDateTimeService
{
    public DateTimeOffset Now => DateTime.Now;
}
public class GlobalSaveChangesInterceptor : SaveChangesInterceptor
{
    private readonly IDateTimeService _dateTimeService;
    public GlobalSaveChangesInterceptor(IDateTimeService dateTimeService)
    {
        _dateTimeService = dateTimeService;
    }

    public override InterceptionResult<int> SavingChanges(
      DbContextEventData eventData,
      InterceptionResult<int> result)
    {
        SavingChangesInternal(eventData);
        return base.SavingChanges(eventData, result);
    }
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
      DbContextEventData eventData,
      InterceptionResult<int> result,
      CancellationToken cancellationToken = default)
    {
        SavingChangesInternal(eventData);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private void SavingChangesInternal(DbContextEventData eventData)
    {
        var context = eventData.Context;
        if (context == null)
            return;
        var now = _dateTimeService.Now;
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.Entity is not IHasTimestamp entity)
                continue;

            switch (entry.State)
            {
                case EntityState.Deleted:
                    entry.State = EntityState.Modified;
                    entity.DeletedAt = now;
                    continue;
                case EntityState.Modified:
                    entity.UpdatedAt = now;
                    continue;
                case EntityState.Added:
                    entity.CreatedAt = now;
                    continue;
                default:
                    continue;
            }
        }
    }
}
```
</div></details>

## データトラッキングを使った通常の更新

データトラッキングを使ってデータを更新する場合は、データを読み込んだ後にプログラムの手続きとして変更を行い、SaveChangesのタイミングでデータベースに書き戻します。

```cs
foreach (var blog in _appDbAppContext.SampleBlogs.Where(blog => blog.Title.Contains(oldValue)))
{
    blog.Title = blog.Title.Replace(oldValue, newValue);
}
await _appDbAppContext.SaveChangesAsync();
```

この場合SQLは次の2回に分けて発行されます。
1つ目は検索してDataContextに読み込むためのSQLです。
ちゃんと削除フラグを考慮して検索していますね。

```sql:発行されるSQL(データの読み込み部分)
SELECT `s`.`id`, `s`.`created_at`, `s`.`deleted_at`, `s`.`row_version`, `s`.`title`, `s`.`updated_at`
FROM `sample_blogs` AS `s`
WHERE `s`.`deleted_at` IS NULL AND ((@__oldValue_0 LIKE '') OR (LOCATE(@__oldValue_0, `s`.`title`) > 0))
```

2つ目はDataContextの変更を書き戻すSQLです。今回は対象のレコードが2件だったので、2レコード分のUpdate文が一回の通信でデータベースに発行されています。また、`updated_at`に関してはInterceptorが勝手に更新していることをが分かります。

```sql:発行されるSQL(データの更新部分)
UPDATE `sample_blogs` SET `title` = @p0, `updated_at` = @p1
WHERE `id` = @p2 AND `row_version` = @p3;
SELECT `row_version`
FROM `sample_blogs`
WHERE ROW_COUNT() = 1 AND `id` = @p2;

UPDATE `sample_blogs` SET `title` = @p4, `updated_at` = @p5
WHERE `id` = @p6 AND `row_version` = @p7;
SELECT `row_version`
FROM `sample_blogs`
WHERE ROW_COUNT() = 1 AND `id` = @p6;
```

処理としては問題なく完了しますが、数千のレコードを更新しようとすると、データベースとの通信であったり、SQLの実行回数の面であったり、データの変更差分の検知であったりで性能に影響がありそうです。

## ExecuteUpdate(EFCore7)

[ExecuteUpdateAsync](https://learn.microsoft.com/ja-jp/ef/core/what-is-new/ef-core-7.0/whatsnew#basic-executeupdate-examples)を使うと、対象のレコードを一回のSQLで更新するようになります。

```cs
await _appDbAppContext
    .SampleBlogs
    .Where(b => b.Title.Contains(oldValue))
    .ExecuteUpdateAsync(_ => _
        .SetProperty(b => b.Title, b => b.Title.Replace(oldValue, newValue)));
```

この場合次のようなSQLが発行されます。

```sql
UPDATE `sample_blogs` AS `s`
SET `s`.`title` = REPLACE(`s`.`title`, @__oldValue_0, @__newValue_1)
WHERE `s`.`deleted_at` IS NULL AND ((@__oldValue_0 LIKE '') OR (LOCATE(@__oldValue_0, `s`.`title`) > 0))
```

あっ、削除フラグは考慮されているけれど、最終更新日が考慮されていませんね。

## 注意点

ExecuteUpdateはSaveChangesを経由しないためいくつか注意点があります。
大きくは下記の3つです。

- QueryFilterは適用されるけれど、Interceptorは適用されない
- ConcurrencyCheckが考慮されない
- トラッキング中のデータは考慮されない

### QueryFilterは適用されるけれど、Interceptorは適用されない

ExecuteUpdateが実行時にDataContextのSaveChangesを経由しないのでInterceptorは反応しません。
ExecuteUpdateを利用する場合はQueryFilterは適用されるけれど、Interceptorは適用されないことを覚えておきましょう。

```cs
var now = _dateTimeService.Now;
await _appDbAppContext
    .SampleBlogs
    .Where(b => b.Title.Contains(oldValue))
    .ExecuteUpdateAsync(_ => _
        .SetProperty(b => b.Title, b => b.Title.Replace(oldValue, newValue))
        .SetProperty(b => b.UpdatedAt, now)
    );
```

更新されましたね。

```sql
UPDATE `sample_blogs` AS `s`
SET `s`.`updated_at` = @__now_2,
  `s`.`title` = REPLACE(`s`.`title`, @__oldValue_0, @__newValue_1)
WHERE `s`.`deleted_at` IS NULL AND ((@__oldValue_0 LIKE '') OR (LOCATE(@__oldValue_0, `s`.`title`) > 0))
```

### ConcurrencyCheckが考慮されない

上のSQLを見るとトラッキングを使った更新では考慮されていた、`row_version`が考慮されていない点に注意が必要です。一度のUpdate文で更新しようと思ったらどうしようもないのでこれは制限ですね。

ExecuteUpdate時はConcurrencyCheckを使った楽観ロックは利用できないので、更新は常に上書きになることに注意しましょう。

### トラッキング中のデータは考慮されない

DataContextでトラッキング中のデータからしてみると、他のDataContextで更新された時と同じように見えます。SaveChangesでの更新とExecuteUpdateでの更新は混ぜないようにしましょう。

```cs
var oldValue = "EF";
var newValue = "EntityFramework";

var blogs = await _appDbAppContext.SampleBlogs.ToListAsync();
Console.WriteLine("1.トラッキング中のデータ");
foreach (var blog in blogs)
{
    Console.WriteLine($"- {blog.Title}");
    blog.Title = "ダミーで更新";
}

var now = _dateTimeService.Now;
await _appDbAppContext
    .SampleBlogs
    .Where(b => b.Title.Contains(oldValue))
    .ExecuteUpdateAsync(_ => _
        .SetProperty(b => b.Title, b => b.Title.Replace(oldValue, newValue))
        .SetProperty(b => b.UpdatedAt, now)
    );

Console.WriteLine("2.トラッキング中のデータ");
foreach (var blog in blogs)
    Console.WriteLine($"- {blog.Title}");

Console.WriteLine("3.最新のデータ");
foreach (var blog in await _appDbAppContext.SampleBlogs.ToListAsync())
    Console.WriteLine($"- {blog.Title}");

// もちろんDataContext側を反映すると`DbUpdateConcurrencyException`
// await _appDbAppContext.SaveChangesAsync();
```

ここまでの流れで、2はデータ更新前に取得したデータなので、データベース中の最新データが表示されないのは何となく予想できると思います。間違えそうなのは3で、改めて最新を取得しているように見えますがこれも2と同様にトラッキング中のデータを表示してしまいます。

```
1.トラッキング中のデータ
- EFのInterceptorについて
- EFとEFCoreの違い
- ここがつらいよEF
- テスト投稿

===============
この時点でデータベースは次の値になっているはず
- EntityFrameworkのInterceptorについて
- EntityFrameworkとEntityFrameworkCoreの違い
- ここがつらいよEntityFramework
- テスト投稿
===============

2.トラッキング中のデータ
- ダミーで更新
- ダミーで更新
- ダミーで更新
- ダミーで更新
3.最新のデータ
- ダミーで更新
- ダミーで更新
- ダミーで更新
- ダミーで更新
```

これは複数のDataContextを使った場合にも発生するためExecuteUpdateを使った場合に限ったことではないのですが、DataContextが読み込み済みのデータに関してはキャッシュを使ってしまいます。いわゆるキャッシュバスティングというやつですね。

https://dotnetdetail.net/entity-framework-core-3-0-cache-busting/

例えば、このまま更新などをする場合に問題はありますが、AsNoTrakingで読み込めば取得はできます。

```cs
Console.WriteLine("4.最新のデータ2");
foreach (var blog in await _appDbAppContext.SampleBlogs.AsNoTracking().ToListAsync())
    Console.WriteLine($"- {blog.Title}");
```

```
4.最新のデータ2
- EntityFrameworkのInterceptorについて
- EntityFrameworkとEntityFrameworkCoreの違い
- ここがつらいよEntityFramework
- テスト投稿
```

## EFCorePlusのUpdateAsync(EFCore7以前)

ExecuteUpdateはEFCore7のメソッドなので、それ以前を利用する場合はサードパーティー製のライブラリを利用する必要があります。例えば、EFCorePlusのUpdateAsyncを利用した場合は次のようになります。基本的にはExecuteUpdateを使った場合と同じ注意点があるので注意しましょう。

```cs
var currentTime = DateTime.Now;
await DbContext.SampleBlogs
    .Where(blog => blog.Title.Contains(oldValue))
    .UpdateAsync(blog => new()
    {
        Title = blog.Title.Replace(oldValue, newValue),
        UpdatedAt = currentTime
    });
```

## おわりに

EFCore 7は.NET6でも利用できるので使い始めて、一番うれしかったのはこのメソッドが使えるようになったことですね。