EF Core での変更の追跡

DbContext インスタンスによって、エンティティに加えられる変更が追跡されます。 さらに、これらの追跡対象エンティティによって、SaveChanges が呼び出されたときにデータベースへの変更が実行されます。

このドキュメントでは、Entity Framework Core (EF Core) の変更の追跡に関する概要と、それがクエリと更新にどのように関連しているかについて説明します。

ヒント

このドキュメントに含まれているすべてのコードは、GitHub からサンプル コードをダウンロードすることで実行およびデバッグできます。

ヒント

わかりやすくするために、このドキュメントでは SaveChanges などの同期メソッドを使用および参照しています。その非同期バージョンである SaveChangesAsync などは使用していません。 特に明記されていない限り、非同期メソッドの呼び出しと待機は置き換えることができます。

エンティティを追跡する方法

エンティティ インスタンスは、次の場合に追跡が開始されます。

  • データベースに対して実行されたクエリから返されるとき
  • AddAttachUpdate、または同様のメソッドによって明示的に DbContext にアタッチされるとき
  • 既存の追跡対象エンティティに接続された新しいエンティティとして検出されるとき

次の場合、エンティティ インスタンスは追跡されなくなります。

  • DbContext が破棄されるとき
  • 変更トラッカーがクリアされるとき (EF Core 5.0 以降)
  • エンティティが明示的にデタッチされるとき

DbContext は、DbContext の初期化と構成に関する記事で説明されているように、有効期間の短い作業単位を表すように設計されています。 つまり、DbContext の破棄は、エンティティの追跡を停止するための "通常の方法" です。 言い換えると、DbContext の有効期間は次のようになります。

  1. DbContext インスタンスを作成する
  2. いくつかのエンティティを追跡する
  3. エンティティにいくつかの変更を加える
  4. SaveChanges を呼び出してデータベースを更新する
  5. DbContext インスタンスを破棄する

ヒント

この方法を採用する場合は、変更トラッカーをクリアしたり、エンティティ インスタンスを明示的にデタッチしたりする必要はありません。 ただし、エンティティをデタッチする必要がある場合は、エンティティを 1 つずつデタッチするよりも ChangeTracker.Clear を呼び出す方が効率的です。

エンティティの状態

すべてのエンティティは、特定の EntityState に関連付けられています。

  • Detached エンティティは DbContext によって追跡されていません。
  • Added エンティティは新規で、まだデータベースに挿入されていません。 つまり、SaveChanges が呼び出されたときに挿入されます。
  • Unchanged エンティティは、データベースからクエリされてから変更されて "いません"。 クエリから返されるすべてのエンティティは、最初はこの状態になります。
  • Modified エンティティは、データベースからクエリされてから変更されています。 つまり、SaveChanges が呼び出されたときに更新されます。
  • Deleted エンティティはデータベースに存在していますが、SaveChanges が呼び出されたときに削除されるようにマークされています。

EF Core では、プロパティ レベルで変更が追跡されます。 たとえば、1 つのプロパティ値のみが変更された場合、データベースの更新によってその値のみが変更されます。 ただし、プロパティは、エンティティ自体が Modified 状態である場合にのみ、変更済みとしてマークされます。 (別の観点から見ると、Modified 状態は、少なくとも 1 つのプロパティ値が変更済みとしてマークされていることを意味します。)

次の表は、さまざまな状態をまとめたものです。

エンティティ状態 DbContext によって追跡される データベースに存在する プロパティが変更された SaveChanges でのアクション
Detached いいえ - - -
Added はい いいえ - 挿入
Unchanged はい はい いいえ -
Modified はい はい はい 更新
Deleted はい はい - 削除

注意

このテキストでは、わかりやすくするために、リレーショナル データベースの用語を使用しています。 通常、NoSQL データベースでも同様の操作がサポートされていますが、名前が異なる可能性があります。 詳細については、お使いのデータベース プロバイダーのドキュメントを参照してください。

