第3章 更改数据库内容

发布时间 2024-01-09 16:51:09作者: 生活的倒影

本章涵盖

  • 在数据库表中创建新行
  • 为两种类型的应用进程更新数据库表中的现有行
  • 使用一对一、一对多和多对多关系更新实体
  • 从数据库中删除单个实体和具有关系的实体

第 2 章介绍了查询数据库。本章继续介绍如何更改数据库的内容。更改数据有三个不同的部分:在数据库表中创建新行、更新数据库表中的现有行和删除数据库表中的行,我按此顺序介绍它们。创建、更新和删除以及读取(EF Core 术语中的查询)是数据库术语,用于表示正在发生的事情,通常缩写为 CRUD。

您将使用与第 2 章中相同的数据库,该数据库具有 Book、PriceOffer、Review、BookAuthor 和 Author 实体类。这些类提供了一系列很好的属性类型和关系,可用于了解通过 EF Core 更改数据库中数据的各种问题和方法。

3.1 EF Core 实体 State 介绍

在开始描述添加、更新或删除实体的方法之前,先了解一下称为 State 的 EF Core 实体属性。此属性提供了对 EF Core 工作方式的另一种视角,这有助于您了解添加、更新或删除实体时发生的情况。

任何实体类实例都有一个 State,可以通过以下 EF Core 命令访问它:context.Entry(someEntityInstance).State。当调用 SaveChanges 时,状态会告诉 EF Core 如何处理此实例。以下是可能的状态列表以及调用 SaveChanges 时会发生的情况:

  • 添加——实体需要在数据库中创建。 SaveChanges 插入它。
  • 未更改——实体存在于数据库中,在客户端没有被修改过。 SaveChanges 会忽略它。
  • 已修改——实体存在于数据库中,并且在客户端已被修改。 SaveChanges 更新它。
  • 已删除——实体存在于数据库中但应删除。SaveChanges 将其删除。
  • 分离——您提供的实体未被跟踪。 SaveChanges 看不到它。

通常,您不会直接查看或更改状态。您可以使用本章中列出的各种命令来添加、更新或删除实体。这些命令确保在跟踪实体中设置状态(请参见下面的定义)。当调用 SaveChanges 时,它会查看所有跟踪的实体及其状态,以决定需要应用于数据库的数据库更改类型。我在本章的其余部分中引用了实体的状态,以向您展示 EF Core 如何决定应用于数据库的更改类型。

定义:跟踪实体是使用不包含 AsNoTracking 方法的查询从数据库读入的实体实例。或者,在将实体实例用作 EF Core 方法(例如添加、更新或删除)的参数后,它就会被跟踪。

3.2 在表中创建新行

在数据库中创建新数据就是向表中添加(通过关系数据库中的 SQL 命令 INSERT)新行。例如,如果您想将新作者添加到我们的图书应用进程,则该添加将称为对数据库的创建操作。

在 EF Core 术语中,在数据库中创建新数据是最简单的更新操作,因为 EF Core 可以采用一组链接的实体类,将它们保存到数据库中,并整理出链接内容所需的外键。在本节中,您将从一个简单的示例开始,然后逐步构建更复杂的创建。

3.2.1 单独创建单个实体

让我们从一个没有导航属性的实体类开始,即与数据库中其他表的关系。此示例很少见,但显示了创建操作中的两个步骤:

  1. 将实体添加到应用进程的 DbContext。
  2. 调用应用进程的 DbContext 的 SaveChanges 方法。

此列表创建一个 ExampleEntity 实体类,并将一个新行添加到该实体映射到的表中,在本例中为 ExampleEntities 表。

清单 3.1 创建单个实体的示例

var itemToAdd = new ExampleEntity
{
    MyMessage = "Hello World"
};
context.Add(itemToAdd);   // 使用 Add 方法将 SingleEntity 添加到应用进程的 DbContext 中。DbContext 根据其参数类型确定要将其添加到的表。
context.SaveChanges();  // 从应用进程的 DbContext 调用 SaveChanges 方法以更新数据库

由于添加了最初未跟踪的实体实例 itemToAdd,因此 EF Core 将开始跟踪它并将其“状态”设置为“已添加”。调用 SaveChanges 后,EF Core 会查找 State为“Added”的 ExampleEntity 类型的跟踪实体,因此该实体将作为新行添加到与 ExampleEntity 类关联的数据库表中。

EF6:在 EF6.x 中,需要将 itemToAdd 添加到应用进程的 DbContext 中的 DbSet<ExampleEntity> 属性,例如 context.ExampleEntities.Add(itemToAdd)。这种方法仍然有效,但 EF Core 引入了清单 3.1 中所示的速记,它适用于 Add、Remove、Update 和 Attach 方法。(有关最后两个命令的更多信息,请参阅第 11 章。EF Core 通过查看你提供的实例类型来确定要更改的实体。

EF Core 创建 SQL 命令来更新基于 SQL Server 的数据库。

清单 3.2 为在 SingleEntities 表中插入新行而创建的 SQL 命令

SET NOCOUNT ON;
INSERT INTO ExampleEntities] ([MyMessage]) VALUES (@p0);  -- 在 ExampleEntities 表中插入(创建)新行
-- 读回新创建的行中的主键
SELECT [ExampleEntityId] FROM [ExampleEntities] WHERE @@ROWCOUNT = 1 AND [ExampleEntityId] = scope_identity();

EF Core 生成的第二个 SQL 命令读回数据库服务器创建的行的主键。此命令可确保使用主键更新原始 ExampleEntity 实例,以便实体的内存中版本与数据库中的版本相同。读回主键很重要,因为稍后可能会更新实体,而更新将需要主键。

EF6:在 EF6.x 中,调用 SaveChanges 时,默认情况下,EF6.x 使用标准的 .NET 验证方法验证数据;它查找数据验证属性,如果存在,则对实体类运行 IValidatableObject.Validate。EF Core 不包括此功能,因为大量验证是在前端完成的,但如果需要,添加验证功能并不难。第 4 章向您展示了如何操作。

3.2.2 创建带有评论的图书

接下来,您将查看包含关系的创建,在本例中,添加一本带有评论的新书。尽管实体类的设置稍微复杂一些,但该过程的步骤与我们前面的非关系创建步骤相同:

  • 它以某种方式将实体类添加到具有“添加状态”的 EF Core 跟踪实体中。
  • 它调用 SaveChanges,它会查看所有跟踪实体的状态,并对所有将“状态”设置为“已添加”的实体运行 SQL INSERT 命令。

此示例使用图书应用进程的数据库及其“图书”和“评论”表。图 3.1 显示了这些表的部分数据库图。

图 3.1 书籍和评论表。“审阅”行有一个外键,EF Core 使用创建的新“书籍”行中的主键值填充该外键。

在下一个列表中,创建一个新的 Book 实体,并用单个 Review 实体填充 Reviews 集合属性。然后调用上下文。Add 方法,后跟 SaveChanges 方法,该方法将这两个实体写入数据库。

清单 3.3 添加一个 Book 实体类也会添加任何链接的实体类

//创建了名为"Test Book"的书籍
var book = new Book
{
    Title = "Test Book", 
    PublishedOn = DateTime.Today, 
    Reviews = new List<Review>()  //创建新的评论集
    {
        new Review  //添加一条评论及其内容
        {
            NumStars = 5,
            Comment = "Great test book!", 
            VoterName = "Mr U Test"
        }
    }
};	
context.Add(book);   //使用 Add 方法将书籍添加到应用进程的 DbContext 属性 Books 中
context.SaveChanges();  //从应用进程的 DbContext 调用 SaveChanges 方法以更新数据库。它查找一本新书,该书的集合包含一个新书评,然后将两者添加到数据库中。

在此列表中需要注意的一点是,您只添加了 Book 实体类,但相关的 Review 实体类也会写入数据库。发生这种情况的原因是 EF Core 跟踪所有关系链接并找到新的 Review 实例,并且由于未跟踪该 Review,因此 EF Core 知道需要将 Review 添加到数据库中。

正如您在清单 3.1 中的简单示例中看到的,EF Core 通过访问链接实体类的 EF Core State 值来确定如何处理这些实体类。如果链接的实例是新的(EF Core 尚不知道),EF Core 将开始跟踪它们,并将其“状态”设置为“已添加”。在所有其他情况下,EF Core 将遵循链接到实体实例的状态。在清单 3.3 中,EF Core 尚不知道 Review 实体实例,这意味着其状态为“已分离”,但在进行 Add 调用时,其“状态”设置为“已添加”。该实例将作为新行插入到数据库中。

