显式跟踪实体

每个 DbContext 实例跟踪对实体所做的更改。 当调用 SaveChanges 时,这些跟踪实体反过来会驱动对数据库的更改。

当同一 DbContext 实例用于查询实体并通过调用 SaveChanges来更新实体时,Entity Framework Core (EF Core) 更改跟踪效果最佳。 这是因为 EF Core 会自动跟踪查询实体的状态,然后在调用 SaveChanges 时检测对这些实体所做的任何更改。 EF Core 中的更改跟踪中介绍了此方法。

Tip

本文档假设你已了解实体状态和 EF Core 更改跟踪的基础知识。 有关这些主题的详细信息,请参阅 EF Core 中的更改跟踪

Tip

可以通过 从 GitHub 下载示例代码来运行和调试本文档中的所有代码。

Introduction

实体可以显式地“附加到” DbContext 上,使上下文能够跟踪这些实体。 这在以下情况下主要有用:

  1. 创建将插入到数据库中的新实体。
  2. 重新附加以前由其他 DbContext 实例查询的已断开连接的实体

大多数应用程序都需要其中的第一个,主要由 DbContext.Add 方法处理。

只有在未跟踪实体时,更改实体或实体关系的应用程序才需要第二个操作。 例如,Web 应用程序可能会将实体发送到 Web 客户端,用户在其中进行更改并将实体发送回。 这些实体称为“已断开连接”,因为它们最初是从 DbContext 查询的,但随后在发送到客户端时与该上下文断开连接。

现在,Web 应用程序必须重新附加这些实体,以便再次追踪它们的变化,并清楚地表明已进行的更改,从而使 SaveChanges 可以对数据库进行适当的更新。 这主要由 DbContext.AttachDbContext.Update 方法处理。

Tip

通常不需要将实体附加到从中查询的 同一 DbContext 实例 。 不要定期执行无跟踪查询,然后将返回的实体附加到同一上下文。 这比使用跟踪查询慢,还可能造成影子属性值缺失等问题,更难正确操作。

生成的键值与显式键值

默认情况下,整数和 GUID 键属性 配置为使用 自动生成的键值。 这具有更改跟踪的主要优势:未设置键值指示实体为“new”。 通过“new”,我们表示尚未插入到数据库中。

以下各节使用了两个模型。 第一个配置为 不使用 生成的键值:

public class Blog
{
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public int Id { get; set; }

    public string Name { get; set; }

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

public class Post
{
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    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; }
}

非生成(即显式设置)键值首先显示在每个示例中,因为一切都非常明确且易于遵循。 然后,下面是使用生成的键值的示例:

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; }
}

请注意,此模型中的键属性不需要在此处进行其他配置,因为使用生成的键值是 简单整数键的默认值

插入新实体

显式键值

要通过 Added 插入实体,必须以 SaveChanges 状态跟踪该实体。 实体通常通过调用DbContext.AddDbContext.AddRangeDbContext.AddAsyncDbContext.AddRangeAsync或在DbSet<TEntity>上调用等效的方法,将其置于“已添加”状态。

Tip

这些方法在更改跟踪的上下文中都以相同的方式工作。 有关详细信息,请参阅 其他更改跟踪功能

例如,若要开始跟踪新博客,

context.Add(
    new Blog { Id = 1, Name = ".NET Blog", });

在此调用后检查更改跟踪器调试视图,可发现上下文正以 Added 状态跟踪新实体:

Blog {Id: 1} Added
  Id: 1 PK
  Name: '.NET Blog'
  Posts: []

但是,Add 方法不仅适用于单个实体。 实际上,它们开始跟踪整个相关实体图,使这些实体全部处于 状态Added。 例如,若要插入新的博客和关联的新文章,请执行以下作:

context.Add(
    new Blog
    {
        Id = 1,
        Name = ".NET Blog",
        Posts =
        {
            new Post
            {
                Id = 1,
                Title = "Announcing the Release of EF Core 5.0",
                Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..."
            },
            new Post
            {
                Id = 2,
                Title = "Announcing F# 5",
                Content = "F# 5 is the latest version of F#, the functional programming language..."
            }
        }
    });

