更改外键和导航

外键和导航概述

实体框架核心(EF Core)模型中的关系使用外键(FK)表示。 FK 包含关系中依赖实体或子实体的一个或多个属性。 当依赖/子实体上的外键属性的值与主体/父级上备用或主键(PK)属性的值匹配时,此依赖/子实体与给定主体/父实体相关联。

外键是一种在数据库中存储和处理关系的好方法,但在应用程序代码中处理多个关联实体时并不十分友好。 因此,大多数 EF Core 模型还会在 FK 表示形式上分层“导航”。 实体实例之间的导航在 C#/.NET 中表现为引用,这些引用反映了通过匹配外键值与主键或备用键值而形成的关联。

导航可以在关系的两侧使用,也可以仅限于一侧使用,或者可以完全不使用,只保留 FK 属性。 FK 属性可以通过使其成为 阴影属性来隐藏。 有关建模关系的详细信息,请参阅 “关系 ”。

小窍门

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

小窍门

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

示例模型

以下模型包含四种实体类型,它们之间的关系。 代码中的注释指示哪些属性是外键、主键和导航。

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

    public IList<Post> Posts { get; } = new List<Post>(); // Collection navigation
    public BlogAssets Assets { get; set; } // Reference navigation
}

public class BlogAssets
{
    public int Id { get; set; } // Primary key
    public byte[] Banner { get; set; }

    public int? BlogId { get; set; } // Foreign key
    public Blog Blog { get; set; } // Reference navigation
}

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

    public int? BlogId { get; set; } // Foreign key
    public Blog Blog { get; set; } // Reference navigation

    public IList<Tag> Tags { get; } = new List<Tag>(); // Skip collection navigation
}

public class Tag
{
    public int Id { get; set; } // Primary key
    public string Text { get; set; }

    public IList<Post> Posts { get; } = new List<Post>(); // Skip collection navigation
}

此模型中的三种关系为:

  • 每个博客可以有多个帖子(一对多):
    • Blog 是主体/父级。
    • Post 是依赖/子项。 它包含 FK 属性 Post.BlogId,其值必须与相关博客的 PK 值匹配 Blog.Id
    • Post.Blog 是从文章到关联博客的参考导航。 Post.BlogBlog.Posts的逆向导航。
    • Blog.Posts 是从博客到所有关联文章的集合导航。 Blog.PostsPost.Blog的逆向导航。
  • 每个博客可以有一项资产(一对一):
    • Blog 是主体/父级。
    • BlogAssets 是依赖/子项。 它包含 FK 属性 BlogAssets.BlogId,其值必须与相关博客的 PK 值匹配 Blog.Id
    • BlogAssets.Blog 是从资产到关联博客的参考导航。 BlogAssets.BlogBlog.Assets的逆向导航。
    • Blog.Assets 是从博客到关联资产的引用导航。 Blog.AssetsBlogAssets.Blog的逆向导航。
  • 每个帖子可以有多个标签,每个标签可以有多个帖子(多对多):
    • 多对多关系是在两个一对多关系基础上的进一步抽象。 本文档稍后将介绍多对多关系。
    • Post.Tags 是一个从帖子到所有相关标签的集合导航。 Post.TagsTag.Posts的逆向导航。
    • Tag.Posts 是一个从标记到所有关联文章的集合型导航。 Tag.PostsPost.Tags的逆向导航。

有关如何建模和配置关系的详细信息,请参阅 “关系 ”。

关系修复

EF Core 使导航与外键值保持一致,反之亦然。 也就是说,如果外键值发生更改,以便它现在引用其他主体/父实体,则会更新导航以反映此更改。 同样,如果导航已更改,则会更新所涉及的实体的外键值以反映此更改。 这称为“关系修复”。

按查询进行修正

从数据库查询实体时,首先会发生修复。 数据库只有外键值,因此,当 EF Core 从数据库中创建实体实例时,它使用外键值设置引用导航,并根据需要将实体添加到集合导航。 例如,考虑对博客及其关联文章和资产的查询:

using var context = new BlogsContext();

var blogs = await context.Blogs
    .Include(e => e.Posts)
    .Include(e => e.Assets)
    .ToListAsync();

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

对于每个博客,EF Core 将首先创建一个 Blog 实例。 然后,当每个帖子从数据库中加载时,其 Post.Blog 引用导航将设置为指向关联的博客。 同样,帖子将添加到 Blog.Posts 集合导航中。 在BlogAssets中也是如此,不过在这种情况下,这两个导航都是引用。 导航 Blog.Assets 设置为指向资产实例, BlogAsserts.Blog 导航设置为指向博客实例。