SAVECHANGES 成功返回后会发生什幺?

当 Add 和 SaveChanges 成功完成时,会发生一些情况:EF Core 现在跟踪已插入数据库的实体实例,并且其状态设置为“未更改”。由于我们使用的是关系数据库,并且两个实体类 Book 和 Review 的主键类型为 int,因此默认情况下,EF Core 将期望数据库使用 SQL IDENTITY 关键字创建主键。因此,EF Core 创建的 SQL 命令将主键读回实体类实例中的相应主键,以确保实体类与数据库匹配。

注意:Cosmos DB 数据库没有等效于 SQL 的 IDENTITY,因此需要提供唯一键,例如 GUID(全局唯一标识符)。唯一的 GUID 由 EF Core 称为 ValueGenerator 生成(请参阅第 10 章)。当您需要一个唯一键时,GUID 对于关系数据库中的主键也很有用,该键在将数据复制/复制到另一个数据库时不会更改。

此外,EF Core 还通过实体类中的导航属性了解关系。在清单 3.3 中,Book 实体的 Reviews 集合属性中有一个新的 Review 实体实例。作为 SaveChanges 过程的一部分,将通过将主键复制到每个新关系中的外键来设置任何外键。然后,实体实例与数据库匹配。这在你想要读取主键或外键时非常有用,如果你再次调用 SaveChanges,EF Core 可以检测到你对主键或外键所做的任何后续更改。

为什幺应该在更改结束时仅调用一次 SaveChanges

在清单 3.3.您会看到 SaveChanges 方法在创建结束时被调用,并且在更新和删除示例中也会看到相同的模式(SaveChanges 方法在末尾调用)。事实上,即使对于包含创建、更新和删除混合的复杂数据库更改,您仍然应该在最后只调用一次 Save- Changes 方法。这样做是因为 EF Core 将保存所有更改(创建、更新和删除)并将它们一起应用于数据库,如果数据库拒绝任何更改,则所有更改都将被拒绝(通过称为事务的数据库功能;请参阅第 4.7.2 节)。

此模式称为工作单元,这意味着数据库更改不能半应用于数据库。例如,如果创建了一个新 Book,其中包含对数据库中不存在的 Author 的 BookAuthor 引用,则不希望保存 Book 实例。保存它可能会破坏图书显示,该显示要求每本书至少有一个作者。

有时,你可能会认为需要调用 SaveChanges 两次,例如,当你需要新实体类的主键来填充实体类的外键时,但 EF Core 总有办法解决这种情况。事实上,清单 3.3 通过同时创建一本新书和一篇新书评来解决这个问题。通读第 6.2.1 和 6.2.2 节,了解 EF Core 如何完成此任务。

数据库中已有一个实例的示例

您可能需要处理的另一种情况是创建一个新实体,该实体包含使用数据库中已有的另一个实体的导航属性。如果要创建一个新的 Book 实体,该实体的 Author 已存在于数据库中,则需要获取要添加到新 Book 实体的 Author 实体的跟踪实例。下面的清单给出了一个示例。请注意,该数据库已包含名为“A先生”的作者。

清单 3.4 添加一本有作者的书

//拿着支票在作者身上读到,作者找到了。拿着支票在作者身上读到,作者找到了。
var foundAuthor = context.Authors.SingleOrDefault(author => author.Name == "Mr. A"); 
if (foundAuthor == null)
    throw new Exception("Author not found");
//以与前一个示例相同的方式创建一本书
var book = new Book
{
    Title = "Test Book", 
    PublishedOn = DateTime.Today
};
//添加 AuthorBook 链接条目,但使用数据库中已有的 Author
book.AuthorsLink = new List<BookAuthor>
{
    new BookAuthor
    {
        Book = book,
        Author = foundAuthor
    }
};
context.Add(book); 
context.SaveChanges();  //将新 Book 添加到 DbContext Books 属性并调用 SaveChanges

前四行加载一个 Author 实体,并进行一些检查以确保它被找到;将跟踪此 Author 类实例,因此 EF Core 知道它已在数据库中。您可以创建一个新的 Book 实体并添加一个新的 BookAuthor 链接实体,但不是创建新的 Author 实体实例,而是使用从数据库读入的 Author 实体。由于 EF Core 正在跟踪 Author 实例,并且知道它位于数据库中,因此在清单 3.4 末尾调用 SaveChanges 时,EF Core 不会尝试将其再次添加到数据库中。

3.3 更新数据库行

更新数据库行分三个阶段实现:

  1. 读取数据(数据库行),可能具有某些关系。
  2. 更改一个或多个属性(数据库列)。
  3. 将更改写回数据库(更新行)。

在本节中,您将忽略任何关系,并专注于这三个阶段。在下一节中,你将了解如何通过向每个阶段添加更多命令来更新关系。

清单 3.5 更改了现有书籍的出版日期。通过以下代码,您可以看到更新的标准流程:

  1. 将要更改的实体类加载为跟踪的实体。
  2. 更改实体类中的属性。
  3. 调用 SaveChanges 来更新数据库。

清单 3.5 更新 Quantum Networking 的发布日期

//找到你要更新的具体书籍——在本例中,我们的特别书籍《量子网络》
var book = context.Books
    .SingleOrDefault(p =>p.Title == "Quantum Networking"); 
if (book == null)
    throw new Exception("Book not found");  //如果找不到书,则抛出异常

book.PublishedOn = new DateTime(2058, 1, 1);   //将预期出版日期更改为2058年(原为2057年)
context.SaveChanges();  //调用 SaveChanges,其中包括运行名为 DetectChanges 的方法。此方法发现 PublishedOn 属性已更改。

调用 SaveChanges 方法时,它将运行一个名为 DetectChanges 的方法,该方法将跟踪快照与最初执行查询时传递给应用进程的实体类实例进行比较。在此示例中,EF Core 确定仅更改了 PublishedOn 属性,EF Core 生成 SQL 以更新该属性。

注意:使用跟踪快照是 DetectChanges 查找已更改属性的正常方式。但第 11 章介绍了跟踪快照的替代方法,例如 INotifyPropertyChanging。这个主题是高级的,所以我在本书的第 1 部分中使用了跟踪实体方法。

下面的清单显示了 EF Core 为清单 3.5 中的代码生成的两个 SQL 命令。一个 SQL 命令查找并加载 Book 实体类,另一个命令更新 PublishedOn 列。

清单 3.6 EF Core 为清单 3.5 中的查询和更新生成的 SQL

--读取加载表中的所有列。
SELECT TOP(2)  --最多读取 Books 表中的两行。您请求了单个项目,但此代码确保在多行适合时失败。
    [p].[BookId],
    [p].[Description],
    [p].[ImageUrl],
    [p].[Price],
    [p].[PublishedOn],
    [p].[Publisher],
    [p].[Title]
FROM [Books] AS [p]
WHERE [p].[Title] = N'Quantum Networking'  --您的 LINQ Where 方法,通过标题选择正确的行

SET NOCOUNT ON;
UPDATE [Books]   --SQL UPDATE 命令 - - 在这种情况下,在 Books 表上
    SET [PublishedOn] = @p0  --由于 EF Core 的 DetectChanges 方法发现只有 PublishedOn 属性已更改,因此它可以以表中的该列为目标。

WHERE [BookId] = @p1;   --EF Core 使用原始书籍中的主键来唯一地选择要更新的行。
SELECT @@ROWCOUNT;  --发回插入到此事务中的行数。SaveChanges 返回此整数,但通常可以忽略它。

3.3.1 处理 Web 应用进程中断开连接的更新

正如您在第 3.3 节中了解到的,更新是一个三阶段过程,需要由应用进程的 DbContext 的同一实例执行读取、更新和 SaveChanges 调用。问题在于,对于某些应用进程(例如网站和 RESTful API),使用应用进程的 DbContext 的相同实例是不可能的,因为在 Web 应用进程中,每个 HTTP 请求通常都是一个新请求,没有来自上一个 HTTP 请求的数据。在这些类型的应用进程中,更新包括两个阶段:

  • 第一阶段是初始读取,在应用进程的 DbContext 的一个实例中完成。
  • 第二阶段通过使用应用进程的 DbContext 的新实例来应用更新。