上下文现在跟踪所有这些实体, 如下所示 Added

Blog {Id: 1} Added
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}]
Post {Id: 1} Added
  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} Added
  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}

请注意,已为上述示例中的 Id 键属性设置显式值。 这是因为此处的模型已配置为使用显式设置的键值,而不是自动生成的键值。 如果不使用生成的密钥,则必须在调用Add显式设置密钥属性。 然后,调用 SaveChanges 时插入这些键值。 例如,使用 SQLite 时:

-- Executed DbCommand (0ms) [Parameters=[@p0='1' (DbType = String), @p1='.NET Blog' (Size = 9)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Blogs" ("Id", "Name")
VALUES (@p0, @p1);

-- Executed DbCommand (0ms) [Parameters=[@p2='1' (DbType = String), @p3='1' (DbType = String), @p4='Announcing the release of EF Core 5.0, a full featured cross-platform...' (Size = 72), @p5='Announcing the Release of EF Core 5.0' (Size = 37)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Posts" ("Id", "BlogId", "Content", "Title")
VALUES (@p2, @p3, @p4, @p5);

-- Executed DbCommand (0ms) [Parameters=[@p0='2' (DbType = String), @p1='1' (DbType = String), @p2='F# 5 is the latest version of F#, the functional programming language...' (Size = 72), @p3='Announcing F# 5' (Size = 15)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Posts" ("Id", "BlogId", "Content", "Title")
VALUES (@p0, @p1, @p2, @p3);

SaveChanges 完成后,所有这些实体都会在 Unchanged 状态中跟踪,因为这些实体现在存在于数据库中:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}]
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} Unchanged
  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}

生成的键值

如上所述,整数和 GUID 键属性 配置为默认使用 自动生成的键值 。 这意味着应用程序 不得显式设置任何键值。 例如,若要插入新的博客,并发布所有包含生成的键值的文章:

context.Add(
    new Blog
    {
        Name = ".NET Blog",
        Posts =
        {
            new Post
            {
                Title = "Announcing the Release of EF Core 5.0",
                Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..."
            },
            new Post
            {
                Title = "Announcing F# 5",
                Content = "F# 5 is the latest version of F#, the functional programming language..."
            }
        }
    });

与显式键值一样,上下文现在将跟踪所有这些实体,如下所示 Added

Blog {Id: -2147482644} Added
  Id: -2147482644 PK Temporary
  Name: '.NET Blog'
  Posts: [{Id: -2147482637}, {Id: -2147482636}]
Post {Id: -2147482637} Added
  Id: -2147482637 PK Temporary
  BlogId: -2147482644 FK Temporary
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: {Id: -2147482644}
Post {Id: -2147482636} Added
  Id: -2147482636 PK Temporary
  BlogId: -2147482644 FK Temporary
  Content: 'F# 5 is the latest version of F#, the functional programming...'
  Title: 'Announcing F# 5'
  Blog: {Id: -2147482644}

请注意,在这种情况下,已为每个实体生成 临时键值 。 EF Core 使用这些值,直到调用 SaveChanges,此时从数据库读取实际键值。 例如,使用 SQLite 时:

-- Executed DbCommand (0ms) [Parameters=[@p0='.NET Blog' (Size = 9)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Blogs" ("Name")
VALUES (@p0);
SELECT "Id"
FROM "Blogs"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

-- Executed DbCommand (0ms) [Parameters=[@p1='1' (DbType = String), @p2='Announcing the release of EF Core 5.0, a full featured cross-platform...' (Size = 72), @p3='Announcing the Release of EF Core 5.0' (Size = 37)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Posts" ("BlogId", "Content", "Title")
VALUES (@p1, @p2, @p3);
SELECT "Id"
FROM "Posts"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

-- Executed DbCommand (0ms) [Parameters=[@p0='1' (DbType = String), @p1='F# 5 is the latest version of F#, the functional programming language...' (Size = 72), @p2='Announcing F# 5' (Size = 15)], 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();

SaveChanges 完成后,已将所有实体更新为使用实际键值,并以 Unchanged 状态跟踪这些实体,因为它们现在与数据库中的状态匹配:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}]
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} Unchanged
  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}

这与使用显式键值的上一个示例完全相同的结束状态。

Tip

即使使用生成的键值,仍可以设置显式键值。 然后,EF Core 将尝试使用此键值进行插入。 某些数据库配置(包括包含标识列的 SQL Server)不支持此类插入操作,并会引发错误(请参阅这些文档以获取解决方法)。

附加现有实体

显式键值

Unchanged 状态跟踪从查询返回的实体。 状态 Unchanged 表示自查询实体以来尚未对其进行修改。 可通过使用 DbContext.AttachDbContext.AttachRange 或者 DbSet<TEntity> 上的等效方法,将断开连接的实体置于此状态,其中该实体可能在 HTTP 请求中从 Web 客户端返回。 例如,若要开始跟踪现有博客,

context.Attach(
    new Blog { Id = 1, Name = ".NET Blog", });

Note

为了简单起见,此处的示例是通过显式使用new 创建实体。 通常,实体实例来自另一个源,例如从客户端反序列化,或从 HTTP Post 中的数据创建。

在此调用后检查更改跟踪器调试视图,可发现上下文正以 Unchanged 状态跟踪实体:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Posts: []

就像 AddAttach 实际上将连接实体的整个图设置为 Unchanged 状态。 例如,若要附加现有博客和关联的现有文章,

context.Attach(
    new Blog
    {
        Id = 1,
        Name = ".NET Blog",
        Posts =
        {
            new Post
            {
                Id = 1,
                Title = "Announcing the Release of EF Core 5.0",
                Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..."
            },
            new Post
            {
                Id = 2,
                Title = "Announcing F# 5",
                Content = "F# 5 is the latest version of F#, the functional programming language..."
            }
        }
    });

上下文现在跟踪所有这些实体, 如下所示 Unchanged

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}]
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} Unchanged
  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}

