更改外键和导航

外键和导航概述

Entity Framework Core (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,其值必须与相关博客的 Blog.Id PK 值匹配。
    • Post.Blog 是从帖文到相关博客的引用导航。 Post.BlogBlog.Posts 的反向导航。
    • Blog.Posts 是从博客到所有相关帖文的集合导航。 Blog.PostsPost.Blog 的反向导航。
  • 每个博客可以有一个资产(一对一):
    • Blog 是主体/父实体。
    • BlogAssets 是依赖实体/子实体。 它包含 FK 属性 BlogAssets.BlogId,其值必须与相关博客的 Blog.Id PK 值匹配。
    • 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 = context.Blogs
    .Include(e => e.Posts)
    .Include(e => e.Assets)
    .ToList();

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 = context.Blogs.ToList();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

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

var posts = context.Posts.ToList();
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 = context.Blogs.Include(e => e.Posts).Single(e => e.Name == ".NET Blog");
var vsBlog = context.Blogs.Include(e => e.Posts).Single(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);

context.SaveChanges();

提示

此处需要调用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}])。 同样,Visual Studio 博客上的 Blog.Posts 导航仅剩一篇帖文 (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 中操作关系的推荐方法。 但是,也可以直接操作 FK 值。 例如,可以通过更改 Post.BlogId 外键值将帖文从一个博客移动到另一个博客:

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

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

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

提示

请勿在每次关系发生更改时编写代码来操作所有导航和 FK 值。 此类代码更复杂,并且必须确保在每种情况下对外键和导航进行一致的更改。 如果可能,只对一个导航进行操作,或者两个导航。 如果需要,仅操作 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);

在此更改后查看更改跟踪调试视图,显示:

  • Post.BlogId FK 已设置为 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 引用导航已设置为 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} 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: []

请注意,Post.BlogId 保持不变,因为对于必需关系,它不能设置为 null。

调用 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);

context.SaveChanges();

从第一个集合中删除帖文后,该对象不会像在前面的示例中那样标记为 Deleted。 相反,EF Core 会跟踪关系是否已断开,即使这是必需的关系。 (FK 值被 EF Core 视为 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 = context.Blogs.Include(e => e.Posts).Single(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);

context.SaveChanges(); // Throws

将引发此异常:

System.InvalidOperationException:实体“博客”和“帖文”与键值“{BlogId: 1}”之间的关联已被切断,但该关系要么被标记为必需,要么为隐式必需,因为外键不可为空。 如果在切断所需关系时应删除依赖实体/子实体,请将关系配置为使用级联删除。

可以通过调用 ChangeTracker.CascadeChanges() 随时强制执行孤立项删除以及级联删除。 将这一操作与将删除孤立项时间设置为 Never 相结合将确保永不会删除孤立项,除非明确指示 EF Core。

更改引用导航

更改一对多关系的引用导航与更改关系另一端的集合导航具有相同的效果。 将依赖实体/子实体的引用导航设置为 null 相当于从主体/父实体的集合导航中删除实体。 所有修正和数据库更改都如上一部分所述发生,包括将实体标记为孤立项(如果需要这一关系)。

可选的一对一关系

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

using var context = new BlogsContext();

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

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

context.SaveChanges();

调用 SaveChanges 之前的调试视图显示新资产已替换现有资产,现有资产现在标记为 Modified,并带有 null 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 = context.Blogs
    .Include(e => e.Posts)
    .Include(e => e.Assets)
    .Single(e => e.Name == "Visual Studio Blog");

context.Remove(vsBlog);

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

context.SaveChanges();

在调用 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) 和 null 引用导航 (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 = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(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 = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(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 反向跳过导航也已修正以包含关联的帖文。

值得注意的是,即使跳过导航已置于顶部,仍然可以直接操作底层的多对多关系。 例如,标记和帖文可以按照引入跳过导航之前的方式进行关联:

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 = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);

post.Tags.Add(tag);

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

在进行此更改后查看调试视图,显示 EF Core 已创建了一个 Dictionary<string, object> 的实例来表示联接实体。 此联接实体包含 PostsIdTagsId 外键属性,这些属性已设置为与关联的帖文和标记的 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 = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);

post.Tags.Add(tag);

context.SaveChanges();

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 = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);

post.Tags.Add(tag);

context.ChangeTracker.DetectChanges();

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

joinEntity.TaggedBy = "ajcvickers";

context.SaveChanges();

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

一旦找到联接实体,便可以用正常方式对其进行操作 - 在本例中,即在调用 SaveChanges 之前设置 TaggedBy 有效负载属性。

注意

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

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

using var context = new BlogsContext();

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

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

context.SaveChanges();

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

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

public override int SaveChanges()
{
    foreach (var entityEntry in ChangeTracker.Entries<PostTag>())
    {
        if (entityEntry.State == EntityState.Added)
        {
            entityEntry.Entity.TaggedBy = "ajcvickers";
        }
    }

    return base.SaveChanges();
}