クエリからの追跡

EF Core の変更の追跡が最も効果を発揮するのは、同じ DbContext インスタンスを使用してエンティティのクエリを実行し、SaveChanges を呼び出して更新する場合です。 これは、EF Core によって、クエリされたエンティティの状態が自動的に追跡され、SaveChanges が呼び出されたときにこれらのエンティティに加えられた変更が検出されるためです。

この方法には、エンティティ インスタンスを明示的に追跡する場合と比べていくつかの利点があります。

  • 単純である。 エンティティの状態を明示的に操作することが必要になる場合はほとんどありません。EF Core によって状態の変化が対処されます。
  • 更新の対象は、実際に変更された値のみに制限されます。
  • シャドウ プロパティの値は保持され、必要に応じて使用されます。 これは、外部キーがシャドウ状態で格納されている場合に特に重要になります。
  • プロパティの元の値は自動的に保持され、効率的な更新のために使用されます。

シンプルなクエリと更新

たとえば、シンプルなブログ/投稿モデルを考えてみましょう。

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    public IList<Post> Posts { get; } = new List<Post>();
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int? BlogId { get; set; }
    public Blog Blog { get; set; }
}

このモデルを使用して、ブログや投稿に対するクエリを実行した後、データベースにいくつかの更新を行うことができます。

using var context = new BlogsContext();

var blog = context.Blogs.Include(e => e.Posts).First(e => e.Name == ".NET Blog");

blog.Name = ".NET Blog (Updated!)";

foreach (var post in blog.Posts.Where(e => !e.Title.Contains("5.0")))
{
    post.Title = post.Title.Replace("5", "5.0");
}

context.SaveChanges();

SaveChanges を呼び出すと、次のデータベースの更新が発生します (データベースの例として SQLite を使用しています)。

-- Executed DbCommand (0ms) [Parameters=[@p1='1' (DbType = String), @p0='.NET Blog (Updated!)' (Size = 20)], CommandType='Text', CommandTimeout='30']
UPDATE "Blogs" SET "Name" = @p0
WHERE "Id" = @p1;
SELECT changes();

-- Executed DbCommand (0ms) [Parameters=[@p1='2' (DbType = String), @p0='Announcing F# 5.0' (Size = 17)], CommandType='Text', CommandTimeout='30']
UPDATE "Posts" SET "Title" = @p0
WHERE "Id" = @p1;
SELECT changes();

変更トラッカーのデバッグ ビューは、追跡されているエンティティとその状態を視覚化するための優れた方法です。 たとえば、上記のサンプルの SaveChanges を呼び出す前に、次のコードを挿入します。

context.ChangeTracker.DetectChanges();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

次の出力が生成されます。

Blog {Id: 1} Modified
  Id: 1 PK
  Name: '.NET Blog (Updated!)' Modified Originally '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}, {Id: 3}]
Post {Id: 1} Unchanged
  Id: 1 PK
  BlogId: 1 FK
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: {Id: 1}
Post {Id: 2} Modified
  Id: 2 PK
  BlogId: 1 FK
  Content: 'F# 5 is the latest version of F#, the functional programming...'
  Title: 'Announcing F# 5.0' Modified Originally 'Announcing F# 5'
  Blog: {Id: 1}

特に以下の点に注目してください。

  • Blog.Name プロパティは変更済み (Name: '.NET Blog (Updated!)' Modified Originally '.NET Blog') としてマークされており、その結果、ブログの状態が Modified になっています。
  • 投稿 2 の Post.Title プロパティは変更済み (Title: 'Announcing F# 5.0' Modified Originally 'Announcing F# 5') としてマークされており、その結果、この投稿の状態が Modified になっています。
  • 投稿 2 のその他のプロパティ値は変更されていないため、変更済みとしてマークされていません。 このため、これらの値はデータベースの更新に含まれていません。
  • その他の投稿は、どのような方法でも変更されていません。 そのため Unchanged 状態のままであり、データベースの更新には含まれていません。