此时调用 SaveChanges 将不起作用。 所有实体都标记为 Unchanged,因此数据库中没有任何更新内容。

生成的键值

如上所述,整数和 GUID 键属性 配置为默认使用 自动生成的键值 。 这在处理断开连接的实体时具有主要优势:未设置键值表示实体尚未插入数据库中。 这允许更改跟踪器自动检测新实体并将其置于 Added 状态。 例如,考虑附加含一个博客和多篇文章的此图:

context.Attach(
    new Blog
    {
        Id = 1,
        Name = ".NET Blog",
        Posts =
        {
            new Post
            {
                Id = 1,
                Title = "Announcing the Release of EF Core 5.0",
                Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..."
            },
            new Post
            {
                Id = 2,
                Title = "Announcing F# 5",
                Content = "F# 5 is the latest version of F#, the functional programming language..."
            },
            new Post
            {
                Title = "Announcing .NET 5.0",
                Content = ".NET 5.0 includes many enhancements, including single file applications, more..."
            },
        }
    });

博客的键值为 1,指示该博客已存在于数据库中。 其中两个帖子还设置了键值,但第三个帖子没有设置。 EF Core 会将此键值视为 0,这是整数的 CLR 默认值。 这会导致 EF Core 将新实体标记为 Added 而不是 Unchanged

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}, {Id: -2147482636}]
Post {Id: -2147482636} Added
  Id: -2147482636 PK Temporary
  BlogId: 1 FK
  Content: '.NET 5.0 includes many enhancements, including single file a...'
  Title: 'Announcing .NET 5.0'
  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} Unchanged
  Id: 2 PK
  BlogId: 1 FK
  Content: 'F# 5 is the latest version of F#, the functional programming...'