查看此查询后的 更改跟踪器调试视图 显示两个博客,每个博客包含一个资产和两个要跟踪的帖子:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: {Id: 1}
  Posts: [{Id: 1}, {Id: 2}]
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: {Id: 2}
  Posts: [{Id: 3}, {Id: 4}]
BlogAssets {Id: 1} Unchanged
  Id: 1 PK
  Banner: <null>
  BlogId: 1 FK
  Blog: {Id: 1}
BlogAssets {Id: 2} Unchanged
  Id: 2 PK
  Banner: <null>
  BlogId: 2 FK
  Blog: {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}
  Tags: []
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}
  Tags: []
Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: {Id: 2}
  Tags: []
Post {Id: 4} Unchanged
  Id: 4 PK
  BlogId: 2 FK
  Content: 'Examine when database queries were executed and measure how ...'
  Title: 'Database Profiling with Visual Studio'
  Blog: {Id: 2}
  Tags: []

调试视图同时显示键值和导航。 使用相关实体的主键值显示导航。 例如,在上面的输出中,Posts: [{Id: 1}, {Id: 2}] 指示 Blog.Posts 集合导航包含两个相关文章,其主键分别为 1 和 2。 同样,对于与第一个博客关联的每个文章,该 Blog: {Id: 1} 行指示 Post.Blog 导航引用主键为 1 的博客。

修复本地跟踪的实体

关系修复还发生在从跟踪查询返回的实体与 DbContext 已跟踪的实体之间。 例如,请考虑对博客、文章和资产执行三个单独的查询:

using var context = new BlogsContext();

var blogs = await context.Blogs.ToListAsync();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

var assets = await context.Assets.ToListAsync();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

var posts = await context.Posts.ToListAsync();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

再次查看调试视图,在第一个查询后只跟踪两个博客:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: <null>
  Posts: []
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: <null>
  Posts: []

Blog.Assets引用导航为 null,并且Blog.Posts集合导航为空,因为上下文中当前没有被跟踪的关联实体。

第二次查询后,已将Blogs.Assets引用导航修正为指向新跟踪的BlogAsset实例。 同样, BlogAssets.Blog 引用导航设置为指向已跟踪 Blog 的相应实例。

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: {Id: 1}
  Posts: []
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: {Id: 2}
  Posts: []
BlogAssets {Id: 1} Unchanged
  Id: 1 PK
  Banner: <null>
  BlogId: 1 FK
  Blog: {Id: 1}
BlogAssets {Id: 2} Unchanged
  Id: 2 PK
  Banner: <null>
  BlogId: 2 FK
  Blog: {Id: 2}

最后,在第三次查询之后,Blog.Posts 集合导航现在包含所有相关帖子,而Post.Blog 引用指向相应的Blog 实例。

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: {Id: 1}
  Posts: [{Id: 1}, {Id: 2}]
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: {Id: 2}
  Posts: [{Id: 3}, {Id: 4}]
BlogAssets {Id: 1} Unchanged
  Id: 1 PK
  Banner: <null>
  BlogId: 1 FK
  Blog: {Id: 1}
BlogAssets {Id: 2} Unchanged
  Id: 2 PK
  Banner: <null>
  BlogId: 2 FK
  Blog: {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}
  Tags: []
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}
  Tags: []
Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: {Id: 2}
  Tags: []
Post {Id: 4} Unchanged
  Id: 4 PK
  BlogId: 2 FK
  Content: 'Examine when database queries were executed and measure how ...'
  Title: 'Database Profiling with Visual Studio'
  Blog: {Id: 2}
  Tags: []

这与通过原始单一查询实现的最终状态相同,因为 EF Core 在跟踪实体时会自动管理导航属性,即使这些实体来自多个不同的查询。

注释

修复永远不会导致从数据库返回更多数据。 它仅连接已由查询返回或已被 DbContext 跟踪的实体。 有关序列化实体时如何处理重复项的信息,请参阅 EF Core 中的身份解析

通过导航更改关系

更改两个实体之间的关系的最简单方法是作导航,同时让 EF Core 正确修复反向导航和 FK 值。 可通过以下方法完成此操作:

  • 从集合导航添加或删除实体。
  • 更改引用导航以指向其他实体,或将其设置为 null。

在集合导航中添加或移除项目

例如,让我们将其中一篇文章从 Visual Studio 博客移动到 .NET 博客。 这需要先加载博客和文章,然后将文章从一个博客上的导航集合移动到另一个博客上的导航集合:

using var context = new BlogsContext();