クエリの後に挿入、更新、および削除を行う

前の例に含まれているような更新は、同じ作業単位の中で挿入や削除と組み合わせることができます。 例:

using var context = new BlogsContext();

var blog = context.Blogs.Include(e => e.Posts).First(e => e.Name == ".NET Blog");

// Modify property values
blog.Name = ".NET Blog (Updated!)";

// Insert a new Post
blog.Posts.Add(
    new Post
    {
        Title = "What’s next for System.Text.Json?", Content = ".NET 5.0 was released recently and has come with many..."
    });

// Mark an existing Post as Deleted
var postToDelete = blog.Posts.Single(e => e.Title == "Announcing F# 5");
context.Remove(postToDelete);

context.ChangeTracker.DetectChanges();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

context.SaveChanges();

次の点に注意してください。

  • ブログと関連する投稿がデータベースからクエリされ、追跡されます
  • Blog.Name プロパティが変更されます
  • ブログの既存の投稿のコレクションに新しい投稿が追加されます
  • 既存の投稿が、DbContext.Remove を呼び出すことによって削除対象としてマークされます

SaveChanges を呼び出す前に変更トラッカーのデバッグ ビューをもう一度確認すると、EF Core によってこれらの変更がどのように追跡されているかがわかります。

Blog {Id: 1} Modified
  Id: 1 PK
  Name: '.NET Blog (Updated!)' Modified Originally '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}, {Id: 3}, {Id: -2147482638}]
Post {Id: -2147482638} Added
  Id: -2147482638 PK Temporary
  BlogId: 1 FK
  Content: '.NET 5.0 was released recently and has come with many...'
  Title: 'What's next for System.Text.Json?'
  Blog: {Id: 1}
Post {Id: 1} Unchanged
  Id: 1 PK
  BlogId: 1 FK
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: {Id: 1}
Post {Id: 2} Deleted
  Id: 2 PK
  BlogId: 1 FK
  Content: 'F# 5 is the latest version of F#, the functional programming...'
  Title: 'Announcing F# 5'
  Blog: {Id: 1}

次のことに注意してください。

  • ブログは Modified としてマークされています。 これにより、データベースの更新が生成されます。
  • 投稿 2 は Deleted としてマークされています。 これにより、データベースの削除が生成されます。
  • 一時的な ID を持つ新しい投稿がブログ 1 に関連付けられ、Added としてマークされています。 これにより、データベースの挿入が生成されます。

これによって、SaveChanges が呼び出されたときに、次のデータベース コマンド (SQLite を使用) が生成されます。

-- Executed DbCommand (0ms) [Parameters=[@p1='1' (DbType = String), @p0='.NET Blog (Updated!)' (Size = 20)], CommandType='Text', CommandTimeout='30']
UPDATE "Blogs" SET "Name" = @p0
WHERE "Id" = @p1;
SELECT changes();

-- Executed DbCommand (0ms) [Parameters=[@p0='2' (DbType = String)], CommandType='Text', CommandTimeout='30']
DELETE FROM "Posts"
WHERE "Id" = @p0;
SELECT changes();

-- Executed DbCommand (0ms) [Parameters=[@p0='1' (DbType = String), @p1='.NET 5.0 was released recently and has come with many...' (Size = 56), @p2='What's next for System.Text.Json?' (Size = 33)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Posts" ("BlogId", "Content", "Title")
VALUES (@p0, @p1, @p2);
SELECT "Id"
FROM "Posts"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

エンティティの挿入と削除の詳細については、「エンティティの明示的な追跡」を参照してください。 EF Core でこのような変更が自動的に検出されるしくみについて詳しくは、「変更の検出と通知」を参照してください。

ヒント

ChangeTracker.HasChanges() を呼び出すと、SaveChanges によってデータベースが更新されるような変更が行われたかどうかを確認できます。 HasChanges から false が返される場合は、SaveChanges によって何も行われません。