此时调用 SaveChanges 不会对 Unchanged 实体执行任何作,但会将新实体插入数据库中。 例如,使用 SQLite 时:

-- Executed DbCommand (0ms) [Parameters=[@p0='1' (DbType = String), @p1='.NET 5.0 includes many enhancements, including single file applications, more...' (Size = 80), @p2='Announcing .NET 5.0' (Size = 19)], 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 能够 自动区分断开连接的图中的新实体和现有实体。 简言之,使用生成的键时,EF Core 将始终在该实体未设置键值时插入实体。

更新现有实体

显式键值

DbContext.UpdateDbContext.UpdateRange以及DbSet<TEntity>上的等效方法的行为与上述Attach方法完全一致,只是将实体放入Modified而不是Unchanged状态。 例如,开始将现有博客作为 Modified 状态跟踪:

context.Update(
    new Blog { Id = 1, Name = ".NET Blog", });

在此调用后检查更改跟踪器调试视图,可发现上下文正以 Modified 状态跟踪此实体:

Blog {Id: 1} Modified
  Id: 1 PK
  Name: '.NET Blog' Modified
  Posts: []

就像 AddAttach 一样,Update 实际上将相关实体的整个图标识为 。 例如,若要将现有博客和关联的现有文章附加为 Modified

context.Update(
    new Blog
    {
        Id = 1,
        Name = ".NET Blog",
        Posts =
        {
            new Post
            {
                Id = 1,
                Title = "Announcing the Release of EF Core 5.0",
                Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..."
            },
            new Post
            {
                Id = 2,
                Title = "Announcing F# 5",
                Content = "F# 5 is the latest version of F#, the functional programming language..."
            }
        }
    });

上下文现在跟踪所有这些实体, 如下所示 Modified

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

此时调用 SaveChanges 会导致更新发送到数据库并应用于所有这些实体。 例如,使用 SQLite 时:

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

-- Executed DbCommand (0ms) [Parameters=[@p3='1' (DbType = String), @p0='1' (DbType = String), @p1='Announcing the release of EF Core 5.0, a full featured cross-platform...' (Size = 72), @p2='Announcing the Release of EF Core 5.0' (Size = 37)], CommandType='Text', CommandTimeout='30']
UPDATE "Posts" SET "BlogId" = @p0, "Content" = @p1, "Title" = @p2
WHERE "Id" = @p3;
SELECT changes();

-- Executed DbCommand (0ms) [Parameters=[@p3='2' (DbType = String), @p0='1' (DbType = String), @p1='F# 5 is the latest version of F#, the functional programming language...' (Size = 72), @p2='Announcing F# 5' (Size = 15)], CommandType='Text', CommandTimeout='30']
UPDATE "Posts" SET "BlogId" = @p0, "Content" = @p1, "Title" = @p2
WHERE "Id" = @p3;
SELECT changes();

生成的键值

Attach 一样,对于 Update,生成的键值也有着同样的主要优势:未设置的键值表示实体是新实体并且尚未插入数据库中。 与上一样 Attach,这允许 DbContext 自动检测新实体并将其置于 Added 状态。 例如,考虑在此博客和文章图中调用 Update

context.Update(
    new Blog
    {
        Id = 1,
        Name = ".NET Blog",
        Posts =
        {
            new Post
            {
                Id = 1,
                Title = "Announcing the Release of EF Core 5.0",
                Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..."
            },
            new Post
            {
                Id = 2,
                Title = "Announcing F# 5",
                Content = "F# 5 is the latest version of F#, the functional programming language..."
            },
            new Post
            {
                Title = "Announcing .NET 5.0",
                Content = ".NET 5.0 includes many enhancements, including single file applications, more..."
            },
        }
    });

与示例 Attach 一样,没有键值的帖子被检测为新帖子,并设置为 Added 状态。 其他实体标记为 Modified

Blog {Id: 1} Modified
  Id: 1 PK
  Name: '.NET Blog' Modified
  Posts: [{Id: 1}, {Id: 2}, {Id: -2147482633}]
Post {Id: -2147482633} Added
  Id: -2147482633 PK Temporary
  BlogId: 1 FK
  Content: '.NET 5.0 includes many enhancements, including single file a...'
  Title: 'Announcing .NET 5.0'
  Blog: {Id: 1}
Post {Id: 1} Modified
  Id: 1 PK
  BlogId: 1 FK Modified Originally <null>
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...' Modified
  Title: 'Announcing the Release of EF Core 5.0' Modified
  Blog: {Id: 1}
Post {Id: 2} Modified
  Id: 2 PK
  BlogId: 1 FK Modified Originally <null>
  Content: 'F# 5 is the latest version of F#, the functional programming...' Modified
  Title: 'Announcing F# 5' Modified
  Blog: {Id: 1}

此时调用 SaveChanges 将导致所有现有实体的更新发送到数据库,同时插入新实体。 例如,使用 SQLite 时:

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

-- Executed DbCommand (0ms) [Parameters=[@p3='1' (DbType = String), @p0='1' (DbType = String), @p1='Announcing the release of EF Core 5.0, a full featured cross-platform...' (Size = 72), @p2='Announcing the Release of EF Core 5.0' (Size = 37)], CommandType='Text', CommandTimeout='30']
UPDATE "Posts" SET "BlogId" = @p0, "Content" = @p1, "Title" = @p2
WHERE "Id" = @p3;
SELECT changes();

-- Executed DbCommand (0ms) [Parameters=[@p3='2' (DbType = String), @p0='1' (DbType = String), @p1='F# 5 is the latest version of F#, the functional programming language...' (Size = 72), @p2='Announcing F# 5' (Size = 15)], CommandType='Text', CommandTimeout='30']
UPDATE "Posts" SET "BlogId" = @p0, "Content" = @p1, "Title" = @p2
WHERE "Id" = @p3;
SELECT changes();

-- Executed DbCommand (0ms) [Parameters=[@p0='1' (DbType = String), @p1='.NET 5.0 includes many enhancements, including single file applications, more...' (Size = 80), @p2='Announcing .NET 5.0' (Size = 19)], 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 中的标识解析中所述。

删除现有实体

必须以 Deleted 状态跟踪将由 SaveChanges 删除的实体。 实体通常通过调用DeletedDbContext.Remove之一或在DbContext.RemoveRange上调用等效的方法,被置于DbSet<TEntity>状态。 例如,将现有的帖子标记为 Deleted

context.Remove(
    new Post { Id = 2 });

在此调用后检查更改跟踪器调试视图,可发现上下文正以 Deleted 状态跟踪实体:

Post {Id: 2} Deleted
  Id: 2 PK
  BlogId: <null> FK
  Content: <null>
  Title: <null>
  Blog: <null>

调用 SaveChanges 时,将删除此实体。 例如,使用 SQLite 时:

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

SaveChanges 完成后,已删除的实体将从 DbContext 中分离,因为它不再存在于数据库中。 因为没有跟踪任何实体,所以调试视图为空。

删除从属实体/子实体

从图形中删除从属/子实体比删除主体/父实体更为简单。 有关详细信息,请参阅下一部分以及 更改外键和导航

在使用 Remove 创建的实体上调用 new 的做法并不常见。 此外,与 AddAttachUpdate 不同,通常不会对尚未在 RemoveUnchanged 状态中跟踪的实体调用 Modified。 相反,通常会跟踪一个单独的实体或一组相关实体的图,然后对需要删除的实体调用 Remove。 此跟踪实体图通常由以下任一项创建:

  1. 运行实体查询
  2. 如前述部分所述,在断开连接的实体图上使用AttachUpdate方法。

例如,上一部分中的代码更有可能从客户端获取数据,然后执行类似的操作。

context.Attach(post);
context.Remove(post);

这种做法对未跟踪的实体调用 Remove,使其先被附加再被标记为 Deleted,因此与上一个示例的行为完全相同。

在更现实的示例中,首先附加实体图,然后将其中一些实体标记为已删除。 例如:

// Attach a blog and associated posts
context.Attach(blog);

// Mark one post as Deleted
context.Remove(blog.Posts[1]);

除调用了Unchanged的实体之外,所有实体均标记为Remove

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}]
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}