var dotNetBlog = await context.Blogs.Include(e => e.Posts).SingleAsync(e => e.Name == ".NET Blog");
var vsBlog = await context.Blogs.Include(e => e.Posts).SingleAsync(e => e.Name == "Visual Studio Blog");

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

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
vsBlog.Posts.Remove(post);
dotNetBlog.Posts.Add(post);

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

await context.SaveChangesAsync();

小窍门

这里需要调用 ChangeTracker.DetectChanges(),因为访问调试视图不会导致 自动检测更改

这是运行上述代码后打印的调试视图:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: <null>
  Posts: [{Id: 1}, {Id: 2}, {Id: 3}]
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: <null>
  Posts: [{Id: 4}]
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}
  Tags: []
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}
  Tags: []
Post {Id: 3} Modified
  Id: 3 PK
  BlogId: 1 FK Modified Originally 2
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: {Id: 1}
  Tags: []
Post {Id: 4} Unchanged
  Id: 4 PK
  BlogId: 2 FK
  Content: 'Examine when database queries were executed and measure how ...'
  Title: 'Database Profiling with Visual Studio'
  Blog: {Id: 2}
  Tags: []

.NET 博客上的Blog.Posts导航现在包含三篇文章(Posts: [{Id: 1}, {Id: 2}, {Id: 3}])。 同样, Blog.Posts Visual Studio 博客上的导航只有一篇文章(Posts: [{Id: 4}])。 这是预期,因为代码显式更改了这些集合。

更有趣的是,尽管代码没有显式更改 Post.Blog 导航,但它已被修复,以指向 Visual Studio 博客(Blog: {Id: 1})。 此外, Post.BlogId 外键值已更新,以匹配 .NET 博客的主键值。 调用 SaveChanges 时,此对 FK 值的更改将持久保存到数据库:

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

更改引用导航

在前面的示例中,通过操作每个博客上文章的集合导航,将文章从一个博客移动到另一个博客。 可以通过将引用导航更改为 Post.Blog 指向新博客来实现相同的效果。 例如:

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
post.Blog = dotNetBlog;

此更改后的调试视图与上一示例中的调试视图 完全相同 。 这是因为 EF Core 检测到引用导航更改,然后修复了集合导航和 FK 值以匹配。

使用外键值来更改关系

在上一部分中,通过导航处理关系,使外键值能够自动更新。 这是在 EF Core 中处理关系的推荐方法。 但是,也可以直接操作外键值。 例如,可以通过更改 Post.BlogId 外键值将文章从一个博客移动到另一个博客:

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
post.BlogId = dotNetBlog.Id;

请注意,这与更改引用导航非常相似,如前面的示例所示。

此更改后的调试视图与前两个示例的情况 完全相同 。 这是因为 EF Core 检测到 FK 值更改,然后修复了要匹配的引用和集合导航。

小窍门

每次关系变化时,不要编写代码来处理所有导航项和外键值。 此类代码更为复杂,必须确保在任何情况下对外键和导航属性的一致更改。 如果可能,仅操作单个导航,或者同时操作两个导航。 如果需要,只需操作 FK 值。 避免同时操作导航和外键值。

修复已添加或已删除的实体

添加到集合导航