在 EF Core 中,这种类型的更新称为断开连接的更新,因为第一阶段和第二阶段使用应用进程 DbContext 的两个不同实例(请参阅前面的列表)。您可以通过多种方式处理断开连接的更新。您应该使用的方法很大程度上取决于您的应用进程。以下是处理断开连接的更新的两种主要方法:

  • 您只发送从第一阶段更新所需的数据。如果要更新书籍的发布日期,则只会发回 BookId 和 PublishedOn 属性。在第二阶段中,使用主键通过跟踪重新加载原始实体,并更新要更改的特定属性。在此示例中,主键是 BookId,要更新的属性是 Book 实体的 PublishedOn 属性(参见图 3.2)。调用 SaveChanges 时,EF Core 可以确定你更改了哪些属性,并仅更新数据库中的那些列。
  • 从第一阶段发送重新创建实体类所需的所有数据。在第二阶段,使用第一阶段的数据重新生成实体类,并可能重新生成关系,并告知 EF Core 更新整个实体(参见图 3.3)。调用 SaveChanges 时,EF Core 将知道,因为你已告知它,它必须更新表行中受影响的所有列,其中包含第一阶段提供的替代数据。

注意:处理选项 1 中描述的实体部分更新的另一种方法是创建新的实体实例并操作每个属性的状态。第 11 章介绍了这个选项,当我们更详细地研究如何改变实体的状态时。

这可是一大堆话啊!现在,我将举例说明处理断开连接的更新的每种方法。

断开连接的更新,重新加载

图 3.2 显示了 Web 应用进程中断开连接的更新示例。在本例中,您将提供一项功能,允许管理员用户更新图书的出版日期。该图显示您仅从第一阶段发回 BookId 和 PublicationDate 数据。

图 3.2 使用 EF Core 的网站上断开连接的更新中的两个阶段。中间的粗虚线表示第一阶段应用进程中保存的数据丢失的点,第二阶段开始时不知道阶段 1 做了什幺。当用户单击“更新”按钮时,仅返回 BookId 和 PublishDate 信息

对于 Web 应用进程,仅将有限数量的数据返回到 Web 服务器的方法是处理 EF Core 更新的常用方法。这种方法使请求速度更快,但其重要原因是安全性。例如,您不希望返回一本书的价格,因为该信息将允许黑客更改他们想要购买的书籍的价格。

有几种方法可以控制 Web 服务器返回/接受哪些数据。例如,在 ASP.NET Core 中,您具有 BindNever 特性,该特性允许您定义不会返回到第二阶段的命名属性。但是,一种更通用的方法,也是我更喜欢的方法,是使用一个特殊的类,该类仅包含应该发送/接收的属性。此类称为 DTO 或 ViewModel。它在性质上类似于第 2 章中的选择加载查询中使用的 DTO,但在本例中,它不仅用于查询,还用于通过浏览器从用户那里接收所需的特定数据。对于更新发布日期的示例,您需要三个部分。第一部分,即向用户发送/接收数据的 DTO,如下所示。

清单 3.7 ChangePubDateDto 向用户发送数据并从用户接收数据

public class ChangePubDateDto
{
    public int BookId { get; set; }   //保存要更新的行的主键,这样可以快速准确地找到正确的行
    public string Title { get; set; }  //您发送书名以向用户展示,以便他们可以确定他们正在更改正确的书籍。
    //要更改的属性。您发送了当前发布日期,并取回了更改后的发布日期。
    [DataType(DataType.Date)]
     public DateTime PublishedOn { get; set; }
}

使用主键读取实体类的最快方法

当您想要更新特定实体并需要使用其主键读取它时,您有几个选项。我曾经使用“查找”命令,但经过一番挖掘,我现在推荐使用 SingleOrDefault,因为它比“查找”命令更快。但是,我应该指出关于 Find 方法的两个有用之处:

    • Find 方法检查当前应用进程的 DbContext,以查看是否已加载所需的实体实例,这可以保存对数据库的访问。但是,如果实体不在应用进程的 DbContext 中,则由于此额外检查,加载速度会变慢。
    • Find 方法的键入更简单、更快捷,因为它比 SingleOrDefault 版本(如 context)更短。查找(键)与上下文。SingleOrDefault(p => p.Bookid == 键)。

使用 SingleOrDefault 方法的好处是,可以使用 Include 等方法将其添加到查询的末尾,而 Find 无法做到这一点。

其次,您需要一种方法来获取第 1 阶段的初始数据。第三,您需要一种方法来从浏览器接收数据,然后重新加载/更新书籍。此列表显示了 ChangePubDateService 类,该类包含两个用于处理这两个阶段的方法。

清单 3.8 用于处理断开连接的更新的 ChangePubDateService 类

//在 DI 中注册该类时需要此接口。在构建 ASP.NET Core BookApp 时,在第 5 章中使用 DI。
public class ChangePubDateService : IChangePubDateService  
{
    //应用进程的 DbContext 通过类构造函数提供,这是构建类的正常方式,您将在 ASP.NET Core 中将其用作服务。
    private readonly EfCoreContext _context;

    public ChangePubDateService(EfCoreContext context)
    {
        _context = context;
    }
    //此方法处理更新的第一部分,例如从所选书籍中获取数据以显示给用户。
    public ChangePubDateDto GetOriginal(int id)
    {
        return _context.Books
            .Select(p => new ChangePubDateDto  //一个只返回三个属性的 select load 查询
            {
                BookId = p.BookId, 
                Title = p.Title,
                PublishedOn = p.PublishedOn
            })
            .Single(k => k.BookId == id);  //使用主键选择我们想要更新的确切行
    }

    public Book UpdateBook(ChangePubDateDto dto)  //此方法处理更新的第二部分,例如对所选书籍执行选择性更新。
    {
        //加载书籍。我使用 SingleOrDefault,因为它比 Find 方法略快。
        var book = _context.Books.SingleOrDefault( x => x.BookId == dto.BookId);
        if (book == null)
            throw new ArgumentException( "Book not found");  //我捕捉到一本书没有找到的情况,并抛出一个异常。
        book.PublishedOn = dto.PublishedOn;  //选择性更新已加载图书的 PublishedOn 属性
        _context.SaveChanges();   //SaveChanges 使用其 DetectChanges 方法找出已更改的内容,然后更新数据库。
        return book;  //返回更新的图书
    }
}

这种重新加载然后更新方法的优点是它更安全(在我们的示例中,通过 HTTP 发送/返回书籍的价格将允许某人更改它)并且由于数据更少而更快。缺点是必须编写代码来复制要更新的特定属性。第 6 章介绍了一些自动化此过程的技巧。

注意:您可以看到此代码并尝试在示例图书应用进程上更新发布日期。如果从 Git 存储库下载代码并在本地运行它,则会看到每本书的“管理”按钮。此按钮包含一个名为“更改发布日期”的链接,该链接将引导您完成此过程。还可以通过“日志”菜单项查看 EF Core 用于执行此更新的 SQL 命令。

断开连接的更新,发送所有数据

在某些情况下,所有数据都可能被发回,因此没有理由重新加载原始数据。对于简单的实体类、某些 RESTful API 或进程到进程的通信,可能会发生这种情况。很大程度上取决于给定的 API 格式与数据库格式的匹配程度以及您对其他系统的信任程度。

图 3.3 显示了一个 RESTful API 示例,在该示例中,外部系统首先向系统查询具有给定标题的书籍。在更新阶段,外部系统会发回有关其收到的图书作者的更新。

图 3.3 断开连接的更新示例,其中将所有数据库信息替换为新数据。与上一个示例中的过程不同,此过程在执行更新之前不需要重新加载原始数据。