调用 SaveChanges 时,将删除此实体。 例如,使用 SQLite 时:

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

SaveChanges 完成后,已删除的实体将从 DbContext 中分离,因为它不再存在于数据库中。 其他实体仍处于 Unchanged 状态:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{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}

删除主要/父级实体

连接两个实体类型的每个关系都有一个主体或父端,以及一个从属或子端。 依赖/子实体是具有外键属性的实体。 在一对多关系中,主端/父端为“一”,依赖端/子端为“多”。 有关详细信息,请参阅 关系

在前面的示例中,我们删除了一篇文章,这是博客文章一对多关系中的依赖/子实体。 这相对简单,因为删除依赖/子实体对其他实体没有任何影响。 另一方面,删除主实体或父实体时,必须同时影响所有依赖实体或子实体。 而不删除会使外键值引用不再存在的主键值。 这是一个无效的模型状态,导致大多数数据库中出现引用约束错误。

可以通过两种方式处理此无效的模型状态:

  1. 将 FK 值设置为 null。 这表示受养人/子女不再与任何主申请人/父母相关。 这是可选关系的默认值,其中外键必须为 null。 将 FK 设置为 null 的做法对于必选关系(其中外键通常不可为 null)无效。
  2. 删除依赖实体/子实体。 这是必需关系的默认值,也对可选关系有效。