当 EF Core 检测到 新的依赖/子实体已添加到集合导航时,将执行以下操作:

  • 如果实体未被跟踪,则将对其进行跟踪。 (实体通常处于 Added 状态。但是,如果将实体类型配置为使用生成的键并设置主键值,则实体将跟踪为 Unchanged 状态。
  • 如果实体与其他主体/父实体相关联,则该关系将断开。
  • 该实体与拥有集合导航功能的上级/父实体相关联。
  • 导航和外键值已针对所有涉及的实体进行修正。

根据这一点,我们可以看到,若要将文章从一个博客移动到另一个博客,我们实际上不需要将其从旧集合导航中删除,然后再将其添加到新集合导航。 因此,上述示例中的代码可以更改为以下代码:

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
vsBlog.Posts.Remove(post);
dotNetBlog.Posts.Add(post);

更改为:

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
dotNetBlog.Posts.Add(post);

EF Core 看到该文章已添加到新博客,并自动将其从第一个博客上的集合中删除。

从集合导航中删除

从主体/父项的集合导航中删除依赖/子实体会导致断开与该主体/父项的关系。 接下来会发生什么取决于关系是可选的还是必需的。

可选关系

对于可选关系,外键值默认设置为 null。 这意味着依赖/子级不再与 任何 主体/父级关联。 例如,让我们加载博客和文章,然后从 Blog.Posts 集合导航中删除其中一篇文章:

var post = dotNetBlog.Posts.Single(e => e.Title == "Announcing F# 5");
dotNetBlog.Posts.Remove(post);

在进行更改后查看更改跟踪调试视图时显示如下内容:

  • FK Post.BlogId 已设置为 null (BlogId: <null> FK Modified Originally 1
  • 引用导航 Post.Blog 已被设置为 null (Blog: <null>
  • 文章已从 Blog.Posts 集合导航中删除(Posts: [{Id: 1}]
Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: <null>
  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}
  Tags: []
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>
  Tags: []

请注意,帖子 标记为 Deleted。 它被标记为 Modified 在调用 SaveChanges 时,数据库中的 FK 值将设置为 null。

必需的关系

不允许将 FK 值设置为 null(通常不可能)用于所需关系。 因此,断绝必需的关系意味着,当调用 SaveChanges 时,为避免违反引用约束,依赖/子实体必须重新关联到新的主体/父实体,或从数据库中删除。 这称为“删除孤儿”,是 EF Core 中必需关系的默认行为。

例如,让我们将博客和文章之间的关系更改为必需,然后运行与上一示例中相同的代码:

var post = dotNetBlog.Posts.Single(e => e.Title == "Announcing F# 5");
dotNetBlog.Posts.Remove(post);

查看此更改后的调试视图,显示:

  • 该帖子已标记为 Deleted 在调用 SaveChanges 时会从数据库中删除。
  • 引用 Post.Blog 导航已设置为空(Blog: <null>)。
  • 文章已从 Blog.Posts 集合导航中删除(Posts: [{Id: 1}])。
Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: <null>
  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}
  Tags: []
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: <null>
  Tags: []

请注意,由于必需关系无法将其设置为 null,因此 Post.BlogId 保持不变。

调用 SaveChanges 会直接导致孤立的帖子被删除。

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

删除孤立的计时设置并重新设置父级关系

默认情况下,标记Deleted为孤立项是在检测到关系更改时立即进行的。 但是,此过程可能会延迟,直到实际调用 SaveChanges。 这可以有效避免从一个主体/父级中删除的实体成为孤儿,因为在调用 SaveChanges 之前,它们会通过新的主体/父级重新挂载上。 ChangeTracker.DeleteOrphansTiming 用于设置此计时。 例如:

context.ChangeTracker.DeleteOrphansTiming = CascadeTiming.OnSaveChanges;

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
vsBlog.Posts.Remove(post);

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

dotNetBlog.Posts.Add(post);

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

await context.SaveChangesAsync();

从第一个集合中删除帖子后,对象并不像上一个示例那样标记为 Deleted。 相反,EF Core 会跟踪关系是否被切断, 即使这是必需的关系。 (EF Core 将 FK 值视为 null,即使它不能真正为 null,因为类型不可为 null。这称为“概念 null”。

Post {Id: 3} Modified
  Id: 3 PK
  BlogId: <null> FK Modified Originally 2
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  Tags: []

此时调用 SaveChanges 将导致删除孤立的帖子。 但是,如上例所示,在调用 SaveChanges 之前,帖子与新博客相关联,那么它将正确地与该新博客关联,并且不再被视为孤立状态。

Post {Id: 3} Modified
  Id: 3 PK
  BlogId: 1 FK Modified Originally 2
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: {Id: 1}
  Tags: []

此时调用的 SaveChanges 将更新数据库中的帖子,而不是将其删除。

还可以关闭孤立对象的自动删除。 如果在跟踪孤立实体时调用 SaveChanges,则会触发异常。 例如,此代码:

var dotNetBlog = await context.Blogs.Include(e => e.Posts).SingleAsync(e => e.Name == ".NET Blog");

context.ChangeTracker.DeleteOrphansTiming = CascadeTiming.Never;

var post = dotNetBlog.Posts.Single(e => e.Title == "Announcing F# 5");
dotNetBlog.Posts.Remove(post);

await context.SaveChangesAsync(); // Throws

将抛出此异常:

System.InvalidOperationException:实体“Blog”和“Post”与键值“{BlogId:1}”之间的关联已中断,但关系被标记为必需或隐含为必需,因为外键不可为 null。 如果在切断所需关系时应删除依赖/子实体,请将关系配置为使用级联删除。

可以随时通过调用 ChangeTracker.CascadeChanges() 来强制删除孤儿记录和进行级联删除。 将此设置与删除孤立项的时机 Never 相结合,以确保孤立项不会被删除,除非明确地指示 EF Core 执行这项操作。

更改引用导航

更改一对多关系中的参考导航与更改该关系另一端的集合导航具有相同的效果。 将从属/子元素的引用导航设置为 null 等效于从主体/父项的集合导航中删除实体。 所有修复和数据库更改均按照上一部分所述进行,包括在关系必需时将实体置为孤立状态。

可选的一对一关系

对于一对一关系,更改引用导航会导致任何以前的关系被切断。 对于可选关系,这意味着以前相关依赖/子级上的 FK 值设置为 null。 例如:

using var context = new BlogsContext();

var dotNetBlog = await context.Blogs.Include(e => e.Assets).SingleAsync(e => e.Name == ".NET Blog");
dotNetBlog.Assets = new BlogAssets();

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

await context.SaveChangesAsync();

调用 SaveChanges 前的调试视图显示,新资产已替换现有资产,该资产现在被标记为 Modified,且具有一个空值的 BlogAssets.BlogId FK:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: {Id: -2147482629}
  Posts: []
BlogAssets {Id: -2147482629} Added
  Id: -2147482629 PK Temporary
  Banner: <null>
  BlogId: 1 FK
  Blog: {Id: 1}
BlogAssets {Id: 1} Modified
  Id: 1 PK
  Banner: <null>
  BlogId: <null> FK Modified Originally 1
  Blog: <null>

这会导致在调用 SaveChanges 时进行更新和插入:

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

-- Executed DbCommand (0ms) [Parameters=[@p2=NULL, @p3='1' (Nullable = true) (DbType = String)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Assets" ("Banner", "BlogId")
VALUES (@p2, @p3);
SELECT "Id"
FROM "Assets"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

所需的一对一关系

运行与上一示例中相同的代码,但这次具有必需的一对一关系,结果表明以前关联的 BlogAssets 现在被标记为 Deleted,因为新的 BlogAssets 替代了它,使其变成孤立对象。

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: {Id: -2147482639}
  Posts: []
BlogAssets {Id: -2147482639} Added
  Id: -2147482639 PK Temporary
  Banner: <null>
  BlogId: 1 FK
  Blog: {Id: 1}
BlogAssets {Id: 1} Deleted
  Id: 1 PK
  Banner: <null>
  BlogId: 1 FK
  Blog: <null>

这会导致在调用 SaveChanges 时删除并插入:

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

-- Executed DbCommand (0ms) [Parameters=[@p1=NULL, @p2='1' (DbType = String)], CommandType='Text', CommandTimeout='30']
INSERT INTO "Assets" ("Banner", "BlogId")
VALUES (@p1, @p2);
SELECT "Id"
FROM "Assets"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

可以通过与调整集合导航的方式相同的方法来改变将孤立项标记为已删除的时间,并具有相同的效果。

删除实体

可选关系

当实体被标记为 Deleted(例如通过调用 DbContext.Remove)时,将从其他实体的导航中删除对已删除实体的引用。 对于可选关系,从属实体中的 FK 值设置为 null。

例如,让我们将 Visual Studio 博客标记为 Deleted

using var context = new BlogsContext();

var vsBlog = await context.Blogs
    .Include(e => e.Posts)
    .Include(e => e.Assets)
    .SingleAsync(e => e.Name == "Visual Studio Blog");

context.Remove(vsBlog);

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

await context.SaveChangesAsync();

在调用 SaveChanges 之前查看 更改跟踪器调试视图

Blog {Id: 2} Deleted
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: {Id: 2}
  Posts: [{Id: 3}, {Id: 4}]
BlogAssets {Id: 2} Modified
  Id: 2 PK
  Banner: <null>
  BlogId: <null> FK Modified Originally 2
  Blog: <null>
Post {Id: 3} Modified
  Id: 3 PK
  BlogId: <null> FK Modified Originally 2
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  Tags: []
Post {Id: 4} Modified
  Id: 4 PK
  BlogId: <null> FK Modified Originally 2
  Content: 'Examine when database queries were executed and measure how ...'
  Title: 'Database Profiling with Visual Studio'
  Blog: <null>
  Tags: []

请注意:

  • 博客被标记为 Deleted.
  • 与已删除博客相关的资产具有 null FK 值 (BlogId: <null> FK Modified Originally 2) 和空引用导航 (Blog: <null>
  • 与已删除博客相关的每个帖子都有一个 "null" FK 值(BlogId: <null> FK Modified Originally 2)和一个 "null" 引用导航(Blog: <null>

必需的关系

必需关系的修复行为与可选关系相同,不同之处在于依赖/子实体被标记为 Deleted ,因为它们在没有主体/父实体的情况下不存在,并且必须在调用 SaveChanges 时从数据库中删除,以避免引用约束异常。 这被称为“级联删除”,这是 EF Core 中对于必须存在的关系的默认行为。 例如,运行与上一个示例中相同的代码,但在调用 SaveChanges 之前,需要的关系会导致以下调试视图:

Blog {Id: 2} Deleted
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: {Id: 2}
  Posts: [{Id: 3}, {Id: 4}]
BlogAssets {Id: 2} Deleted
  Id: 2 PK
  Banner: <null>
  BlogId: 2 FK
  Blog: {Id: 2}
Post {Id: 3} Deleted
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: {Id: 2}
  Tags: []
Post {Id: 4} Deleted
  Id: 4 PK
  BlogId: 2 FK
  Content: 'Examine when database queries were executed and measure how ...'
  Title: 'Database Profiling with Visual Studio'
  Blog: {Id: 2}
  Tags: []

如预期所示,家属/子女现在标记为 Deleted。 但是,请注意,已删除实体上的导航 尚未 更改。 这似乎很奇怪,但它通过清除所有导航来避免彻底破坏已删除的实体图。 也就是说,即使删除了博客、资产和文章,该博客、资产和文章仍形成实体图。 这使得恢复已删除的实体图比在 EF6 中实体图被碎片化时容易得多。

级联删除时序和重新设定父级

默认情况下,一旦父/主体被标记为 Deleted,就会立即触发级联删除。 这与删除孤儿项相同,如前所述。 与删除孤立项一样,只需适当地设置 ChangeTracker.CascadeDeleteTiming,此过程可以延迟,直到调用 SaveChanges,也可以完全禁用。 这在某种程度上与删除孤立项一样有用,包括在删除主体/父级后重新分配子级/从属项的关系。

级联删除以及删除孤立记录,可以随时通过调用 ChangeTracker.CascadeChanges()来强制执行。 将此与设置级联删除时间 Never 相结合,确保不会发生级联删除,除非明确指示 EF Core 执行该操作。

小窍门

级联删除和删除孤儿记录密切相关。 这两者都会导致在与所需主体/父实体的关系被切断时删除依赖实体/子实体。 对于级联删除,断开是因为主体/父级本身被删除。 对于孤立的孤儿实体,主要/父实体仍然存在,但不再与从属/子实体有联系。

多对多关系

EF Core 中的多对多关系是使用联接实体实现的。 多对多关系的每一方都通过一对多关系与此联接实体相关联。 此联接实体可以显式定义和映射,也可以隐式和隐藏创建。 在这两种情况下,基础行为都是相同的。 我们将首先查看此底层行为,以了解多对多关系的跟踪原理。

多对多关系的工作原理

请考虑此 EF Core 模型,该模型使用显式定义的联接实体类型在帖子和标记之间创建多对多关系:

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

    public IList<PostTag> PostTags { get; } = new List<PostTag>(); // Collection navigation
}

public class Tag
{
    public int Id { get; set; }
    public string Text { get; set; }

    public IList<PostTag> PostTags { get; } = new List<PostTag>(); // Collection navigation
}

public class PostTag
{
    public int PostId { get; set; } // First part of composite PK; FK to Post
    public int TagId { get; set; } // Second part of composite PK; FK to Tag

    public Post Post { get; set; } // Reference navigation
    public Tag Tag { get; set; } // Reference navigation
}

请注意, PostTag 联接实体类型包含两个外键属性。 在此模型中,对于帖子与标记相对应,必须存在一个 PostTag 联接实体,其中 PostTag.PostId 外键值必须与 Post.Id 主键值匹配,并且 PostTag.TagId 外键值必须与 Tag.Id 主键值匹配。 例如:

using var context = new BlogsContext();

var post = await context.Posts.SingleAsync(e => e.Id == 3);
var tag = await context.Tags.SingleAsync(e => e.Id == 1);

context.Add(new PostTag { PostId = post.Id, TagId = tag.Id });

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

运行此代码后查看 更改跟踪器调试视图 ,显示帖子和标记与新的 PostTag 联接实体相关:

Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  PostTags: [{PostId: 3, TagId: 1}]
PostTag {PostId: 3, TagId: 1} Added
  PostId: 3 PK FK
  TagId: 1 PK FK
  Post: {Id: 3}
  Tag: {Id: 1}
Tag {Id: 1} Unchanged
  Id: 1 PK
  Text: '.NET'
  PostTags: [{PostId: 3, TagId: 1}]

请注意,集合导航在 PostTag 上已被修复,引用导航在 PostTag 上也已被修复。 这些关系可以通过导航而不是 FK 值来进行操作,就像前例中所有示例一样。 例如,可以通过在联接实体上设置引用导航来修改上述代码以添加关系:

context.Add(new PostTag { Post = post, Tag = tag });

这会导致对 FK 和导航的更改与上一示例中完全相同。

跳过导航

操控连接表可能很麻烦。 可以使用特殊的集合导航直接操作多对多关系,这些导航可以“跳过”连接实体。 例如,可以向上面的模型添加两个跳过导航;一个从帖子到标记,另一个从标记到帖子:

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

    public IList<Tag> Tags { get; } = new List<Tag>(); // Skip collection navigation
    public IList<PostTag> PostTags { get; } = new List<PostTag>(); // Collection navigation
}

public class Tag
{
    public int Id { get; set; }
    public string Text { get; set; }

    public IList<Post> Posts { get; } = new List<Post>(); // Skip collection navigation
    public IList<PostTag> PostTags { get; } = new List<PostTag>(); // Collection navigation
}

public class PostTag
{
    public int PostId { get; set; } // First part of composite PK; FK to Post
    public int TagId { get; set; } // Second part of composite PK; FK to Tag

    public Post Post { get; set; } // Reference navigation
    public Tag Tag { get; set; } // Reference navigation
}

这种多对多关系需要以下配置,以确保跳过导航和普通导航都用于相同的多对多关系:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(p => p.Posts)
        .UsingEntity<PostTag>(
            j => j.HasOne(t => t.Tag).WithMany(p => p.PostTags),
            j => j.HasOne(t => t.Post).WithMany(p => p.PostTags));
}

有关映射多对多关系的详细信息,请参阅 “关系 ”。

跳转导航的外观和行为类似于常规集合导航。 但是,它们处理外键值的方式不同。 让我们将帖子与标签相关联,但这次使用略过导航:

using var context = new BlogsContext();

var post = await context.Posts.SingleAsync(e => e.Id == 3);
var tag = await context.Tags.SingleAsync(e => e.Id == 1);

post.Tags.Add(tag);

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

请注意,此代码不使用联接实体。 相反,如果这是一对多关系,则只需以相同的方式将实体添加到导航集合中。 生成的调试视图实质上与之前相同:

Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  PostTags: [{PostId: 3, TagId: 1}]
  Tags: [{Id: 1}]
PostTag {PostId: 3, TagId: 1} Added
  PostId: 3 PK FK
  TagId: 1 PK FK
  Post: {Id: 3}
  Tag: {Id: 1}
Tag {Id: 1} Unchanged
  Id: 1 PK
  Text: '.NET'
  PostTags: [{PostId: 3, TagId: 1}]
  Posts: [{Id: 3}]

请注意,已自动创建一个联接实体的 PostTag 实例,并将 FK 值设置为当前关联的标签和帖子的 PK 值。 所有常规引用和集合导航都已修复,以匹配这些 FK 值。 此外,由于此模型包含跳过导航功能,因此这一点也已得到修复。 具体来说,尽管我们已将标记添加到Post.Tags跳过导航,但该关系另一端的Tag.Posts反向跳过导航也已修复,以包含相关的帖子。

值得注意的是,即使跳过导航在顶部,基础的多对多关系仍然可以直接操控。 例如,可以像在引入跳过导航之前那样将标签和 Post 关联起来:

context.Add(new PostTag { Post = post, Tag = tag });

或使用 FK 值:

context.Add(new PostTag { PostId = post.Id, TagId = tag.Id });

这仍然会导致跳过的导航将被正确修复,并导致与上一示例中相同的调试视图输出。

仅跳过导航

在上一部分中,除了完全定义两个基础一对多关系 之外 ,还添加了跳过导航。 这可用于说明 FK 值发生的情况,但通常是不必要的。 相反,可以使用 仅跳过导航来定义多对多关系。 这是在本文档顶部的模型中定义多对多关系的方式。 使用此模型,我们可以通过向跳过导航添加帖子 Tag.Posts 来再次关联帖子和标记(或者,也可以将标记添加到 Post.Tags 跳过导航):

using var context = new BlogsContext();

var post = await context.Posts.SingleAsync(e => e.Id == 3);
var tag = await context.Tags.SingleAsync(e => e.Id == 1);

post.Tags.Add(tag);

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

在进行此更改后,查看调试视图显示 EF Core 已创建表示联接实体的 Dictionary<string, object> 实例。 此联接实体包含已PostsIdTagsId设置为匹配关联 post 和标记的 PK 值的外键属性。

Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  Tags: [{Id: 1}]
Tag {Id: 1} Unchanged
  Id: 1 PK
  Text: '.NET'
  Posts: [{Id: 3}]
PostTag (Dictionary<string, object>) {PostsId: 3, TagsId: 1} Added
  PostsId: 3 PK FK
  TagsId: 1 PK FK

有关隐式联接实体和使用实体类型的详细信息,请参阅Dictionary<string, object>

重要

按约定用于联接实体类型的 CLR 类型可能会在将来的版本中发生更改,以提高性能。 请勿依赖以 Dictionary<string, object> 作为联接类型,除非已明确配置。

将实体与数据载荷连接

到目前为止,所有示例都使用了联接实体类型(无论是显式还是隐式),该类型仅包含多对多关系所需的两个外键属性。 作关系时,应用程序无需显式设置这两个 FK 值,因为它们的值来自相关实体的主键属性。 这允许 EF Core 创建联接实体的实例,而无需丢失数据。

具有生成值的有效负载

EF Core 支持向联接实体类型添加其他属性。 这称为为联接实体提供“有效负载”。 例如,我们来将TaggedOn属性添加到PostTag联接实体:

public class PostTag
{
    public int PostId { get; set; } // First part of composite PK; FK to Post
    public int TagId { get; set; } // Second part of composite PK; FK to Tag

    public DateTime TaggedOn { get; set; } // Payload
}

当 EF Core 创建联接实体实例时,将不会设置此有效负载属性。 应对此情况的最常见方法是使用带有自动生成值的负载属性。 例如,在插入每个新实体时, TaggedOn 可以将该属性配置为使用存储生成的时间戳:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(p => p.Posts)
        .UsingEntity<PostTag>(
            j => j.HasOne<Tag>().WithMany(),
            j => j.HasOne<Post>().WithMany(),
            j => j.Property(e => e.TaggedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));
}

现在,可以像之前一样标记帖子:

using var context = new BlogsContext();

var post = await context.Posts.SingleAsync(e => e.Id == 3);
var tag = await context.Tags.SingleAsync(e => e.Id == 1);

post.Tags.Add(tag);

await context.SaveChangesAsync();

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

在调用 SaveChanges 后查看 更改跟踪器调试视图 ,显示有效负载属性已正确设置:

Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  Tags: [{Id: 1}]
PostTag {PostId: 3, TagId: 1} Unchanged
  PostId: 3 PK FK
  TagId: 1 PK FK
  TaggedOn: '12/29/2020 8:13:21 PM'
Tag {Id: 1} Unchanged
  Id: 1 PK
  Text: '.NET'
  Posts: [{Id: 3}]

显式设置有效负载值

在上一个示例之后,让我们添加一个有效负载属性,该属性不使用自动生成的值。

public class PostTag
{
    public int PostId { get; set; } // First part of composite PK; FK to Post
    public int TagId { get; set; } // Second part of composite PK; FK to Tag

    public DateTime TaggedOn { get; set; } // Auto-generated payload property
    public string TaggedBy { get; set; } // Not-generated payload property
}

现在,可以像之前一样标记帖子,并且仍将自动创建关联实体。 然后,可以使用 访问跟踪实体中所述的机制之一访问此实体。 例如,下面的代码使用 DbSet<TEntity>.Find 来访问联接实体实例。

using var context = new BlogsContext();

var post = await context.Posts.SingleAsync(e => e.Id == 3);
var tag = await context.Tags.SingleAsync(e => e.Id == 1);

post.Tags.Add(tag);

context.ChangeTracker.DetectChanges();

var joinEntity = await context.Set<PostTag>().FindAsync(post.Id, tag.Id);

joinEntity.TaggedBy = "ajcvickers";

await context.SaveChangesAsync();

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

一旦找到联接实体,可以按正常方式操控它,在调用 SaveChanges 之前设置 TaggedBy 有效负载属性。

注释

请注意,此处需要调用 ChangeTracker.DetectChanges(),以便让 EF Core 能够检测到导航属性的更改,并在使用 Find 之前创建联接实体实例。 有关详细信息,请参阅 更改检测和通知

或者,可以显式创建联接实体以将帖子与标记相关联。 例如:

using var context = new BlogsContext();

var post = context.Posts.SingleAsync(e => e.Id == 3);
var tag = context.Tags.SingleAsync(e => e.Id == 1);

context.Add(
    new PostTag { PostId = post.Id, TagId = tag.Id, TaggedBy = "ajcvickers" });

await context.SaveChangesAsync();

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

最后,设置有效负载数据的另一种方法是通过重写 SaveChanges 或使用 DbContext.SavingChanges 事件来在更新数据库之前处理实体。 例如:

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    foreach (var entityEntry in ChangeTracker.Entries<PostTag>())
    {
        if (entityEntry.State == EntityState.Added)
        {
            entityEntry.Entity.TaggedBy = "ajcvickers";
        }
    }

    return await base.SaveChangesAsync(cancellationToken);
}