清单 3.9 模拟了 RESTful API,它的第一个阶段读取要更新的 Author 实体类,然后将其串行化为 JSON 字符串。(图 3.3 的步骤 2 显示了该 JSON 的外观。然后,解码该 JSON 并使用 EF Core Update 命令,该命令将替换主键定义的行中的所有信息,在本例中为 AuthorId。

清单 3.9 模拟来自外部系统的更新/替换请求

string json;
using (var context = new EfCoreContext(options))  //模拟外部系统以 JSON 字符串形式返回修改后的 Author 实体类
{
    var author = context.Books
        .Where(p => p.Title == "Quantum Networking")
        .Select(p => p.AuthorsLink.First().Author)
        .Single();
    author.Name = "Future Person 2";
    json = JsonConvert.SerializeObject(author);
}
using (var context = new EfCoreContext(options))
{
    var author = JsonConvert
        .DeserializeObject<Author>(json);  //模拟从外部系统接收 JSON 字符串并将其解码为 Author 类
    context.Authors.Update(author);   //提供指向多对多链接表的链接,该链接表链接到本书的作者
    context.SaveChanges();  //Update 命令,用于替换给定主键的所有行数据,在本例中为 AuthorId
}

您可以使用作者实体实例作为参数来调用 EF Core Update 命令,该命令将作者实体的所有属性标记为已修改。当调用 SaveChanges 命令时,它将更新该行中与实体类具有相同主键的所有列。

EF6 :“更新”命令是 EF Core 中的添加功能。在 EF6.x 中,需要直接操作实体对象状态,例如使用命令 DbContext.Entry(object)。状态 = EntityState.Modified。第 11 章介绍了 EF Core 设置实体状态的方式的细微变化。

这种方法的优点是数据库更新速度更快,因为您无需额外读取原始数据。您也不必编写代码来复制要更新的特定属性,而在之前的方法中确实需要这样做。

缺点是可以传输更多数据,并且除非精心设计 API,否则很难将您收到的数据与数据库中已有的数据进行协调。此外,您信任外部系统能够正确记住所有数据,尤其是系统的主键。

注意:清单 3.9 仅涵盖一个没有关系的类,但在许多 RESTful API 和进程间通信中,可能会发送大量链接数据。在该示例中,API 可能期望发回整本书及其所有关系,仅用于更新作者姓名。这个过程变得很复杂,因此我在第 11 章中介绍了它,该章展示了如何管理每个属性的状态并介绍了 EF Core 的 TrackGraph 方法,该方法有助于处理具有关系的类的部分更新。

3.4 处理更新中的关系

现在我们已经创建了更新数据库的三个基本步骤,是时候考虑更新实体类之间的关系了——例如,为一本书添加新的评论。更新关系会增加代码的复杂性,尤其是在断开连接的状态下,这就是为什幺我将此内容放在单独的部分中。

本节介绍 EF Core 使用的三种关系链接类型的更新,并提供连接更新和断开连接更新的示例。在所有情况下,您都将使用 Book 实体类,它具有三个关系链接。以下清单显示了 Book 实体类,但重点放在最后的关系上。 (我删除了一些非关系属性以将重点放在关系上。)

清单 3.10 Book 实体类,显示要更新的关系

public class Book  //图书类包含主要图书信息。
{
    public int BookId { get; set; }
    //… other nonrelational properties removed for clarity

    //relationships
    public PriceOffer Promotion { get; set; }  //可选的PriceOffer链接
    public ICollection<Review> Reviews { get; set; }   //可以是零到许多书评
    public ICollection<Tag> Tags { get; set; }   //EF Core 5 与 Tag 实体类的自动多对多关系
    public ICollection<BookAuthor> AuthorsLink { get; set; }  //提供指向多对多链接表的链接,该链接表链接到本书的作者
}

3.4.1 主从关系

EF 中使用术语“主体”和“从属”来定义关系的各个部分:

  • 主实体——包含依赖关系通过外键引用的主键
  • 从属实体——包含引用主体实体主键的外键

在图书应用进程示例中,图书实体类是主要实体。 PriceOffer、Review 和 BookAuthor 实体类是依赖实体。我发现“主要”和“从属”这两个术语很有帮助,因为它们定义了负责的内容:主要实体。我在本书中凡适用的地方都会使用这些术语。

注意:实体类可以同时是主体实体和从属实体。例如,在图书馆与有评论的书籍的层次关系中,书籍将是图书馆实体类的依赖关系。

关系中的受抚养人能否在没有委托人的情况下存在?

依赖关系的另一个方面是它是否可以独立存在。如果主关系被删除,依赖关系是否存在仍然存在的业务案例?在许多情况下,如果没有主要关系,关系的从属部分就没有意义。例如,如果书评链接到的书已从数据库中删除,则书评就没有意义。

在少数情况下,即使主体被删除,依赖关系也应该存在。假设您想要记录一本书在其生命周期内发生的所有更改。如果您删除一本书,您不希望该组日志也被删除。

在数据库中,此任务是通过处理外键的可为空性来处理的。如果依赖关系中的外键不可为空,则依赖关系不能在没有主体的情况下存在。在示例 Book App 数据库中,PriceOffer、Review 和 BookAuthor 实体都依赖于主体 Book 实体,因此它们的外键为 int 类型。如果删除该书或删除该书的链接,则依赖实体也将被删除。

但是如果你定义一个用于日志记录的类——我们称之为 BookLog——你希望这个类存在,即使这本书被删除了。为了实现这一点,您需要将其 BookId 外键设置为 Nullable 类型。然后,如果您删除 BookLog 实体链接到的书籍,您可以将 BookLog 的 BookId 外键配置为 null。

注意:在前面的 BookLog 示例中,如果删除 BookLog 链接到的 Book 实体,则默认操作是将 BookLog 的外键设置为 null,因为 EF Core 默认为可选关系的 OnDelete 属性的 ClientSetNull 设置。第 8.8.1 节更详细地介绍了此主题。

我现在提到这种情况,是因为当我们更新关系时,在某些情况下,从属关系会从其主体中删除。我将举一个例子,用新的依赖关系替换所有依赖关系。我们删除的旧关系会发生什幺情况取决于外键的可空性:如果外键不可为 null,则删除依赖关系,如果外键可为 null,则将其设置为 null。在第 3.5 节中,我将详细讨论此主题以及 EF Core 如何处理删除操作。

3.4.2 更新一对一关系:将 PriceOffer 添加到图书

在我们的示例 Book App 数据库中,我们有一个可选的依赖关系属性,称为 Promotion,从 Book 实体类到 PriceOffer 实体类。本小节介绍如何将 PriceOffer 类添加到现有书籍中。此列表显示 PriceOffer 实体类的内容,该类通过名为 BookId 的外键链接到 Books 表。

清单 3.11 PriceOffer 实体类,将外键显示回 Book 实体

public class PriceOffer  //如果存在PriceOffer,则旨在覆盖正常价格。
{
    public int PriceOfferId { get; set; } 
    public decimal NewPrice { get; set; }
    public string PromotionalText { get; set; }

    //Relationships
    public int BookId { get; set; }  //外键返回它应该被应用到的书
}

连接状态更新

连接状态更新假定你对读取和更新使用相同的上下文。清单 3.12 显示了一个代码示例,它有三个阶段:

  1. 加载具有任何现有 PriceOffer 关系的 Book 实体。
  2. 将关系设置为要应用于此书的新 PriceOffer 实体。
  3. 调用 SaveChanges 以更新数据库。
var book = context.Books  //找到一本书。在此示例中,该图书没有现有的促销活动,但如果存在现有的促销活动,它也可以正常工作。
    .Include(p => p.Promotion)  //虽然不需要包含,因为您加载的内容没有促销,但使用包含是很好的做法,因为如果要更改关系,则应加载任何关系。
    .First(p => p.Promotion == null);

book.Promotion = new PriceOffer  //为本书添加新的PriceOffer
{
    NewPrice = book.Price / 2, 
    PromotionalText = "Half price today!"
};
context.SaveChanges();  //SaveChanges 方法调用 DetectChanges,后者发现 Promotion 属性已更改,因此它将该实体添加到 PriceOffers 表中。

正如你所看到的,关系的更新就像你为更改图书的出版日期所做的基本更新一样。在这种情况下,由于这种关系,EF Core 必须执行额外的工作。EF Core 在 PriceOffers 表中创建了一个新行,您可以在 EF Core 为清单 3.12 中的代码生成的 SQL 代码片段中看到该行:

INSERT INTO [PriceOffers] ([BookId], [NewPrice], [PromotionalText]) VALUES (@p0, @p1, @p2);

现在,如果图书上存在现有促销活动(即 Book 实体类中的 Promotion 属性不为 null),会发生什幺情况?这种情况就是为什幺加载 Book 实体类的查询中的 Include(p => p.Promotion) 命令如此重要。由于该 Include 方法,EF Core 将知道已将现有 PriceOffer 分配给这本书,并将在添加新版本之前将其删除。

需要明确的是,在这种情况下,您必须使用某种形式的关系加载——急切、显式、选择或延迟加载关系——以便 EF Core 在更新之前知道它。如果不这样做,并且存在现有关系,EF Core 将对重复的外键 BookId 引发异常,EF Core 已在该外键上放置唯一索引,并且 PriceOffers 表中的另一行将具有相同的值。

断开连接状态更新

在断开连接状态下,定义要更新哪本书以及在 PriceOffer 实体类中放入什幺内容的信息将从阶段 1 传递回阶段 2。这种情况发生在书籍出版日期的更新中(图 3.2),其中BookId 和 PublishedOn 值已反馈。

在向图书添加促销的情况下,您需要传入唯一定义所需图书的 BookId,以及构成 PriceOffer 实体类的 NewPrice 和 PromotionalText 值。下一个清单显示了 ChangePriceOfferService 类,其中包含两个方法,用于向用户显示数据以及在用户提交请求时更新 Book 实体类上的促销信息。

清单 3.13 ChangePriceOfferService 类具有处理每个阶段的方法

public class ChangePriceOfferService : IChangePriceOfferService
{
    private readonly EfCoreContext _context; 
    public Book OrgBook { get; private set; }

    public ChangePriceOfferService(EfCoreContext context)
    {
        _context = context;
    }
 
    public PriceOffer GetOriginal(int id)  //获取一个 PriceOffer 类,发送给用户更新
    {
        OrgBook = _context.Books  //装载任何现有促销的书籍
            .Include(r => r.Promotion)
            .Single(k => k.BookId == id);
        //您可以返回现有的促销活动进行编辑,也可以创建一个新的促销活动。重要的一点是设置 BookId,因为您需要将其传递到第二阶段。
        return OrgBook?.Promotion ?? new PriceOffer
        {
            BookId = id,
            NewPrice = OrgBook.Price
        };
    }
 
    public Book AddUpdatePriceOffer(PriceOffer promotion)  //处理更新的第二部分,对所选书籍的 Promotion 属性执行选择性添加/更新
    {
        var book = _context.Books  //在图书中加载任何现有的促销活动,这很重要,否则,您的新 PriceOffer 将发生冲突并引发错误
            .Include(r => r.Promotion)
            .Single(k => k.BookId == promotion.BookId); 
        if (book.Promotion == null)  //检查代码是否应创建新的 PriceOffer 或更新现有的 PriceOffer
        {
            book.Promotion = promotion;  //您需要添加新的 PriceOffer,以便将促销活动分配给关系链接。EF Core 将看到它,并在 PriceOffer 表中添加一个新行。
        }
        else
        {
            //您需要进行更新,因此您只复制要更改的部分。EF Core 将看到此更新,并生成仅更新这两列的代码。
            book.Promotion.NewPrice = promotion.NewPrice; 
            book.Promotion.PromotionalText = promotion.PromotionalText;
        }
        _context.SaveChanges();   //SaveChanges 使用其 DetectChanges 方法,该方法查看哪些更改 - 添加新的 PriceOffer 或更新现有更改。
        return book;  //返回更新的图书
    }	
}	

此代码将更新现有 PriceOffer,或者添加新的 PriceOffer(如果不存在)。调用 SaveChanges 时,它可以通过 EF Core 的 DetectChanges 方法确定需要哪种类型的更新,并创建正确的 SQL 来更新数据库。这与清单 3.12 中所示的连接版本不同,在清单 3.12 中,您将任何 PriceOffer 替换为新版本。这两个版本都可以使用,但是如果您要记录上次创建/更新实体的人(请参阅第 11.4.3 节),则更新现有实体会为您提供有关更改内容的更多信息。

更新关系的另一种方法:直接创建新行

我们已将此更新视为更改 Book 实体类中的关系,但您也可以将其视为在 PriceOffers 表中创建/删除一行。此列表查找数据库中没有关联促销的第一本书,然后向该书籍添加新的 PriceOffer 实体。

清单 3.14 创建一个 PriceOffer 行以与现有书籍一起使用

var book = context.Books
    .First(p => p.Promotion == null);  //您找到要添加新 PriceOffer 的图书,该图书不能是现有的 PriceOffer。

context.Add( new PriceOffer  //将新的 PriceOffer 添加到 PriceOffers 表中
{
    //定义 PriceOffer。必须包含 BookId(EF Core 之前填写了该 ID)。
    BookId = book.BookId, NewPrice = book.Price / 2,
    PromotionalText = "Half price today!"
});
 
context.SaveChanges();  //SaveChanges 将 PriceOffer 添加到 PriceOffers 表中。

您应该注意,以前,您不必在 PriceOffer 实体类中设置 BookId 属性,因为 EF Core 已为您完成此操作。但是当您以这种方式创建关系时,您确实需要设置外键。完成此操作后,如果您在前面的创建代码之后加载具有促销关系的 Book 实体类,您会发现该 Book 已获得促销关系。

注意:PriceOffer 实体类没有返回到 Book 类的关系属性链接(public Book BookLink {get; set;})。如果是这样,您可以将 BookLink 设置为 Book 实体类,而不是设置外键。设置外键或设置返回主体实体的关系链接将告诉 EF Core 设置关系。

创建依赖实体类的优点是,它使您无需在断开连接的状态下重新加载主体实体类(在本例中为 Book)。缺点是 EF Core 无法帮助您处理关系。在这种情况下,如果图书上已有 PriceOffer 并且您添加了另一个,SaveChanges 将失败,因为您有两个具有相同外键的 PriceOffer 行。

当 EF Core 无法帮助您处理关系时,您需要谨慎使用创建/删除方法。有时,这种方法可以使处理复杂的关系变得更容易,因此值得记住,但我更喜欢在大多数一对一的情况下更新主体实体类的关系。

注意:稍后,在第 3.4.5 节中,您将学习另一种通过更改外键来更新关系的方法。

3.4.3 更新一对多关系:为书籍添加评论

您已经通过查看一对一关系了解了更新关系的基本步骤。正如您已经看到的基本模式一样,我会更快地处理剩余的关系。但我也会指出关系的许多方面的一些差异。

Book App数据库中的一对多关系用Book’s Reviews来表示;该网站的用户可以添加对书籍的评论。可以有任意数量的评论,从无到很多。此清单显示了依赖于 Review 的实体类,它通过名为 BookId 的外键链接到 Books 表。

清单 3.15 Review 类,显示返回 Book 实体类的外键

public class Review  //持有客户评论与他们的评级
{
    public int ReviewId { get; set; } 
    public string VoterName { get; set; } 
    public int NumStars { get; set; } 
    public string Comment { get; set; }

    //Relationships
    public int BookId { get; set; }  //外键保存着该评论所属图书的键。
}

连接状态更新

清单 3.16 为一本书添加了一个新的评论。此代码遵循与一对一连接更新相同的模式:通过 Include 方法加载 Book 实体类和 Reviews 关系。但在本例中,将 Review 实体添加到 Book 的 Reviews 集合中。由于使用了 Include 方法,因此,如果没有评论或链接到此书的评论集合,则 Reviews 属性将是一个空集合。在此示例中,数据库已包含一些 Book 实体,我采用第一个实体。

清单 3.16 为处于连接状态的图书添加评论

//找到第一本书,并加载它可能有的任何评论
var book = context.Books
    .Include(p => p.Reviews)
    .First();

book.Reviews.Add(new Review  //为本书添加新的评论
{
    VoterName = "Unit Test", 
    NumStars = 5,
    Comment = "Great book!"
 
    context.SaveChanges();  //SaveChanges 调用 DetectChanges,后者发现 Reviews 属性已更改,然后从那里找到新的 Review,并将其添加到 Review 表中。
});

与 PriceOffer 示例一样,您无需在 Review 中填写外键(BookId 属性),因为 EF Core 知道 Review 正在添加到 Book 实体类,并将外键设置为正确的值。

更改/替换所有一对多关系

在继续进行断开连接状态更新之前,我想考虑一下您想要更改或替换整个集合的情况,而不是像您在评论中所做的那样添加到集合中。

如果书籍有类别(例如,软件设计、软件语言等),您可以允许管理员用户更改类别。实现此更改的一种方法是在多选列表中显示当前类别,允许管理员用户更改它们,然后用新选择替换书中的所有类别。

EF Core 使替换整个集合变得容易。如果将新集合分配给已加载跟踪的一对多关系(例如通过使用 Include 方法),EF Core 会将现有集合替换为新集合。如果集合中的项目只能链接到主类(依赖类具有不可为 null 的外键),则默认情况下,EF Core 将删除集合中已删除的项目。

接下来是用新集合替换整个现有书评集合的示例。效果是删除原始评论并用一条新评论替换它们。

清单 3.17 用另一个集合替换整个评论集合

var book = context.Books
    .Include(p => p.Reviews)  //Include 很重要;它会创建一个包含任何现有评论的集合,如果没有现有评论,则创建一个空集合。
    .Single(p => p.BookId == twoReviewBookId);  //您正在加载的这本书有2条评论。

book.Reviews = new List<Review>
{
    //你替换了整个集合。
    new Review
    {
        VoterName = "Unit Test", 
        NumStars = 5,
    }
};	
 
context.SaveChanges();  //SaveChanges 通过 DetectChanges 知道应删除旧集合,并将新集合写入数据库。

因为您在示例中使用了测试数据,所以您知道主键为twoReviewBookId的书有两条评论,并且该书是唯一有评论的书;因此,整个数据库中只有两条评论。调用 SaveChanges 方法后,该书只有一篇评​​论,并且两条旧评论已被删除,因此现在数据库中只有一篇评​​论。

删除一行就像从列表中删除实体一样简单。 EF Core 将看到更改并删除链接到该实体的行。同样,如果您将新评论添加到图书的评论集合属性中,EF Core 将看到对该集合的更改,并将新评论添加到数据库中。

加载现有集合对于这些更改非常重要:如果不加载它们,EF Core 将无法删除、更新或替换它们。更新后旧版本仍将存在于数据库中,因为 EF Core 在更新时并不知道它们。您还没有用您的单个评论替换现有的两个评论。事实上,您现在有三个评论——原来在数据库中的两个和您的新评论——这不是您想要做的。

断开连接状态更新

在断开连接的状态下,您创建一个空的 Review 实体类,但用用户想要提供评论的书籍填充其外键 BookId。然后用户对该书进行投票,然后您将该评论添加到他们引用的书中。以下清单显示了 AddReviewService 类,该类具有用于设置和更新书籍的方法,以添加用户的新评论。

清单 3.18 在示例图书应用进程中向图书添加新评论

public class AddReviewService
{
    private readonly EfCoreContext _context;
    public string BookTitle { get; private set; } 
    
    public AddReviewService(EfCoreContext context)
    {
        _context = context;
    }
 
    public Review GetBlankReview(int id)  //形成用户要填写的评论
    {
        //您阅读书名,以便在用户填写评论时显示给他们。 
        BookTitle = _context.Books
            .Where(p => p.BookId == id)
            .Select(p => p.Title)
            .Single();
        return new Review  //创建填写 BookId 外部密钥的评论
        {
            BookId = id
        };
    }
 
    public Book AddReviewToBook(Review review)  //用新的评论更新本书
    {
        //使用评论的外键中的值加载正确的书籍,并包括任何现有评论(如果还没有评论,则包含空集合) 
        var book = _context.Books
            .Include(r => r.Reviews)
            .Single(k => k.BookId == review.BookId); 
        book.Reviews.Add(review);  //将新评论添加到评论集合中
        _context.SaveChanges();   //SaveChanges 使用其 DetectChanges 方法,该方法可查看“书评”属性已更改,并在“审阅”表中创建一个新行。
        return book;  //返回更新的图书
	}
}

此代码的第一部分比前面的断开状态示例更简单,因为您要添加新评论,因此不必为用户加载现有数据。但总的来说,代码采用了与 ChangePriceOfferService 类使用的相同的方法。

更新关系的另一种方法:直接创建新行

与 PriceOffer 一样,您可以直接向数据库添加一对多关系。但同样,你承担了管理关系的角色。例如,如果您想替换整个评论集合,则必须在添加新集合之前删除评论链接到相关书籍的所有行。

直接向数据库添加一行有一些优点,因为如果您有很多项目和/或它们很大,则加载所有一对多关系可能会产生大量数据。因此,如果您遇到性能问题,请记住这种方法。

注意:我的实验表明,不加载关系然后将新集合分配给一对多关系相当于直接创建新行。但我不建议这样做,因为这不是正常的更新模式;其他人(甚至你)可能会稍后回来并误读你的意图。

3.4.4 更新多对多关系

在 EF Core 中,我们谈论多对多关系,但关系数据库并不直接实现多对多关系。相反,我们正在处理两个一对多关系,如图 3.4 所示。

图 3.4 数据库中的多对多关系是由一个链接表创建的,该链接表包含需要多对多关系的两个表的主键。

在 EF Core 中,您有两种方法在两个实体类之间创建多对多关系:

  • 链接到每个实体中的链接表,也就是说,在 Left 实体类中有一个 ICollection 属性。您需要创建一个实体类来充当链接表(例如图 3.4 中的 LeftRight),但该实体类允许您在链接表中添加额外的数据,以便您可以对多对多关系进行排序/筛选。
  • 您可以在想要创建多对多关系的两个实体类之间直接链接——也就是说,您的 Left 实体类中有一个 ICollection 属性。此链接更容易编写代码,因为 EF Core 处理链接表的创建,但您无法在正常的 Include 方法中访问链接表以进行排序/筛选。

注意:本章使用 EF Core 默认设置来实现多对多关系。第 8 章介绍多对多关系的配置选项。

通过链接实体类更新多对多关系

在 Book 实体类中,您需要一个到书籍作者的多对多链接。但在一本书中,作者姓名的顺序很重要。因此,您可以创建一个具有 Order(字节)属性的链接表,该属性允许您以正确的顺序显示 Author's Name 属性,这意味着您

  • 创建一个名为BookAuthor的实体类,它同时包含Book实体类的主键(BookId)和Author实体类的主键(AuthorId)。您还可以添加一个 Order 属性,其中包含一个数字,用于设置这本书的作者的显示顺序。 BookAuthor 链接实体类还包含与作者和书籍的两个一对一关系。
  • 将名为 AuthorsLink 的 Icollection 类型的导航属性添加到 Book 实体类中。
  • 还可以将名为 BooksLink 的 Icollection 类型的导航属性添加到 Author 实体类中。

图 3.5 显示了这三个实体类,仅显示了 Book 到 BookAuthor 和 BookAuthor 到 Author 的链接。

图 3.5 The Book 与其作者的多对多关系,它使用 BookAuthor 链接表。由于您创建了指向 BookAuthor 实体类的一对多链接,因此可以访问 Order 属性,以对向客户显示作者姓名的顺序进行排序。

如图 3.5 所示,BookAuthor 实体类有两个属性:BookId 和 AuthorId。这些属性分别是 Books 表和 Authors 表的外键。它们还共同构成了 BookAuthor 行的主键(称为复合键,因为它有多个部分)。复合键的作用是确保图书和作者之间只有一个链接。第 7 章更详细地介绍了组合键。此外,BookAuthor 实体类还具有一个 Order 属性,该属性允许您定义 Author 实体类的顺序,以便 Author's Name 属性将显示在“Book App”图书列表中。

例如,您将通过 BookAuthor 链接实体类将作者 Martin Fowler 添加为《量子网络》一书的额外作者。 (我敢肯定,如果 Martin Fowler 在量子网络完善时在场,他会很乐意与本书合作。将 Order 属性设置为 1 以使 Martin Fowler 成为第二作者。(当前 Author 的现有 BookAuthor 实体的 Order 属性设置为 0。下一个列表显示了生成的代码。

清单 3.19 在《量子网络》一书中添加新的作者

//此代码查找标题为“量子网络”的书,其当前作者是“未来人”。
var book = context.Books
    .Include(p => p.AuthorsLink)
    .Single(p => p.Title == "Quantum Networking");

var existingAuthor = context.Authors
    .Single(p => p.Name == "Martin Fowler");  //你找到一个现有的作者 - - 在这种情况下,"马丁·福勒"。

book.AuthorsLink.Add(new BookAuthor  //将新的 BookAuthor 链接实体添加到 Book 的 AuthorsLink 集合。
{
    //填写多对多关系中的两个导航属性。
    Book = book,
    Author = existingAuthor,
    Order = (byte) book.AuthorsLink.Count  //将 Order 设置为 AuthorsLink 的旧计数,在本例中为 1(因为第一作者的值为 0)。
});
context.SaveChanges();  //SaveChanges 将在 BookAuthor 表中创建新行。

需要理解的是,BookAuthor 实体类是关系的多方。这个列表将另一位作者添加到其中一本书中,应该看起来很熟悉,因为它类似于我已经解释过的一对多更新方法。

需要注意的一点是,加载Book的AuthorsLink时,不需要加载Author实体类中对应的BooksLink。原因是,当您更新 AuthorsLink 集合时,EF Core 知道有一个指向该书的链接,并且在更新期间,EF Core 将自动填充该链接。下次有人加载 Author 实体类及其 BooksLink 关系时,他们将看到该集合中的 Quantum Networking 书籍的链接。 (有关何时填写哪些链接的详细信息,请参阅第 6.2.2 节。)

另请注意,删除 AuthorsLink 条目不会删除它们链接到的书籍或作者实体,因为该条目是一对多关系的一端,不依赖于书籍或作者。事实上,Book 和 Author 实体类是主要实体,BookAuthor 类依赖于这两个主要实体类。

通过直接访问其他实体来更新多对多关系

EF Core 5 添加了以多对多关系直接访问另一个实体类的功能。此功能使设置和使用多对多关系变得更加容易,但您将无法在 Include 方法中访问链接表。

EF6:在 EF6.x 中,您可以定义多对多关系,EF6.x 将为您创建一个隐藏链接表,并处理该表中行的所有创建/删除。 EF Core 5 添加了该功能,但现在您可以更好地控制链接表的配置。

在图书应用进程中,一本书可以有零到多个类别,例如 Linux、数据库和 Microsoft .NET,以帮助客户找到合适的图书。这些类别保存在 Tag 实体中(TagId 保存类别名称),与 Book 具有直接的多对多关系。这允许图书在图书应用进程的图书列表显示中显示其类别,还允许图书应用进程提供按类别过滤图书列表显示的功能。图 3.6 显示了 Book 和 Tag 实体类及其相互直接链接的属性。

图 3.6 Book 实体类和 Tag 实体类之间的直接多对多关系。您可以访问多对多关系的每一端。当 EF Core 看到这种多对多关系时,它会生成一个隐藏的实体类,并创建正确的数据库代码以使用关联的链接表。

这种直接访问的多对多功能使得在 Book 实体和 Tag 实体之间添加/删除链接变得简单。以下列表显示了如何向 Quantum 网络手册添加另一个标记。

清单 3.20 通过直接的多对多关系向一本书添加标签

//找到标题为“量子网络”的书,并加载其标签
var book = context.Books
    .Include(p => p.Tags)
    .Single(p => p.Title == "Quantum Networking");

var existingTag = context.Tags  //您找到名为"编辑选择"的标签以添加此书。
    .Single(p => p.TagId == "Editor's Choice");
 
book.Tags.Add(existingTag);   //您将标签添加到图书标签集合中。
context.SaveChanges();  //调用 SaveChanges 时,EF Core 会在隐藏的 BookTags 表中创建一个新行。

如果您将之前的清单(清单 3.20)与清单 3.19 中向书中添加另一个作者进行比较,您会发现向直接多对多关系添加新条目要容易得多。 EF Core 承担在 BooksTag 表中创建必要行的工作。如果您删除了 Tags 集合中的条目,您将删除 BooksTag 表中的相应行。

更新关系的另一种方法:直接创建新行

描述了如何更新两种类型的多对多关系后,现在我将讨论另一种方法:直接创建链接表行。当集合中有大量条目时,此方法的优点是性能更好。

您可以在链接表中创建一个新条目,而不必读取集合。例如,您可以创建一个 BookAuthor 实体类,并在该类中填写 Book 和 Author 一对一关系。然后,将新的 BookAuthor 实体实例添加到数据库中并调用 SaveChanges。对于可能很小的 AuthorsLink 集合,此技术很可能不值得付出额外的努力,但对于包含大量链接条目的多对多关系,它可以显着提高性能。

3.4.5 高级功能:通过外键更新关系

到目前为止,我已经向您展示了如何使用实体类本身来更新关系。例如,当您向书籍添加评论时,您会加载书籍实体及其所有评论。这很好,但在断开连接的状态下,您必须从浏览器/RESTful API 返回的图书主键加载图书及其所有评论。在许多情况下,您可以删除实体类的加载并设置外键。

这种技术适用于我迄今为止展示的大多数断开连接的更新,但让我举一个将评论从一本书转移到另一本书的示例。 (我知道——这种情况在现实世界中不太可能发生。但它只是一个简单的例子。)下面的清单在用户键入请求后执行更新。该代码假设用户想要更改的评论的 ReviewId 以及他们想要附加评论的新 BookId 在名为 dto 的变量中返回。

清单 3.21 更新外键以更改关系

//使用从浏览器返回的主键查找要移动的审阅
var reviewToChange = context
    .Find<Review>(dto.ReviewId); 
reviewToChange.BookId = dto.NewBookId;   //更改评论中的外键以指向应链接到的书籍
context.SaveChanges();  //调用 SaveChanges,它会发现审阅中的外键已更改,因此它会更新数据库中的该列

这种技术的好处是,您不必加载 Book 实体类或使用 Include 命令来加载与这本书相关的所有评论。在我们的示例图书应用进程中,这些实体并不太大,但在实际应用进程中,主要实体和依赖实体可能非常大。 (例如,某些亚马逊产品有数千条评论。)在断开连接的系统中,我们经常通过断开连接仅发送主键,这种方法对于减少数据库访问非常有用,从而提高性能。

注意:通过外键更新关系时,您可能需要访问应用进程的 DbContext 中没有 DbSet 属性的实体,那幺如何读入数据呢?清单 3.21 使用 Find 方法,但如果您需要更复杂的查询,则可以通过 Set 方法访问任何实体,例如 context.Set().Where(p => p.投票数 > 5)。

3.5 删除实体

更改数据库中数据的最后一种方法是从表中删除一行。删除数据比我们已经讨论过的更新更容易,但确实有几点需要注意。在描述如何从数据库中删除实体之前,我想介绍一种称为软删除的方法,其中实体被隐藏而不是被删除。

注意:我在第 6.1.7 节中提供了一些有关使用软删除的额外信息,其中涵盖了实际应用进程中的某些情况。

3.5.1 软删除方法:使用全局查询过滤器隐藏实体

一种思想流派认为,不应从数据库中删除任何内容,而应使用状态来隐藏它,称为软删除。 (参见 Udi Dahan 的帖子“不要删除——就不要”,网址:http://mng.bz/6glD。)我认为这种方法是明智的,EF Core 提供了一种称为全局查询过滤器的功能,允许软删除的实现很简单。

软删除背后的想法是,在现实世界的应用进程中,数据并不会不再是数据;而是会继续存在。它转变为另一种状态。在我们的书籍示例中,一本书可能不再在售,但该书存在的事实是不容置疑的,那幺为什幺要删除它呢?相反,您设置一个标志来表示该实体将隐藏在所有查询和关系中。要了解此过程的工作原理,您需要将软删除功能添加到 Book 实体列表中。为此,您需要做两件事:

  • 将名为 SoftDeleted 的布尔属性添加到 Book 实体类中。如果该属性为 true,则 Book 实体实例将被软删除;它不应该在正常查询中找到。
  • 通过 EF Core 的流畅配置命令添加全局查询过滤器。效果是对 Books 表的任何访问应用额外的Where 过滤器。

将 SoftDeleted 属性添加到 Book 实体实例非常简单。此代码片段显示了具有 SoftDeleted 属性的 Book 实体类:

 

public class Book
{
    //… other properties left out for clarity 
    public bool SoftDeleted { get; set; }
}

将全局查询过滤器添加到 DbSetBooks 属性意味着将 EF Core 配置命令添加到应用进程的 DbContext。第 7 章介绍了这个配置命令,但它在下面的列表中以粗体显示,以便您了解发生了什幺。

清单 3.22 将全局查询过滤器添加到 DbSetBooks 属性

public class EfCoreContext : DbContext
{
    //… Other parts removed for clarity

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        //… other configration parts removed for clarity
 
        //向对 Book 实体的所有访问添加筛选器。可以使用 IgnoreQueryFilters 运算符绕过此筛选器
        modelBuilder.Entity<Book>()
            .HasQueryFilter(p => !p.SoftDeleted);
    }
}

若要软删除 Book 实体,需要将 SoftDeleted 属性设置为 true 并调用 SaveChanges。然后,对 Book 实体的任何查询都将排除 SoftDeleted 属性设置为 true 的 Book 实体。

如果要访问具有模型级筛选器的所有实体,请将 IgnoreQueryFilters 方法添加到查询中,例如 context。Books.IgnoreQueryFilters()。此方法绕过该实体上的任何查询筛选器。

注意:我构建了一个名为 EfCore.SoftDeleteServices 的库,它提供了用于配置和使用这种形式的软删除的代码。有关详细信息,请参阅 http://mng.bz/op7r

现在我们已经介绍了软删除方法,让我们介绍一下真正从数据库中删除实体的方法。我们将从一个简单的示例开始,然后删除具有关系的实体。

3.5.2 删除没有关系的仅依赖实体

我选择了 PriceOffer 实体类来显示基本删除,因为它是一个依赖实体。因此,您可以删除它而不影响其他实体。此列表查找 PriceOffer,然后将其删除。

清单 3.23 从数据库中删除(删除)实体

var promotion = context.PriceOffers.First();  //找到第一个 PriceOffer

context.Remove(promotion);   //从应用进程的 DbContext 中删除该 PriceOffer。DbContext 根据其参数类型确定要删除的内容。
context.SaveChanges();  //SaveChanges 调用 DetectChanges,后者查找标记为已删除的跟踪 PriceOffer 实体,然后将其从数据库中删除。

 调用Remove方法将作为参数提供的实体的状态设置为Deleted。然后,当您调用 SaveChanges 时,EF Core 会找到标记为已删除的实体,并创建正确的数据库命令以从实体引用的表中删除相应的行(在本例中为 PriceOffers 表中的行)。 EF Core 为 SQL Server 生成的 SQL 命令如以下代码片段所示:

SET NOCOUNT ON;
DELETE FROM [PriceOffers] WHERE [PriceOfferId] = @p0; 
SELECT @@ROWCOUNT;

3.5.3 删除有关系的主体

3.3.1 节讨论了主从关系以及外键的可为空性。关系数据库需要保持引用完整性,因此,如果您删除表中其他行通过外键指向的行,则必须采取措施来阻止引用完整性丢失。

定义:参照完整性是一个关系数据库概念,表明表关系必须始终一致。任何外键字段必须与外键引用的主键一致(请参阅 http://mng.bz/XY0M)。

以下是在删除具有依赖实体的主体实体时设置数据库以保持引用完整性的三种方法:

  • 您可以告诉数据库服务器删除依赖于主体实体的依赖实体,称为级联删除。
  • 如果列允许,您可以告诉数据库服务器将依赖实体的外键设置为空。
  • 如果这些规则均未设置,则当您尝试删除具有依赖实体的主体实体时,数据库服务器将引发错误。

3.5.4 删除书籍及其依赖关系

在本部分中,您将删除 Book 实体,该实体是具有三个依赖关系的主体实体:Promotion、Reviews 和 AuthorsLink。如果没有 Book 实体,这三个依赖实体就不可能存在;不可为空的外键将这些依赖实体链接到特定的 Book 行。

默认情况下,EF Core 对具有不可空外键的依赖关系使用级联删除。从开发人员的角度来看,级联删除使删除主体实体变得更容易,因为其他两个规则需要额外的代码来处理删除依赖实体。但在许多业务应用中,这种方法可能并不合适。本章使用级联删除方法,因为它是 EF Core 对于不可为空外键的默认设置。

考虑到这一点,让我们通过使用默认的级联删除设置来删除具有关系的图书来查看级联删除的实际效果。此列表在删除该图书之前加载该图书实体类的 Promotion(PriceOffer 实体类)、Reviews、AuthorsLink 和 Tags 关系。

清单 3.24 删除一本具有三个依赖实体类的书

var book = context.Books
    .Include(p => p.Promotion)  //这四个 Include 确保这四个从属关系都加载到书中。
    .Include(p => p.Reviews)
    .Include(p => p.AuthorsLink)
    .Include(p => p.Tags)
    .Single(p => p.Title == "Quantum Networking");  //找到 Quantum Networking 书籍,您知道该书籍具有促销、两条评论、一个 BookAuthor 链接和一个 BookTag

context.Books.Remove(book);  //删除该书
context.SaveChanges();  //SaveChanges 调用 DetectChanges,后者查找标记为已删除的跟踪 Book 实体,删除其依赖关系,然后删除该 Book。

我的测试数据包含一本标题为 Quantum Networking 的书,其中有一个 PriceOffer、两个 Reviews 以及一个与其关联的 BookAuthor 实体。我提到的所有这些依赖实体的外键都指向《量子网络》一书。清单 3.24 中的代码运行后,EF Core 删除 Book、PriceOffer、两条 Reviews、单个 BookAuthor 链接和单个(隐藏)BookTag。

最后一条语句表明 EF Core 删除了所有内容,这一点很重要。因为您放入了四个 Include,所以 EF Core 了解依赖实体并执行了删除。如果您没有将 Includes 合并到代码中,EF Core 将不知道依赖实体,也无法删除这三个依赖实体。在这种情况下,保持引用完整性的问题将落在数据库服务器身上,其响应将取决于外键约束的 DELETE ON 部分的设置方式。默认情况下,EF Core 为这些实体类创建的数据库将设置为使用级联删除。

注意:链接到图书的作者和标签不会被删除,因为它们不是图书的依赖实体;仅删除 BookAuthor 和 BookTag 链接实体。这种安排是有道理的,因为作者和标签可能会在其他书籍上使用。

第 8.8.1 节介绍了如何配置 EF Core 处理关系中依赖实体删除的方式。有时,如果某个依赖实体链接到某个主体实体,则阻止该主体被删除很有用。例如,在我们的示例图书应用进程中,如果客户订购了一本书,即使该书不再出售,您也希望保留该订单信息。在这种情况下,您将 EF Core 的删除操作更改为“限制”,并从数据库中的外键约束中删除 ON DELETE CASCADE,以便在尝试删除该图书时会引发错误。

注意:当您删除具有可为空外键(称为可选依赖关系)的依赖实体的主体实体时,EF Core 处理删除的方式与数据库处理删除的方式之间存在细微差别。我在第 8.8.1 节中通过有用的表 8.1 解释了这种情况。

总结

  • 实体实例有一个状态,其值可以是已添加、未更改、已修改、已删除或已分离。该状态定义了调用 SaveChanges 时实体会发生什幺情况。
  • 如果添加实体,其状态将设置为已添加。当您调用 SaveChanges 时,该实体将作为新行写入数据库。
  • 您可以通过将实体类加载为跟踪实体、更改属性并调用 SaveChanges 来更新实体类中的一个或多个属性。
  • 实际应用进程使用两种类型的更新场景——连接状态和断开连接状态——这会影响执行更新的方式。
  • EF Core 有一个 Update 方法,它将整个实体类标记为已更新。当您想要更新实体类并且所有数据都已可用时,可以使用此方法。
  • 当您更新关系时,您有两种选择,各有不同的优点和缺点:
    • 您可以加载与主要实体的现有关系并更新主要实体中的该关系。 EF Core 将从那里解决问题。此选项更易于使用,但在处理大型集合时可能会产生性能问题。
    • 您可以创建、更新或删除依赖实体。这种方法很难正确执行,但通常速度更快,因为您不需要加载任何现有关系。
  • 要从数据库中删除实体,请使用Remove 方法,然后使用SaveChanges 方法。

对于 EF6.x 读者:

  • Update 方法是 EF Core 中一个受欢迎的新命令。在 EF6.x 中,您必须使用 DbContext.Entry(object).State 来实现该功能。
  • EF Core 提供了“添加”、“更新”和“删除”的简写。您可以将这些命令中的任何一个应用于上下文本身,如 context.Add(book) 中。
  • 在 EF6.x 中,默认情况下,SaveChanges 在向数据库中添加实体或更新实体之前验证数据。 EF Core 不会对 SaveChanges 运行任何验证,但很容易添加回来(请参阅第 4 章)。
  • EF6.x 允许您直接定义多对多关系,并负责创建链接表和管理行以使该过程正常运行。 NET Core 5 将此功能添加到 EF Core 中;第 3.4.4 节介绍了这个主题。