有关更改跟踪和关系的详细信息,请参阅更改外键和导航

可选关系

在我们已使用的模型中,Post.BlogId 外键属性可为 null。 这意味着关系是可选的,因此 EF Core 的默认行为是在删除博客时将外键属性设置为 BlogId null。 例如:

// Attach a blog and associated posts
context.Attach(blog);

// Mark the blog as deleted
context.Remove(blog);

在调用 后检查Remove,可发现博客按预期那样被标记为 Deleted

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

更有趣的是,所有相关帖子现在都标记为 Modified。 这是因为每个实体中的外键属性已设置为 null。 调用 SaveChanges 会将数据库中每个帖子的外键值更新为 null,然后删除博客:

-- Executed DbCommand (0ms) [Parameters=[@p1='1' (DbType = String), @p0=NULL], CommandType='Text', CommandTimeout='30']
UPDATE "Posts" SET "BlogId" = @p0
WHERE "Id" = @p1;
SELECT changes();

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

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

SaveChanges 完成后,已删除的实体将从 DbContext 中分离,因为它不再存在于数据库中。 其他实体现在已通过外键值 null 标记为 Unchanged,该状态与数据库的状态匹配:

Post {Id: 1} Unchanged
  Id: 1 PK
  BlogId: <null> FK
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: <null>
Post {Id: 2} Unchanged
  Id: 2 PK
  BlogId: <null> FK
  Content: 'F# 5 is the latest version of F#, the functional programming...'
  Title: 'Announcing F# 5'
  Blog: <null>

必需的关系

Post.BlogId如果外键属性不可为 null,则博客和文章之间的关系将变为“必需”。 在这种情况下,在删除主体/父实体时,EF Core 将默认删除从属/子实体。 例如,删除包含相关文章的博客,如前面的示例所示:

// Attach a blog and associated posts
context.Attach(blog);

// Mark the blog as deleted
context.Remove(blog);

在调用 后检查Remove,可发现博客再次按预期那样被标记为 Deleted

Blog {Id: 1} Deleted
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}]
Post {Id: 1} Deleted
  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}

更有趣的是,在这种情况下,所有相关的帖子也被标记为 Deleted。 调用 SaveChanges 会导致博客和所有相关文章从数据库中删除:

