显式跟踪实体

每个 DbContext 实例跟踪对实体所做的更改。 在调用 SaveChanges 时,这些跟踪的实体会相应地驱动对数据库的更改。

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

提示

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

提示

通过从 GitHub 下载示例代码,你可运行并调试到本文档中的所有代码。

提示

为了简单起见,本文档使用和引用同步方法(如 SaveChanges),而不是它们的异步等效方法(如 SaveChangesAsync)。 除非另有说明,否则可以替换调用并等待异步方法。

介绍

可将实体显式附加到 DbContext,这样上下文就可以跟踪这些实体。 这主要适用于以下两个操作:

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

大多数应用程序将需要其中的第一个操作,该操作主要由 DbContext.Add 方法进行处理。

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

Web 应用程序现在必须重新附加这些实体,以便再次跟踪它们,并且指示所做的更改,以便 SaveChanges 能够对数据库进行适当的更新。 这主要由 DbContext.AttachDbContext.Update 方法进行处理。

提示

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

生成的键值与显式键值

默认将整数和 GUID 键属性配置为使用自动生成的键值。 这在更改跟踪方面有一个主要优势:未设置的键值表明实体为新实体。 “新”是指尚未将它插入数据库中。

以下几部分使用了两个模型。 第一个模型被配置为不使用生成的键值

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

请注意,此模型中的键属性在此不需要额外的配置,因为使用生成的键值是简单的整数键的默认做法

插入新实体

显式键值

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

提示

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

例如,开始跟踪新博客:

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}

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

提示

即使在使用生成的键值时,仍可以设置显式键值。 EF Core 将尝试使用此键值进行插入。 一些数据库配置(包括 SQL Server 的“标识”列)不支持此类插入,会引发错误(请参阅这些文档中的变通方法)。

附加现有实体

显式键值

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

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

注意

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

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

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

Add 一样,Attach 实际上将连接的实体的整个图设置为 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。 例如,将现有博客和它所关联的现有文章附加为 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 删除的实体。 通常通过调用 DbContext.RemoveDbContext.RemoveRange 之一或者 DbSet<TEntity> 上的等效方法来将实体置于 Deleted 状态。 例如,将现有文章标记为 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 拆离已删除的实体,因为它不再存在于数据库中。 因为没有跟踪任何实体,所以调试视图为空。

删除依赖实体/子实体

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

在使用 new 创建的实体上调用 Remove 的做法并不常见。 此外,不同于 AddAttachUpdate,通常不会在未以 UnchangedModified 状态跟踪的实体上调用 Remove。 而典型的做法是:跟踪一个实体或跟踪相关实体的图,然后对应删除的实体调用 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 中拆离所有已删除的实体,因为它们不再存在于数据库中。 因此,调试视图的输出为空。

注意

本文档仅简单介绍对 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 void 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}");
        });

    context.SaveChanges();
}

对于图中每个实体,上面的代码都先检查主键值,再跟踪实体。 对于未设置的(零)键值,该代码执行 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

注意

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

TrackGraph 有两个重载。 在上面使用的简单重载中,EF Core 确定何时停止遍历图。 具体而言,如果遇到以下情况,它停止从给定实体访问新的相关实体:已跟踪了该实体,或回叫不开始跟踪实体。

高级重载 ChangeTracker.TrackGraph<TState>(Object, TState, Func<EntityEntryGraphNode<TState>,Boolean>) 具有返回布尔的回调。 如果回叫返回 false,停止图遍历;否则,继续遍历。 使用此重载时,必须谨慎,避免无限循环。

使用高级重载还可向 TrackGraph 提供状态,然后将此状态传递给每个回叫。