-- Executed DbCommand (0ms) [Parameters=[@p0='1' (DbType = String)], CommandType='Text', CommandTimeout='30']
DELETE FROM "Posts"
WHERE "Id" = @p0;
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=[@p1='1' (DbType = String)], CommandType='Text', CommandTimeout='30']
DELETE FROM "Blogs"
WHERE "Id" = @p1;

SaveChanges 完成后,所有已删除的实体都会从 DbContext 中分离,因为它们不再存在于数据库中。 因此,调试视图中的输出为空。

Note

本文档只是浅尝辄止介绍如何在 EF Core 中处理(或管理)关系。 有关建模关系以及更改外键和导航的详细信息,请参阅“关系”,了解有关在调用 SaveChanges 时更新/删除依赖实体/子实体的详细信息。

使用 TrackGraph 进行自定义跟踪

ChangeTracker.TrackGraph的工作方式与AddAttachUpdate类似,但在跟踪之前,它会为每个实体实例生成一个回调。 这允许在确定如何跟踪图形中的单个实体时使用自定义逻辑。

例如,假设 EF Core 在跟踪具有生成的键值的实体时使用的规则:如果键值为零,则实体为新实体,应插入。 让我们扩展此规则来说明键值是否为负值,则应删除实体。 这样,我们就可以更改断开连接图形实体中的主键值,以标记已删除的实体:

blog.Posts.Add(
    new Post
    {
        Title = "Announcing .NET 5.0",
        Content = ".NET 5.0 includes many enhancements, including single file applications, more..."
    }
);

var toDelete = blog.Posts.Single(e => e.Title == "Announcing F# 5");
toDelete.Id = -toDelete.Id;

然后,可以使用 TrackGraph 跟踪此断开连接的图形:

public static async Task UpdateBlog(Blog blog)
{
    using var context = new BlogsContext();

    context.ChangeTracker.TrackGraph(
        blog, node =>
        {
            var propertyEntry = node.Entry.Property("Id");
            var keyValue = (int)propertyEntry.CurrentValue;

            if (keyValue == 0)
            {
                node.Entry.State = EntityState.Added;
            }
            else if (keyValue < 0)
            {
                propertyEntry.CurrentValue = -keyValue;
                node.Entry.State = EntityState.Deleted;
            }
            else
            {
                node.Entry.State = EntityState.Modified;
            }

            Console.WriteLine($"Tracking {node.Entry.Metadata.DisplayName()} with key value {keyValue} as {node.Entry.State}");
        });

    await context.SaveChangesAsync();
}

对于图形中的每个实体,上述代码在 跟踪实体之前会检查主键值。 对于未设置(零)键值的情况,代码会执行 EF Core 通常会做的默认操作。 也就是说,如果未设置键,则实体将被标记为 Added。 如果设置了键且值为非负值,则实体将被标记为 Modified。 但是,如果找到负键值,则会还原其实际非负值,并将实体跟踪为 Deleted

运行此代码的输出为:

Tracking Blog with key value 1 as Modified
Tracking Post with key value 1 as Modified
Tracking Post with key value -2 as Deleted
Tracking Post with key value 0 as Added

Note

为简单起见,此代码假定每个实体都有一个名为整数的主键属性 Id。 这可以编码为抽象基类或接口。 或者,可以从元数据中获取 IEntityType 主键属性或属性,以便此代码适用于任何类型的实体。

TrackGraph 有两个重载。 在上述简单重载中,EF Core 确定何时停止遍历图形。 具体而言,当某个实体已被跟踪,或者回调没有开始对该实体进行跟踪时,它将停止从该给定实体访问新的相关实体。

高级重载ChangeTracker.TrackGraph<TState>(Object, TState, Func<EntityEntryGraphNode<TState>,Boolean>)包含一个回调函数,该函数返回一个布尔值。 如果回叫返回 false,停止图遍历;否则,继续遍历。 在使用此重载时,必须小心避免无限循环。

高级重载还允许向 TrackGraph 提供状态,然后将此状态传递给每个回调。