第8章 配置关系

发布时间 2024-01-05 16:08:18作者: 生活的倒影

本章涵盖

  • 配置与 By Convention 的关系
  • 配置与数据注释的关系
  • 配置与 Fluent API 的关系
  • 以其他五种方式将实体映射到数据库表

第 7 章介绍了如何配置标量(非关系)属性。本章介绍如何配置数据库关系。我假设您至少已经阅读了第 7 章的第一部分,因为配置关系使用相同的三种方法(按约定、数据注释和 Fluent API)来映射数据库关系。

本章介绍 EF Core 如何查找和配置实体类之间的关系,并提供指针和示例演示如何配置每种类型的关系:一对一、一对多和多对多。EF Core 的“按约定”关系规则可以快速配置许多关系,但你还将了解所有数据批注和 Fluent API 配置选项,这些选项使你能够精确定义关系的行为方式。您还将了解允许您使用额外键和替代表映射方法增强关系的功能。最后,您将考虑将类映射到数据库的五种方法。

8.1 定义一些关系术语

本章涉及关系的各个部分,您需要明确的术语,以便您确切地知道我们正在谈论关系的哪个部分。图 8.1 显示了使用我们图书应用进程中的图书和评论实体类来表示这些术语。我在此图之后进行了更详细的描述,因此当我在本章中使用这些术语时,这些术语对您来说是有意义的。

图 8.1 Book 和 Review 实体类显示了本章中用于讨论关系的六个术语:主体实体、从属实体、主体键、导航属性、外键和所需关系。未显示的是可选关系,在第 2.1.1 节中进行了描述。

为确保这些术语清晰,以下是详细说明:

  • 主键 - 一个新术语,取自 EF Core 文档,指的是第 1 部分中定义的主键,或新的备用键,每行具有唯一的值,并且不是主键(请参阅第 1 部分) 8.8.3)

注意:图 8.1 提供了一个名为 UniqueISBN 的备用键的示例,它代表每个实体的唯一值。 (ISBN 代表国际标准书号,每本书都是唯一的。)

  • 主实体——包含主键属性的实体,依赖关系通过外键引用该实体(第 3 章中介绍)
  • 从属实体 - 包含引用主要实体的外键属性的实体(在第 3 章中介绍)
  • 主键——实体有一个主键,也称为主键,对于数据库中存储的每个实体来说,主键都是唯一的
  • 导航属性 - 取自 EF Core 文档的术语,指包含 EF Core 用于链接实体类的单个实体类或实体类集合的属性
  • 外键——在第 2.1.3 节中定义,保存其链接到的数据库行的主键值(或者可以为空)
  • 必需的关系——外键不可为空的关系(并且主体实体必须存在)
  • 可选关系——外键可为空的关系(并且主体实体可以缺失)

注意 主键和外键可以包含多个属性/列。这些键称为复合键。您已经在第 3.4.4 节中看到了这些键之一,因为 BookAuthor 多对多链接实体类具有一个由 BookId 和 AuthorId 组成的复合主键。

您将在第 8.4 节中看到,EF Core 可以按约定查找和配置大多数关系。在某些情况下,EF Core 需要帮助,但一般来说,如果您使用“按约定”命名规则,它可以为您找到并配置导航属性。

8.2 您需要什幺导航属性?

实体类之间关系的配置应以项目的业务需求为指导。您可以在关系的两端添加导航属性,但这表明每个导航属性都是有用的,而某些导航属性则不然。最好只提供从业务或软件设计角度来看有意义的导航属性。

例如,在我们的 Book App 中,Book 实体类有许多 Review 实体类,每个 Review 类都通过外键链接到一本书。因此,您可以在 Book 类中拥有 ICollection 类型的导航属性,并在 Review 类中拥有 Book 类型的导航属性。在这种情况下,您将拥有一个完全定义的关系:两端都有导航属性的关系。

但你需要一个完全定义的关系吗?从软件设计的角度来看,关于图书/评论导航关系存在两个问题。这些问题的答案定义了您需要包含哪种导航关系:

  • Book 实体类是否需要了解 Review 实体类?我说是的,因为我们要计算平均评论分数。
  • Review 实体类是否需要了解 Book 实体类?我说不,因为在这个示例应用进程中,我们没有对这种关系做任何事情。

因此,我们的解决方案是在 Book 类中仅包含 ICollection 导航属性,如图 8.1 所示。

我的经验是,仅当从业务角度来看有意义或需要导航属性来创建(EF Core 的添加)具有关系的实体类时,才应添加导航属性(请参阅第 6.2.1 节)。最小化导航属性将有助于使实体类更易于理解,并且初级开发人员不会试图使用不适合您的项目的关系。

8.3 配置关系

与第 7 章中介绍的配置非关系属性相同,EF Core 具有三种配置关系的方法。以下是配置属性的三种方法,但重点关注关系:

  • 按照约定 - EF Core 通过查找对其中具有主键的类的引用来查找和配置关系。
  • 数据注释——这些注释可用于标记外键和关系引用。
  • 流畅的 API——该 API 提供了最丰富的命令集来完全配置任何关系。

接下来的三节依次详细介绍每种方法。正如您将看到的,如果您遵循其命名标准,则按约定方法可以为您自动配置许多关系。另一方面,Fluent API 允许您手动定义关系的每个部分,如果您的关系不符合“按约定”方法,这可能会很有用。

8.4 按约定配置关系

在配置关系时,按约定方法确实可以节省时间。在 EF6.x 中,我曾经费力地定义我的关系,因为我没有完全理解按约定方法处理关系的力量。现在我了解了约定,我让 EF Core 创建我的大部分关系,除了少数情况下 By Convention 不起作用。 (第 8.4.6 节列出了这些例外情况。)

规则很简单,但是属性名称、类型和可空性一起定义关系的方式需要一些时间来理解。我希望阅读本节能够在您开发下一个使用 EF Core 的应用进程时节省您的时间。

8.4.1 是什幺使类成为实体类?

第 2 章将术语“实体类”定义为已由 EF Core 映射到数据库的普通 .NET 类。在这里,您想要定义 EF Core 如何使用 By Convention 方法查找类并将其识别为实体类。

图 7.1 显示了 EF Core 配置自身的三种方式。以下是该过程的回顾,现在重点是查找关系和导航属性:

  1. EF Core 扫描应用进程的 DbContext,查找任何公共 DbSet 属性。它假定 DbSet 属性中的类 T 是实体类。
  2. EF Core 还会查看步骤 1 中找到的类中的每个公共属性,并查看可能是导航属性的属性。其类型包含未定义为标量属性的类(字符串是一个类,但它被定义为标量属性)的属性被假定为导航属性。这些属性可能显示为单个链接(例如 public PriceOffer Promotion ( get; set; }) 或实现 IEnumerable 接口的类型(例如 public ICollection Reviews { get; set; })。
  3. EF Core 检查每个实体类是否都有主键(请参阅第 7.9 节)。如果该类没有主键并且尚未配置为没有主键(请参阅第 7.9.3 节),或者如果该类未排除,EF Core 将引发异常。

8.4.2 具有导航属性的实体类示例

清单 8.1 显示了实体类 Book,它是在应用进程的 DbContext 中定义的。在本例中,您有一个 DbSet 类型的公共属性,它通过了“必须具有有效的主键”测试,因为它有一个名为 BookId 的公共属性。

您感兴趣的是 EF Core 的 By Convention 配置如何处理类底部的三个导航属性。正如您将在本节中看到的,EF Core 可以通过导航属性的类型以及导航属性引用的类中的外键来确定它是哪种关系。

清单 8.1 Book 实体类,其关系位于底部

public class Book
{
    public int BookId { get; set; }
    //其他标量属性因不相关而被删除...
    public PriceOffer Promotion { get; set; }  //链接到 PriceOffer,这是一对零或一的关系
    public ICollection<Tag> Tags { get; set; }  //使用 EF Core 5 的自动多对多关系直接链接到标记实体列表
    public ICollection<BookAuthor> AuthorsLink { get; set; }  //链接到本书的所有评论: 一对多关系
    public ICollection<Review> Reviews { get; set; }  //通过链接表链接到作者多对多关系的一侧
}

如果两个实体类之间存在两个导航属性,则该关系称为完全定义,EF Core 可以按约定确定它是一对一关系还是一对多关系。如果仅存在一个导航属性,则 EF Core 无法确定,因此它假定为一对多关系。

如果只有一个导航属性,或者想要更改默认的“按约定”设置(例如,删除具有关系的实体类时),则某些一对一关系可能需要通过 Fluent API 进行配置。

8.4.3 EF Core 如何按约定查找外键

外键必须在类型和名称上与主键(在第 8.1 节中定义)匹配,但为了处理一些情况,外键名称匹配有三个选项,如图 8.2 所示。该图显示了使用实体类 Review 的外键名称的所有三个选项,该实体类引用实体类 Book 中的主键 BookId。

图 8.2 引用 Book 实体类主键的外键的三个按约定选项。这些选项允许你为外键使用唯一名称,EF Core 可以从中确定此关系引用的主键。

选项 1 是我使用最多的选项;如图 8.1 所示。选项 2 适用于使用简短的 By Convention 主键名称 Id 的开发人员,因为它使外键对于它所链接到的类是唯一的。选项 3 有助于处理特定情况,在这些情况下,如果使用选项 1,则会获得重复的命名属性。下面的清单显示了使用选项 3 处理层次结构关系的示例。

清单 8.2 选项 3 外键的层次结构关系

public class Employee
{
    public int EmployeeId { get; set; } public string Name { get; set; }

    //Relationships
    public int? ManagerEmployeeId { get; set; }   //外键使用<NavigationalPropertyName> <PrimaryKeyName>模式
    public Employee Manager { get; set; }
}

名为 Employee 的实体类有一个名为 Manager 的导航属性,该属性链接到员工的经理,而经理也是员工。您不能使用 EmployeeId 的外键(选项 1),因为它已用作主键。因此,您可以使用选项 3,并通过在开头使用导航属性名称来调用外键 ManagerEmployeeId。

8.4.4 外键的可为空性:必需或可选的依赖关系

外键的可为空性定义关系是必需的(不可为空的外键)还是可选的(可为空的外键)。必需的关系通过确保外键链接到有效的主键来确保关系的存在。第 8.6.1 节描述了与工单实体类具有必需关系的参加者实体。

通过将外键值设置为空,可选关系允许主体实体和从属实体之间没有链接。 Employee 实体类中的 Manager 导航属性(如清单 8.2 所示)是可选关系的一个示例,因为处于业务层次结构顶部的人不会有老板。关系的必需或可选状态也会影响删除主体实体时依赖实体所发生的情况。每种关系类型的 OnDelete 操作的默认设置如下:

  • 对于必需的关系,EF Core 将 OnDelete 操作设置为 Cascade。如果删除主体实体,则从属实体也将被删除。
  • 对于可选关系,EF Core 将 OnDelete 操作设置为 ClientSetNull。如果依赖实体正在被跟踪,则当主体实体被删除时,外键将被设置为空。但是,如果未跟踪依赖实体,则数据库约束删除设置将接管,并且 ClientSetNull 设置将设置数据库规则,就像限制设置已就位一样。结果是数据库层面删除失败,抛出异常。

注意:ClientSetNull 删除行为相当不寻常,第 8.8.1 节解释了原因。该部分还介绍了如何配置关系的删除行为。

8.4.5 外键:如果省略它们会发生什幺?

如果 EF Core 通过导航属性或通过 Fluent API 配置的关系找到关系,则它需要外键来在关系数据库中设置关系。在实体类中包含外键是一种很好的做法,可以让您更好地控制外键的可为空性。此外,当您在断开连接的更新中处理关系时,访问外键也很有用(请参阅第 3.3.1 节)。

但如果您确实遗漏了外键(有意或无意),EF Core 配置将添加外键作为影子属性。第 7 章中介绍的影子属性是隐藏属性,只能通过特定的 EF Core 命令访问。自动添加外键作为影子属性可能会很有用。例如,我的一个客户有一个通用 Note 实体类,该实体类已添加到许多实体的 Notes 集合中。

图 8.3 显示了一对多关系,其中 Note 实体类用于两个实体类(Customer 和 Job)的集合导航属性中。请注意,Customer 和 Job 实体类的主键名称使用不同的 By Convention 命名方法来显示影子属性的命名方式。

图 8.3 如果未在 Notes 实体类中提供自己的外键,EF Core 的 By Convention 配置会将可为 null(即可选关系)外键添加为影子属性。

如果获取影子属性外键的实体类具有指向关系另一端的导航链接,则该影子属性的名称将为<导航属性名称><主体键属性名称>。如果图 8.3 中的 Note 实体具有指向名为 LinkBack 的 Customer 实体的导航链接,则影子属性外键的名称将为 LinkBackId。

注意:我的单元测试表明,如果没有外键来链接这两个实体,一对一关系将被拒绝。因此,EF Core 的 By Convention 不会自动在一对一关系上设置影子属性外键。

如果你想添加一个外键作为影子属性,你可以通过 Fluent API HasForeignKey 来实现,如第 8.6 节所示,但影子属性的名称是通过字符串提供的。请注意不要使用现有属性的名称,因为这不会添加影子属性,但会使用现有属性。

影子外键属性将为空,这具有第 8.4.4 节中关于外键可为空性描述的效果。如果这种效果不是您想要的,您可以使用 Fluent API IsRequired 方法更改影子属性的可为空性,如第 8.8.2 节中所述。

EF6:EF6.x 使用类似的方法来添加外键(如果您将外键排除在实体类之外),但在 EF6.x 中,您无法配置可为空性或访问内容。 EF Core 的影子属性使得忽略外键更加可控。

8.4.6 By Convention 配置什幺时候不起作用?

如果您要使用按约定配置方法,您需要知道它何时不起作用,以便您可以使用其他方法来配置您的关系。以下是我列出的不起作用的场景,最常见的首先列出:

  • 您有复合外键(请参阅第 8.6 节或第 8.5.1 节)。
  • 您想要创建一对一的关系,而无需双向导航链接(请参阅第 8.6.1 节)。
  • 您想要覆盖默认的删除行为设置(请参阅第 8.8.1 节)。
  • 您有两个属于同一类的导航属性(请参阅第 8.5.2 节)。
  • 您想要定义特定的数据库约束(请参阅第 8.8.4 节)。

8.5 使用数据注释配置关系

只有两个数据注释与关系相关,因为大多数导航配置都是通过 Fluent API 完成的:ForeignKey 和 InverseProperty 注释。

8.5.1 ForeignKey 数据注释

foreignKey 数据注释允许您为类中的导航属性定义外键。以 Employee 类的分层示例为例,您可以使用此注释来定义 Manager 导航属性的外键。以下清单显示了更新的 Employee 实体类,其中 Manager 导航属性具有新的、更短的外键名称,该名称不符合按约定命名:ManagerEmployeeId。

清单 8.3 使用ForeignKey数据注释来设置外键名称

public class Employee
{
    public int EmployeeId { get; set; } 
    public string Name { get; set; }
    public int? ManagerId { get; set; } 
    [ForeignKey(nameof(ManagerId))]   //定义哪个属性是 Manager 导航属性的外键
    public Employee Manager { get; set; }
}

注意:您已将ForeignKey 数据注释应用到Manager 导航属性,并给出外键名称ManagerId。但ForeignKey数据注释也可以相反地工作。您可以将ForeignKey 数据注释应用于外键属性ManagerId,并给出导航属性Manager 的名称,例如[ForeignKey(nameof(Manager))]。

foreignKey 数据注释采用一个参数,该参数是一个字符串。该字符串应包含外键属性的名称。如果外键是复合键(具有多个属性),则应以逗号分隔,如 [ForeignKey(Property1, Property2)] 中所示。

提示:我建议您使用 nameof 关键字来提供属性名称字符串。这更安全,因为如果您更改外键属性的名称,nameof 将同时更新,或者如果您忘记更改所有引用,则会抛出编译错误。

8.5.2 InverseProperty数据注释

InverseProperty 数据注释是一种相当专业的数据注释,当您有两个导航属性转到同一类时可以使用。此时,EF Core 无法确定哪些外键与哪个导航属性相关。这种情况最好用代码来展示。以下清单显示了一个示例 Person 实体类,其中包含两个列表:一个用于图书馆员拥有的书籍,另一个用于借给特定人员的书籍。

清单 8.4 LibraryBook 实体类与 Person 类有两种关系

public class LibraryBook
{
    public int LibraryBookId { get; set; } 
    public string Title { get; set; }
    public int LibrarianPersonId { get; set; } 
    public Person Librarian { get; set; }
    public int? OnLoanToPersonId { get; set; } 
    public Person OnLoanTo { get; set; }
}

图书管理员和图书借阅者(OnLoanTo 导航属性)都由 Person 实体类表示。Librarian 导航属性和 OnLoanTo 导航属性都链接到同一类,并且 EF Core 无法在没有帮助的情况下设置导航链接。以下清单中所示的 InverseProperty 数据批注在配置导航链接时向 EF Core 提供信息。

清单 8.5 使用 InverseProperty 注解的 Person 实体类

public class Person
{
    public int PersonId { get; set; } 
    public string Name { get; set; }
    [InverseProperty("Librarian")]   //将 LibrarianBooks 链接到 LibraryBook 类中的 Librarian 导航属性
    public ICollection<LibraryBook> LibrarianBooks { get; set; }
    [InverseProperty("OnLoanTo")]   //将 BooksBorrowedByMe 列表链接到 LibraryBook 类中的 OnLoanTo 导航属性
    public ICollection<LibraryBook> BooksBorrowedByMe { get; set; }
}

此代码是您很少使用的配置选项之一,但如果您遇到这种情况,则必须使用它或定义与 Fluent API 的关系。否则,EF Core 将在启动时引发异常,因为它无法确定如何配置关系。

8.6 Fluent API 关系配置命令

正如我在第 8.4 节中所说,可以使用 EF Core 的“按约定”方法配置大多数关系。但是,如果要配置关系,Fluent API 具有一组精心设计的命令,这些命令涵盖了所有可能的关系组合。它还具有额外的命令,可用于定义其他数据库约束。图 8.4 显示了定义与 Fluent API 的关系的格式。所有 Fluent API 关系配置命令都遵循此模式。

EF6:EF6 EF Core 的 Fluent API 命令名称与 EF6 相比发生了变化,对我来说,它们更清晰了。我发现 EF6 的 WithRequired 和 WithRequiredPrincipal/WithRequiredDependent 命令有点令人困惑,而 EF Core Fluent API 命令具有更清晰的 HasOne/HasMany 后跟 WithOne/WithMany 语法。

图 8.4 Fluent API 允许您定义两个实体类之间的关系。HasOne/HasMany 和 WithOne/WithMany 是两个主要部分,后跟其他命令以指定其他部分或设置某些功能。

接下来,我们将定义一对一、一对多和多对多关系,以说明这些 Fluent API 关系的用法。

8.6.1 创建一对一关系

一对一关系可能会变得有点复杂,因为在关系数据库中有三种方法可以构建它们。若要了解这些选项,您将查看一个示例,在该示例中,您有与会者(实体类 Attendee)参加软件会议,并且每个与会者都有唯一的票证(实体类票证)。

第 3 章介绍了如何创建、更新和删除关系。回顾一下,下面是一个代码片段,演示如何创建一对一关系:

var attendee = new Attendee
{
    Name = "Person1",
    Ticket = new Ticket{ TicketType = TicketTypes.VIP}
};
context.Add(attendee); 
context.SaveChanges();

图 8.5 显示了构建这种一对一关系的三个选项。主要实体位于图的顶部,从属实体位于底部。请注意,选项 1 将参加者作为从属实体,而选项 2 和 3 将工单作为从属实体。

每个选项都有优点和缺点。您应该使用适合您业务需求的一种。

选项 1 是构建一对一关系的标准方法,因为它允许您定义需要一对一依赖实体(必须存在)。在我们的示例中,如果您尝试保存未附加唯一票证的参加者实体实例,则会引发异常。图 8.6 更详细地显示了选项 1。

使用 option-1 一对一安排,您可以通过使外键可为空来使依赖实体成为可选的。另外,在图 8.6 中,您可以看到 WithOne 方法有一个参数,用于挑选 Ticket 实体类中的 attendee 导航属性,该属性链接回 attendee 实体类。由于参加者类是关系的依赖部分,因此如果删除参加者实体,链接的工单将不会被删除,因为工单是关系中的主体实体。本示例中选项 1 的缺点是它允许一张票证用于多个与会者,这与我在开始时所说的业务规则不符。最后,此选项允许您通过将新票证分配给参加者的票证导航属性来将票证替换为另一个票证实例。

图 8.5 在关系数据库中定义一对一关系的三种方法;底部的注释指示 EF Core 对每种方法的处理。选项 1 与选项 2 和 3 的不同之处在于,一对一关系的两端顺序是互换的,这改变了可以强制存在的部分。在选项 1 中,与会者必须拥有票证,而在选项 2 和 3 中,票证对与会者是可选的。此外,如果删除主体实体(顶行),则从属实体(底行)也将被删除。

图 8.6 非空值外键确保主实体(在本例中为 Attendee)必须具有一个从属的一对一实体 Ticket。此外,将关系配置为一对一可确保每个从属实体 Ticket 都是唯一的。请注意,右侧的 Fluent API 具有双向导航属性;每个实体都具有指向另一个实体的导航属性。

图 8.5 中的选项 2 和 3 扭转了主体/从属关系,与会者成为关系中的主要实体。这种情况交换了关系的必需/可选性质。现在,与会者可以在没有票证的情况下存在,但票证不能在没有与会者的情况下存在。选项 2 和 3 确实强制将票证仅分配给一个与会者,但将票证替换为另一个票证实例需要您先删除旧票证。图 8.7 显示了这种关系。

图 8.7 选项 2:Ticket 实体持有 Attendee 实体的外键,更改哪个实体是主体,哪个实体是依赖实体。在这种情况下,与会者现在是主体,票证是从属实体。

选项 2 和 3 很有用,因为它们形成了可选的一对一关系,通常称为一对一或一对一关系。选项 3 是定义选项 2 的更有效方法,将主键和外键组合在一起。我会在 Book App 中对 PriceOffer 实体类使用选项 3,但我想从更简单的选项 2 方法开始。另一个更好的版本使用 Owned 类型(参见第 8.9.1 节),因为它是从同一个表自动加载的,这样更安全(我不能忘记添加 Include)并且效率更高。

8.6.2 创建一对多关系

一对多关系更简单,因为有一种格式:许多实体包含外键值。您可以使用“按约定”方法定义大多数一对多关系,只需为许多实体中的外键指定一个遵循“按约定”方法的名称(请参阅第 8.4.3 节)。但是,如果要定义关系,可以使用 Fluent API,它可以完全控制关系的设置方式。图 8.8 提供了 Fluent API 代码示例,用于在图书应用进程中创建“一本书具有许多评论”关系。

在本例中,Review 实体类没有返回 Book 的导航链接,因此 WithOne 方法没有参数。

图 8.8 一对多关系,其中外键必须位于依赖实体中,在本例中为 Review 实体类。在右侧的 Fluent API 中可以看到,Book 具有链接到 Review 实体类的集合导航属性 Reviews,但 Review 没有返回到 Book 的导航属性。

注意:清单 3.16 显示了如何将评论添加到图书的一对多集合导航属性 Reviews 中。

集合有一些值得了解的功能。首先,您可以对实现 IEnumerable 接口的集合使用任何泛型类型,例如 IList、Collection、HashSet、List 等。 IEnumerable 本身是一种特殊情况,因为您无法添加到该集合中。

出于性能原因,您应该使用 HashSet 进行导航集合,因为它改进了 EF Core 查询和更新过程的某些部分。 (有关此主题的更多信息,请参阅第 14 章。)但是 HashSet 不保证条目的顺序,如果您向 Includes 添加排序,这可能会导致问题(请参阅第 2.4.1 节,清单 2.5)。这就是为什幺我建议在第 1 部分和第 2 部分中使用 ICollection(如果您可以对 Include 方法进行排序),因为 ICollection 会保留添加条目的顺序。但在关于性能的第 3 部分中,您不在 Includes 中使用排序,以便可以使用 HashSet 以获得更好的性能。

其次,虽然您通常使用 getter 和 setter 定义集合导航属性(例如 public ICollection Reviews { get; set; }),但这样做并不是必需的。仅当使用空集合初始化支持字段时,才可以提供 getter。以下内容也有效:

public ICollection<Review> Reviews { get; } = new List<Review>();

虽然在这种情况下初始化集合可能会让事情变得更容易,但我不建议初始化导航集合属性。我在第 6.1.6 节中给出了不初始化集合导航属性的原因。

8.6.3 创建多对多关系

第 2 章和第 3 章描述了多对多关系;在本节中,您将学习如何配置它们。在这些章节中,您了解了两种类型的多对多关系:

  • 您的链接表包含您在多对多关系另一端读取数据时要访问的信息。例如,书籍与作者的多对多关系,其中链接表包含作者姓名的显示顺序。
  • 您可以直接访问多对多关系的另一方。例如,Book to Tag 多对多关系,在该关系中,您可以直接访问 Book 实体类中的 Tags 集合,而无需访问链接表。

使用链接实体类配置多对多关系

从多对多关系开始,在该关系中,通过链接表访问关系的另一端。这种关系需要更多的工作,但允许您向链接表添加额外的数据,您可以对这些数据进行排序/筛选。您在第 3.4.4 节中看到了如何执行此操作。图 8.9 显示了这种多对多关系的配置部分。

图 8.9 使用链接表的多对多关系中涉及的三个实体类。仅当链接表实体类中有额外数据时,才使用这种类型的多对多关系。在本例中,BookAuthor 类包含一个 Order 属性,该属性定义作者姓名应与 Book 一起显示的顺序。

在 Book/Author 示例中,By Convention 配置可以查找并链接所有标量和导航属性,因此唯一需要的配置就是设置主键。以下代码片段在应用进程的 DbContext 的 OnModelCreating 方法中使用 Fluent API:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<BookAuthor>().HasKey(x => new {x.BookId, x.AuthorId});
}

可以通过将 Fluent API 与以下清单中的代码结合使用来配置多对多关系中的四种关系。请注意,不需要列表中的 HasOne/WithMany Fluent API 命令,因为 BookAuthor 实体类遵循 By 约定命名和键入规则。

清单 8.6 通过两个一对多关系配置多对多关系

public static void Configure(this EntityTypeBuilder<BookAuthor> entity)
{
    entity.HasKey(p => new { p.BookId, p.AuthorId });  //使用 Book 和 Author 主键的名称来形成自己的组合键
    
    //Relationships
    //配置从 Book 到 BookAuthor 实体类的一对多关系
    entity.HasOne(p => p.Book).WithMany(p => p.AuthorsLink).HasForeignKey(p => p.BookId);
    //配置从 Author 到 BookAuthor 实体类的一对多关系
    entity.HasOne(p => p.Author).WithMany(p => p.BooksLink).HasForeignKey(p => p.AuthorId);
}

配置可直接访问其他实体的多对多关系

随着 EF Core 5 的发布,可以直接引用多对多关系的另一端。第 2 章和第 3 章中显示的示例是 Book 实体类,该类具有一个 ICollection Tags 导航属性,该属性包含一系列 Tag 实体类。Tag 实体类包含一个类别(Microsoft .NET、Web 等),可帮助客户找到他们要查找的书籍。

“按约定”配置适用于直接的多对多关系。如果两端的实体类有效,则 By Convention 配置将为您设置关系和键,如图 8.10 所示。按照约定,还将使用属性包为你创建链接实体(请参阅第 8.9.5 节)。

图 8.10 EF Core 5 的直接多对多关系之所以有效,是因为 (a) EF Core 为您创建链接实体类,并且 (b) 当它看到包含直接多对多关系的查询时,它会添加 SQL 命令以使用隐藏的链接实体类。无需创建链接实体类或执行配置,使这些类型的多对多关系更容易设置。

但是,如果要添加自己的链接表和配置,可以通过 Fluent API 配置来实现。链接表的实体类类似于图 8.9 中所示的 BookAuthor 链接实体类。区别在于,作者键/关系被标签键/关系所取代。下面的清单显示了 Book 配置类,该类设置 BookTag 实体类以链接这两个部分。

清单 8.7 使用 Fluent API 配置直接多对多关系

public void Configure (EntityTypeBuilder<Book> entity)
{
    //… other configrations left out for clarity

    entity.HasMany(x => x.Tags)  //HasMany/WithMany 创建了直接的多对多关系。
        .WithMany(x => x.Books)
        .UsingEntity<BookTag>(  //UsingEntity 方法允许您为链接表定义实体类。
            bookTag => bookTag.HasOne(x => x.Tag)  //多对多关系的定义标记端
        .WithMany().HasForeignKey(x => x.TagId), bookTag => bookTag.HasOne(x => x.Book)  //定义多对多关系的 Book 端
        .WithMany().HasForeignKey(x => x.BookId));
}

8.7 控制集合导航属性的更新

有时,您需要控制对集合导航属性的访问。尽管您可以通过将 setter 设置为私有来控制对一对一导航的访问,但该方法不适用于集合,因为大多数集合类型允许您添加或删除条目。要完全控制集合导航属性,您需要使用 EF Core 支持字段,如第 7.14 节中所述。

EF6:EF6.x 没有办法控制对集合导航属性的访问,这意味着某些模式(例如 DDD)很难成功实现。 EF Core 的支持字段允许您构建遵循 DDD 原则的实体类。

将链接实体类的集合存储在字段中允许您拦截任何更新集合的尝试。以下是此功能有用的一些业务/软件设计原因:

  • 在更改时触发一些业务逻辑,例如,如果集合包含超过 10 个条目,则调用方法。
  • 出于性能原因构建本地缓存值,例如每当在 Book 实体类中添加或删除评论时,都会保存缓存的 ReviewsAverageVotes 属性。
  • 将 DDD 应用到您的实体类。对数据的任何更改都应该通过方法来完成(参见第 13 章)。

对于控制集合导航属性的示例,您将向 Book 类添加缓存的 ReviewsAverageVotes 属性。该酒店将保留与本书相关的所有评论的平均票数。为此,您需要

  • 添加一个名为 _reviews 的支持字段来保存评论集合,并更改属性以返回 _reviews 支持字段中保存的集合的只读副本。
  • 添加一个名为 ReviewsAverageVotes 的只读属性,以保存与本书链接的评论的缓存平均投票。
  • 添加向 _reviews 支持字段添加评论和删除评论的方法。每种方法都会使用当前的评论列表重新计算平均投票。

下面的列表显示了更新的 Book 类,该类显示了与 Reviews 和缓存的 ReviewsAverageVotes 属性相关的代码。

清单 8.8 具有只读 Reviews 集合导航属性的 Book 类

public class Book
{
    //添加一个支持字段,该字段是一个列表。默认情况下,EF Core 将读取和写入此字段。
    private readonly ICollection<Review> _reviews = new List<Review>();

    public int BookId { get; set; } 
    public string Title { get; set; }
    //… other properties/relationships left out

    public double? ReviewsAverageVotes { get; private set; }  //保存预先计算的评论平均值,并且是只读的

    public IReadOnlyCollection<Review> Reviews =>   //只读的集合,所以没有人可以更改集合
        _reviews.ToList();   //返回_reviews支持字段中的评论副本
 
    public void AddReview(Review review)  //添加允许将新审阅添加到_reviews集合的方法
    {    
        _reviews.Add(review);   //将新的审阅添加到支持字段_reviews并在调用 SaveChanges 时更新数据库
        ReviewsAverageVotes = _reviews.Average(x => x.NumStars);  //重新计算该书的平均票数
    }
 
    public void RemoveReview(Review review)  //添加从 _reviews 集合中删除审阅的方法
    {
        _reviews.Remove(review);   //从列表中删除审阅,并在调用 SaveChanges 时更新数据库
        ReviewsAverageVotes = _reviews.Any()
            ? _reviews.Average(x => x.NumStars)  //如果有任何评论,则重新计算该书的平均票数
            : (double?)null;  //如果没有评论,则将值设置为 null
    }
}	

您无需配置支持字段,因为您使用的是按约定命名,并且默认情况下,EF Core 会读取数据并将数据写入 _reviews 字段。

此示例展示了如何使集合导航属性变为只读,但它并不完美,因为并发更新可能会使 ReviewsAverageVotes 缓存属性过时。在第 3 部分中,您将自始至终使用 DDD 构建应用进程,并实现处理并发问题的强大缓存方法。

8.8 Fluent API 关系中可用的其他方法

我们已经介绍了配置标准关系的所有方法,但是关系的一些最详细的部分需要向关系的 Fluent API 配置添加额外的命令。在本节中,我们将介绍四种定义关系的一些更深层次部分的方法:

  • OnDelete - 更改依赖实体的删除操作
  • IsRequired—定义外键的可为空性
  • HasPrincipalKey - 使用备用唯一密钥
  • HasConstraintName - 设置外键约束名称和对关系数据的元数据访问

8.8.1 OnDelete:更改依赖实体的删除操作

第 8.4.4 节描述了删除主体实体的默认操作,该操作基于从属外键的可为空性。 OnDelete Fluent API 方法允许您更改 EF Core 在发生影响依赖实体的删除时执行的操作。

您可以将 OnDelete 方法添加到 Fluent API 关系配置的末尾。此清单显示了第 4 章中添加的代码,用于阻止 Book 实体在客户订单中通过 LineItem 实体类引用时被删除。

清单 8.9 更改依赖实体上的默认 OnDelete 操作

public static void Configure(this EntityTypeBuilder<LineItem> entity)
{
    entity.HasOne(p => p.ChosenBook)
        .WithMany()
        .OnDelete(DeleteBehavior.Restrict);  //将 OnDelete 方法添加到定义关系的末尾
}

如果有人尝试删除 LineItem 的外键链接到该 Book 的 Book 实体,则此代码会导致引发异常。您这样做是因为您希望客户的订单不被更改。表 8.1 解释了可能的 DeleteBehavior 设置。

表 8.1 删除 EF Core 中可用的行为。中间列突出显示了在不应用 OnDelete 选项时将使用的删除行为。

名称 删除行为对依赖实体的影响 默认值
Restrict 删除操作不应用于依赖实体。依赖实体保持不变,这可能会导致删除失败,无论是在 EF Core 中还是在关系数据库中。  
SetNull 依赖实体不会被删除,但其外键属性设置为 null。如果任何依赖实体外键属性不可为 null,则在调用 SaveChanges 时会引发异常。  
ClientSetNull 如果 EF Core 正在跟踪依赖实体,则其外键设置为 null,并且不会删除依赖实体。但是,如果 EF Core 未跟踪依赖实体,则适用数据库规则。在 EF Core 创建的数据库中,此 DeleteBehavior 会将 SQL DELETE 约束设置为 NO ACTION,这会导致删除失败并出现异常。 Optional relationships
Cascade 删除依赖实体。必需的关系 Required relationships
ClientCascade 对于 DbContext 跟踪的实体,在删除相关主体时,将删除依赖实体。但是,如果 EF Core 未跟踪依赖实体,则适用数据库规则。在 EF Core 创建的数据库中,此项将设置为“限制”,这会导致删除失败并出现异常。  

名称以 Client 开头的两个删除行为是 ClientSetNull(EF Core 2.0 中添加)和 ClientCascade(EF Core 3.0 中添加)。这两种删除行为将删除操作的一些处理从数据库移至客户端(即 EF Core 代码)。我相信,添加这两个设置是为了防止在某些数据库(例如 SQL Server)中,当您的实体具有循环回自身的导航链接时出现问题。在这些情况下,当您尝试创建数据库时,您会从数据库服务器收到错误,这可能很难诊断和修复。

在这两种情况下,这些命令都会在 EF Core 内执行代码,这些代码分别执行与数据库使用 SetNull 和级联删除行为相同的工作。但是,这是一个很大的但是,只有当您具备以下条件时,EF Core 才能应用这些更改:

加载链接到您要删除的主体实体的所有相关依赖实体。如果不这样做,数据库将应用其删除规则,这通常会引发异常。

ClientSetNull 删除设置是可选关系的默认设置,EF Core 会将加载的依赖实体类的外键设置为 null。如果使用 EF Core 创建/迁移数据库,EF Core 会将数据库删除规则设置为 ON DELETE NO ACTION (SQL Server)。如果您的实体有循环(SQL Server 称为可能的循环删除路径),数据库服务器不会抛出异常。 SetNull 删除设置会将数据库删除规则设置为 ON DELETE SET NULL (SQL Server),这将导致数据库服务器抛出可能的循环删除路径异常。

ClientCascade 删除设置对数据库的级联删除功能执行相同的操作,因为它将删除任何加载的依赖实体类。同样,如果您使用 EF Core 创建/迁移数据库,EF Core 会将数据库删除规则设置为 ON DELETE NO ACTION (SQL Server)。级联删除设置会将数据库删除规则设置为 ON DELETE CASCADE (SQL Server),这将导致数据库服务器抛出可能的循环删除路径异常。

注意:EF Core 文档中有一个关于级联删除的页面,其中包含一些工作示例;参见http://mng.bz/nMGK。此外,相关 GitHub 存储库的 Part2 分支有一个名为 Ch08_DeleteBehaviour 的单元测试,其中包含对每个设置的测试。

清单 8.10 显示了删除主体实体时使用 ClientSetNull 和 ClientCascade 的正确方法。此清单中的实体加载了一个可选的依赖实体,该实体(默认情况下)具有 ClientSetNull 的默认删除行为。但只要您加载正确的一个或多个依赖实体,相同的代码就适用于 ClientCascade。

清单 8.10 删除带有可选依赖实体的主体实体

var entity = context.DeletePrincipals  //在主实体中读取
    .Include(p => p.DependentDefault)  //包括具有 ClientSetNul 默认删除行为的依赖实体
    .Single(p => p.DeletePrincipalId == 1);
 
context.Remove(entity);  //设置要删除的主体实体
context.SaveChanges();  //调用 SaveChanges,将其外键设置为 null

请注意,如果不包含 Include 方法或加载可选依赖实体的其他方法,SaveChanges 将抛出 DbUpdateException,因为数据库服务器将报告外键约束违规。将 EF Core 的方法与数据库服务器的方法调整为可选关系的一种方法是将删除行为设置为 SetNull 而不是默认的 ClientSetNull,使数据库中的外键约束为 ON DELETE SET NULL (SQL Server) 并将数据库负责将外键设置为空。无论您是否加载可选依赖实体,调用 SaveChanges 的结果都将相同:可选依赖实体上的外键将设置为 null。

但请注意,如果您有 SetNull 或 Cascade 的删除行为设置,并且服务器检测到可能的循环关系(例如分层数据),某些数据库服务器可能会在数据库创建时返回错误。这就是 EF Core 具有 ClientSetNull 和 ClientCascade 删除行为的原因。

注意如果您在 EF Core 外部管理数据库创建/迁移,请务必确保关系数据库外键约束符合 EF Core 的 OnDelete 设置。否则,您将得到不一致的行为,具体取决于依赖实体是否正在被跟踪。

8.8.2 IsRequired:定义外键的可为空性

第 6 章介绍了 Fluent API 方法 IsRequired 如何允许您设置标量属性(例如字符串)的可为空性。在关系中,同一个命令设置外键的可为空性,正如我已经说过的,它定义了关系是必需的还是可选的。

IsRequired 方法在影子属性中最有用,因为 EF Core 默认情况下使影子属性可为空,并且 IsRequired 方法可以将它们更改为不可为空。下一个清单描述了 attendee 实体类,以前用于显示一对一关系,但显示了另外两个使用影子属性作为外键的一对一关系。

清单 8.11 显示其所有关系的 attendee 实体类

public class Attendee
{
    public int AttendeeId { get; set; } 
    public string Name { get; set; }
    public int TicketId { get; set; }   //一对一关系的外键,票证
    public Ticket Ticket { get; set; }  //访问 Ticket 实体的一对一导航属性
    public MyOptionalTrack Optional { get; set; }  //使用外键的影子属性的一对一导航属性。默认情况下,外键可为 null,因此关系是可选的。
    public MyRequiredTrack Required { get; set; }  //使用外键的影子属性的一对一导航属性。使用 Fluent API 命令表示外键不可为空,因此需要这种关系。
}	

Optional navigational 属性(将 shadow 属性用于其外键)是按约定配置的,这意味着 shadow 属性保留为可为 null 的值。因此,它是可选的,如果删除了 Attendee 实体,则不会删除 MyOptionalTrack 实体。

对于“必需的导航”属性,以下列表显示了 Fluent API 配置。在这里,使用 IsRequired 方法根据需要生成 Required 一对一导航属性。每个 Attendee 实体都必须将 MyRequiredTrack 实体分配给 Required 属性。

清单 8.12 Attendee 实体类的 Fluent API 配置

public void Configure (EntityTypeBuilder<Attendee> entity)
{
    entity.HasOne(attendee => attendee.Ticket)  //设置一对一的导航关系 Ticket,该关系具有在 Attendee 类中定义的外键
        .WithOne(attendee => attendee.Attendee)
        .HasForeignKey<Attendee> (attendee => attendee.TicketId)  //指定作为外键的属性。您需要提供类类型,因为外键可能位于主体或依赖实体类中。
        .IsRequired();

    entity.HasOne(attendee => attendee.Required)  //设置一对一导航关系 Required,该关系未定义外键
        .WithOne(attendee => attendee.Attend)
        .HasForeignKey<Attendee>( "MyShadowFk")  //使用 HasForeignKey 方法,该方法采用字符串,因为它是影子属性,只能通过名称引用。请注意,您使用自己的姓名。
        .IsRequired();  //使用 IsRequired 表示外键不应为空
}

您可以省略“工单”导航属性的配置,因为它将根据“按约定”规则正确配置,但您将其保留,以便可以将其与“必需”导航属性的配置进行比较,该属性使用阴影其外键的属性。必需的导航属性的配置是必要的,因为 IsRequired 方法将影子外键属性从可为空更改为不可为空,这反过来又使关系成为必需的。

请注意清单 8.12 如何引用影子外键属性:您需要使用 HasForeignKey(string) 方法。 类告诉 EF Core 在何处放置影子外键属性,该属性可以是一对一关系的关系末端,也可以是一对多关系的多个实体类。

HasForeignKey(string) 方法的字符串参数允许您定义影子外键属性名称。您可以使用任何名称;您不需要坚持使用图 8.3 中列出的 By Convention 名称。但您需要小心,不要使用您所定位的实体类中任何现有属性的名称,因为这种方法可能会导致奇怪的行为。 (如果您选择现有属性,则不会出现警告,因为您可能正在尝试定义非影子外键。)

8.8.3 HasPrincipalKey:使用备用唯一密钥

我在本章开头提到了备用键这个术语,说它是唯一值,但不是主键。我给出了一个名为 UniqueISBN 的备用键的示例,它表示不是主键的唯一键。 (请记住,ISBN 代表国际标准书号,每本书都有一个唯一的编号。)

现在让我们看一个不同的例子。下面的清单创建了一个 Person 实体类,它使用普通的 int 主键,但在链接到人员的联系信息时,您将使用 UserId 作为备用键,如清单 8.14 所示。

清单 8.13 Person 类,名称取自 ASP.NET 授权

public class Person
{
    public int PersonId { get; set; }
    public string Name { get; set; } 
    public Guid UserId { get; set; }  //保存此人的唯一 ID
    public ContactInfo ContactInfo { get; set; }  //链接到 ContactInfo 的导航属性
}

示例 8.14 以 EmailAddress 作为外键的 ContactInfo 类

public class ContactInfo
{
    public int ContactInfoId { get; set; }
    public string MobileNumber { get; set; } 
    public string LandlineNumber { get; set; }
    public Guid UserIdentifier { get; set; }  //UserIdentifier 用作 Person 实体链接到此联系人信息的外键。
}

图 8.11 显示了 Fluent API 配置命令,这些命令使用 Person 实体类中的备用键作为 ContactInfo 实体类中的外键。

以下是有关备用键的一些说明:

  • 您可以拥有由两个或多个属性组成的复合备用键。处理组合键的方式与处理组合键的方式相同:使用匿名类型,例如 HasPrincipalKey(c => new {c.Part1, c.Part2})。
  • 唯一键(参见第 7.10 节)和备用键是不同的,您应该为您的业务案例选择正确的密钥。以下是一些区别:
    • 唯一键确保每个条目都是唯一的;它们不能在外键中使用。
    • 唯一键可以为 null,但备用键不能。
    • 可以更新唯一键值,但不能更新备用键值。(请参阅 EF Core 问题 #4073 http://mng.bz/vzEM)。
  • 您可以使用 Fluent API 命令 modelBuilder.Entity() 将属性定义为独立备用键。HasAlternateKey(c => c.LicensePlate),但不需要这样做,因为使用 HasPrincipalKey 方法设置关系会自动将属性注册为备用键。

图 8.11 Fluent API 使用 UserId 属性(包含人员的唯一 ID)作为链接到 ContactInfo 的外键来设置一对一关系。命令 HasPrincipalKey 将 UserId 属性定义为备用键,并在 ContactInfo 实体中的 UserIdentifier 属性和 Person 实体中的 UserId 之间创建外键约束链接。

8.8.4 Fluent API 关系中较少使用的选项

本部分简要提及(但未详细介绍)两个可用于设置关系的 Fluent API 命令。

HASCONSTRAINTNAME:设置外键约束名称

HasConstraintName 方法允许您设置外键约束的名称,如果要捕获外键错误的异常并使用约束名称形成更用户友好的错误消息,这将非常有用。本文介绍了如何: http://mng.bz/4ZwV.

元数据:访问关系信息

MetaData 属性提供对关系数据的访问,其中一些是读/写的。MetaData 属性公开的大部分内容都可以通过特定命令(如 IsRequired)进行访问,但如果需要一些不寻常的东西,请查看 MetaData 属性支持的各种方法/属性。

8.9 将实体映射到数据库表的替代方法

有时,没有从实体类到数据库表的一对一映射很有用。您可能希望将两个类合并到一个表中,而不是在两个类之间创建关系。此方法允许您在使用其中一个实体时仅加载表的一部分,这将提高查询的性能。

本节介绍将类映射到数据库的五种替代方法,每种方法在某些情况下都有优点:

  • 拥有的类型——允许将类合并到实体类的表中,对于使用普通类对数据进行分组非常有用。
  • 每层次结构表 (TPH) — 允许将一组继承类保存在一个表中,例如从 Animal 类继承的名为 Dog、Cat 和 Rabbit 的类。
  • 每个类型的表 (TPT) — 将每个类映射到不同的表。此方法的工作原理与 TPH 类似,只不过每个类都映射到单独的表。
  • 表拆分——允许将多个实体类映射到同一个表,并且当表中某些列的读取频率高于所有表列的读取频率时非常有用。
  • 属性包 - 允许您通过字典创建实体类,这使您可以选择在启动时创建映射。属性包还使用其他两个功能:将相同类型映射到多个表以及在实体类中使用索引器。

8.9.1 拥有类型:将普通类添加到实体类中

EF Core 具有自有类型,允许您定义一个类来保存要在数据库中的多个位置使用的通用数据分组,例如地址或审核数据。自有类型类没有自己的主键,因此它没有自己的标识;它依赖于“拥有”它的实体类的身份。在 DDD 术语中,拥有的类型称为值对象。

EF6:EF Core 拥有的类型与 EF6.x 的复杂类型类似。最大的变化是您必须专门配置一个拥有的类型,而 EF6.x 将任何没有主键的类视为复杂类型(这可能会导致错误)。与 EF6.x 的实现相比,EF Core 的自有类型有一个额外的功能:自有类型中的数据可以配置为保存在单独的隐藏表中。

以下是使用自有类型的两种方法:

  • 拥有的类型数据保存在实体类映射到的同一个表中。
  • 拥有的类型数据保存在与实体类不同的表中。

拥有的类型数据与实体类保存在同一个表中

作为自有类型的示例,您将创建一个名为 OrderInfo 的实体类,它需要两个地址:BillingAddress 和 DeliveryAddress。这些地址由 Address 类提供,如下列表所示。您可以通过向类添加属性 [Owned] 将 Address 类标记为自有类型。拥有的类型没有主键,如清单底部所​​示。

清单 8.15 Address 拥有的类型,后跟 OrderInfo 实体类

public class OrderInfo  //实体类 OrderInfo,具有一个主键和两个地址
{
    public int OrderInfoId { get; set; } 
    public string OrderNumber { get; set; }
    //两个不同的 Address 类。每个 Address 类的数据将包含在 OrderInfo 映射到的表中。
    public Address BillingAddress { get; set; }
    public Address DeliveryAddress { get; set; }
}
 
[Owned]  //属性 [Owned] 告知 EF Core 它是拥有的类型。
public class Address  //拥有的类型没有主键。
{
    public string NumberAndStreet { get; set; } 
    public string City { get; set; }
    public string ZipPostCode { get; set; } 
    [Required]
    [MaxLength(2)]
    public string CountryCodeIso2 { get; set; }
}

由于已将属性 [Owned] 添加到 Address 类,并且在同一表中使用拥有的类型,因此无需使用 Fluent API 来配置拥有的类型。此方法可以节省您的时间,尤其是在许多地方使用您拥有的类型时,因为您不必编写 Fluent API 配置。但是,如果不想使用 [Owned] 属性,下一个列表将向你显示 Fluent API,以告知 EF Core OrderInfo 实体类中的 BillingAddress 和 DeliveryAddress 属性是拥有的类型,而不是关系。

清单 8.16 Fluent API 在 OrderInfo 中配置拥有的类型

public class SplitOwnDbContext: DbContext
{
    public DbSet<OrderInfo> Orders { get; set;}
    //… other code removed for clarity

    protected override void OnModelCreating (ModelBuilder modelBuilder)
    {
        modelBulder.Entity<OrderInfo>()  //选择拥有类型的所有者
            .OwnsOne(p => p.BillingAddress);   //使用 OwnsOne 方法告知 EF Core 属性 BillingAddress 是拥有的类型,并且应将数据添加到 OrderInfo 映射到的表中的列中
        modelBulder.Entity<OrderInfo>()  //对第二个属性 DeliveryAddress 重复该过程
            .OwnsOne(p => p.DeliveryAddress);
    }
}

结果是一个表,其中包含 OrderInfo 实体类中的两个标量属性,后跟两组 Address 类属性,一组以 BillingAddress_ 为前缀,另一组以 DeliveryAddress_ 为前缀。由于拥有的类型属性可以为 null,因此所有属性都作为可为 null 的列保存在数据库中。例如,清单 8.15 中的 CountryCodeIso2 属性被标记为 [Required],因此它应该是不可为 null 的,但为了允许 BillingAddress 或 DeliveryAddress 的 null 属性值,它被存储在一个可为 null 的列中。EF Core 这样做是为了在读入包含拥有类型的实体时判断是否应创建拥有类型的实例。

拥有的类型属性可以为 null 这一事实意味着实体类中的拥有的类型非常适合 DDD 所说的值对象。值对象没有键,两个具有相同属性的值对象被视为相等。它们可以为 null 这一事实允许使用“空”值对象。

注意:EF Core 3.0 中引入了可为 null 的拥有类型,但存在一些性能问题。(SQL 使用 LEFT JOIN。EF Core 5 已修复这些性能问题。

下面的列表显示了 EF Core 使用命名约定为 OrderInfo 实体类生成的 SQL Server CREATE TABLE 命令的一部分。

清单 8.17 显示列名的 SQL CREATE TABLE 命令

CREATE TABLE [Orders] (
    [OrderInfoId] int NOT NULL IDENTITY, 
    [OrderNumber] nvarchar(max) NULL, 
    [BillingAddress_City] nvarchar(max) NULL,
    [BillingAddress_NumberAndStreet] nvarchar(max) NULL, 
    [BillingAddress_ZipPostCode] nvarchar(max) NULL, 
    [BillingAddress_CountryCodeIso2] [nvarchar](2) NULL,  --属性具有 [Required] 属性,但存储为可为 null 的值,以处理帐单/送货地址为 null。
    [DeliveryAddress_City] nvarchar(max) NULL, 
    [DeliveryAddress_CountryCodeIso2] nvarchar(max) NULL, 
    [DeliveryAddress_NumberAndStreet] nvarchar(max) NULL, 
    [DeliveryAddress_CountryCodeIso2] [nvarchar](2) NULL, 
    CONSTRAINT [PK_Orders] PRIMARY KEY ([OrderInfoId])
);

默认情况下,拥有类型中的每个属性或字段都存储在可为 null 的列中,即使它们不可为 null。EF Core 这样做是为了允许你不将实例分配给拥有的类型,此时拥有类型使用的所有列都设置为 NULL。如果读入具有拥有类型的实体,并且拥有类型的所有列均为 NULL,则拥有的类型属性设置为 null。

但 EF Core 5 添加了一项功能,允许你说拥有的类型是必需的,即必须始终存在。为此,请将 Fluent API IsRequired 方法添加到映射到拥有的类型的 OrderInfo 的 DeliveryAddress 导航属性中(请参阅下一个列表)。此外,此功能允许列的单个可为 null 性遵循正常规则。例如,清单 8.17 中显示的 DeliveryAddress_CountryCodeIso2 列现在不是 NULL。

清单 8.18 Fluent API 在 OrderInfo 中配置拥有的类型

protected override void OnModelCreating (ModelBuilder modelBuilder)
{
    modelBulder.Entity<OrderInfo>()
        .OwnsOne(p => p.BillingAddress); 
    modelBulder.Entity<OrderInfo>()
        .OwnsOne(p => p.DeliveryAddress);
    modelBulder.Entity<OrderInfo>()  //选择 DeliveryAddress 导航属性
        .Navigation(p => p.DeliveryAddress)
        .IsRequired();  //应用 IsRequired 方法意味着 DeliveryAddress 不得为 null。
}	

使用拥有类型可以帮助您通过将公共数据组转换为拥有类型来组织数据库,从而更轻松地在代码中处理公共数据组(如地址等)。下面是关于实体类中保存的拥有的类型的一些最后几点:

  • 当您读取实体时,会自动创建拥有的类型导航属性(如 BillingAddress)并填充数据。不需要 Include 方法或任何其他形式的关系加载。
  • Julie Lerman(@julielerman 在 Twitter 上)指出,拥有类型可以取代一对一或零或一的关系,尤其是在拥有类型很小的情况下。拥有的类型具有更好的性能,并且会自动加载,这意味着它们将更好地实现图书应用进程中使用的零或一 PriceOffer。
  • 拥有的类型可以嵌套。例如,您可以创建一个 CustomerContact 拥有的类型,该类型又包含一个 Address 拥有的类型。如果在另一个实体类中使用了 CustomerContact 拥有的类型(我们称之为 SuperOrder),则所有 CustomerContact 属性和 Address 属性都将添加到 SuperOrder 的表中。

拥有的类型数据保存在与实体类不同的表中

EF Core 将数据保存在拥有类型中的另一种方法是在单独的表中保存,而不是在实体类中保存。在此示例中,你将创建一个 User 实体类,该类具有一个名为 HomeAddress 的 Address 类型。在这种情况下,请在配置代码中的 OwnsOne 方法后面添加一个 ToTable 方法。

清单 8.19 配置拥有的表数据存储在单独的表中

public class SplitOwnDbContext: DbContext
{
    public DbSet<OrderInfo> Orders { get; set; }
    //… other code removed for clarity

    protected override void OnModelCreating (ModelBuilder modelBuilder)
    {
        modelBulder.Entity<User>()
            .OwnsOne(p => p.HomeAddress);
            .ToTable("Addresses");  //将 ToTable 添加到 OwnsOne 会告知 EF Core 将拥有的类型 Address 存储在单独的表中,其主键等于保存到数据库的 User 实体的主键。
    }
}

EF Core 设置了一对一关系,其中主键也是外键(请参见第 8.6.1 节,选项 3),并且 OnDelete 状态设置为 Cascade,以便删除主实体 User 的拥有类型条目。因此,数据库有两个表:Users 和 Addresses。

清单 8.20 数据库中的 Users 和 Addresses 表

CREATE TABLE [Users] (
    [UserId] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NULL,
    CONSTRAINT [PK_Orders] PRIMARY KEY ([UserId])
);
CREATE TABLE [Addresses] (
    [UserId] int NOT NULL IDENTITY,
    [City] nvarchar(max) NULL, 
    [CountryCodeIso2] nvarchar(2) NOT NULL,  --请注意,不可为 null 的属性或具有 Required 设置的可为 null 的属性现在存储在不可为 null 的列中。
    [NumberAndStreet] nvarchar(max) NULL, 
    [ZipPostCode] nvarchar(max) NULL,
    CONSTRAINT [PK_Orders] PRIMARY KEY ([UserId]),
    CONSTRAINT "FK_Addresses_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("UserId") ON DELETE CASCADE
);

这种拥有类型的用法与第一次使用不同,在第一次用法中,数据存储在实体类表中,因为您可以保存没有地址的 User 实体实例。但同样的规则也适用于查询:在查询 User 实体时,将读入 HomeAddress 属性,而无需 Include 方法。

用于保存 HomeAddress 数据的 Addresses 表处于隐藏状态;无法通过 EF Core 访问它。这种情况可能是一件好事,也可能是一件坏事,具体取决于您的业务需求。但是,如果要访问 Address 部分,则可以使用两个实体类来实现相同的功能,这两个实体类之间具有一对多关系。

8.9.2 每个层次结构的表 (TPH):将继承的类放入一个表中

每个层次结构的表 (TPH) 将相互继承的所有类存储在单个数据库表中。例如,如果您想在商店中保存付款,该付款可以是现金 (PaymentCash) 或信用卡 (PaymentCard)。每个选项都包含金额(例如,10 美元),但信用卡选项包含额外的信息,例如在线交易收据。在这种情况下,TPH 使用单个表来存储继承类的所有版本,并返回正确的实体类型 PaymentCash 或 PaymentCard,具体取决于保存的内容。

提示:我已经在几个项目中为我的客户使用了 TPH 类,我发现 TPH 是一个很好的解决方案,用于存储当某些数据集需要额外属性时相似的数据集。假设您有很多产品类型,具有通用的名称、价格、产品代码、重量和其他属性,但密封剂产品需要 MinTemp 和 MaxTemp 属性,TPH 可以使用一个表而不是多个表来实现这些属性。

TPH 可以按约定进行配置,这会将继承类的所有版本合并到一个表中。这种方法的优点是将公共数据保存在一个表中,但访问该数据有点麻烦,因为每个继承类型都有自己的 DbSet<T> 属性。但是,当您添加 Fluent API 时,可以通过一个 DbSet 属性访问所有继承的类,<T>在我们的示例中,这使得 PaymentCash / PaymentCard 示例更加有用。

第一个示例使用多个 DbSet<T>,每个类对应一个 DbSet,并按约定进行配置。第二个示例使用一个<T>映射到基类的 DbSet,我发现它是更有用的版本,并显示了 TPH Fluent API 命令。

按约定配置 TPH

若要将 By 约定方法应用于 PaymentCash/PaymentCard 示例,请创建一个名为 PaymentCash 的类,然后创建一个名为 PaymentCard 的类,该类继承自 PaymentCash,如下面的清单所示。如您所见,PaymentCard 继承自 PaymentCash,并添加了一个额外的 ReceiptCode 属性。

清单 8.21 两个类:PaymentCash 和 PaymentCard

public class PaymentCash
{
    [Key]
    public int PaymentId { get; set; } 
    public decimal Amount { get; set; }
}
//PaymentCredit - 继承自 PaymentCash
public class PaymentCard : PaymentCash
{
    public string ReceiptCode { get; set; }
}

清单 8.22 使用 By 约定方法,显示了应用进程的 DbContext 和两个 DbSet 属性,两个类各一个。由于包含这两个类,并且 PaymentCard 继承自 PaymentCash,因此 EF Core 会将这两个类存储在一个表中。

清单 8.22 更新后的应用进程的 DbContext 和两个 DbSet 属性

public class Chapter08DbContext : DbContext
{
    //… other DbSet<T> properties removed

    //Table-per-hierarchy
    public DbSet<PaymentCash> CashPayments { get; set; } 
    public DbSet<PaymentCard> CreditPayments { get; set; }

    public Chapter08DbContext( DbContextOptions<Chapter08DbContext> options) : base(options)
    { }

    protected override void OnModelCreating (ModelBuilder modelBuilder)
    {
        //no configuration needed for PaymentCash or PaymentCard
    }
}

最后,此列表显示了 EF Core 生成的代码,用于创建将存储 PaymentCash 和 PaymentCard 实体类的表。

清单 8.23 EF Core 生成的用于构建 CashPayment 表的 SQL

CREATE TABLE [CashPayments] ( 
    [PaymentId] int NOT NULL IDENTITY, 
    [Amount] decimal(18, 2) NOT NULL,
    --“鉴别器”列保存类的名称,EF Core 使用该名称来定义保存的数据类型。按约定设置时,此列将类的名称保存为字符串。
    [Discriminator] nvarchar(max) NOT NULL, 
    [ReceiptCode] nvarchar(max),  --仅当 ReceiptCode 列是 PaymentCredit 时,才使用它。
    CONSTRAINT [PK_CashPayments] PRIMARY KEY ([PaymentId])
);

如你所见,EF Core 添加了一个 Discriminator 列,它在返回数据时使用该列根据保存的内容创建正确的类类型:PaymentCash 或 PaymentCard。此外,当类类型为 PaymentCard 时,ReceiptCode 列是填充/只读的。

不在 TPH 基类中的任何标量属性都映射到可为 null 的列,因为这些属性仅由 TPH 类的一个版本使用。如果 TPH 类中有很多类,则值得一看是否可以将相似的类型化属性合并到同一列。例如,在产品 TPH 类中,您可能有一个产品类型密封剂需要双倍 MaxTemp,而另一个产品类型镇流器需要双倍 WeightKgs。可以使用以下代码片段将这两个属性映射到同一列:

public class Chapter08DbContext : DbContext
{
    //… other part left out

    Protected override void OnModelCreating (ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Sealant>()
            .Property(b => b.MaxTemp)
            .HasColumnName("DoubleValueCol");

        modelBuilder.Entity<Ballast>()
            .Property(b => b.WeightKgs)
            .HasColumnName("DoubleValueCol");
    }
}

使用 FLUENT API 改进我们的 TPH 示例

尽管 By Convention 方法减少了数据库中的表数,但您有两个单独的 DbSet 属性,并且您需要使用正确的属性来查找已使用的付款。此外,您没有可在任何其他实体类中使用的通用 Payment 类。但是,通过重新排列和添加一些 Fluent API 配置,可以使此解决方案更加有用。

图 8.12 显示了新的排列方式。通过具有一个名为 Payment 的抽象类来创建公共基类,PaymentCash 和 PaymentCard 继承自该抽象类。此方法允许您在另一个名为 SoldIt 的实体类中使用 Payment 类。

图 8.12 通过使用 Fluent API,您可以创建更有用的 TPH 形式。这里,一个名为 Payment 的抽象类被用作基,这个类可以在另一个实体类中使用。放置在 SoldIt 付款属性中的实际类类型将是 PaymentCash 或 PaymentCard,具体取决于创建 SoldIt 类时使用的内容。

这种方法更有用,因为现在您可以在 SoldIt 实体类中放置一个 Payment 抽象类,并获取付款的金额和类型,无论是现金还是卡。PType 属性告诉您类型(PType 属性的类型为 PTypes,它是值为 Cash 或 Card 的枚举),如果需要 PaymentCard 中的 Receipt 属性,则可以将 Payment 类强制转换为 PaymentCard 类型。

除了创建图 8.12 中所示的实体类之外,还需要更改应用进程的 DbContext 并添加一些 Fluent API 配置,以告知 EF Core 有关 TPH 类的信息,因为它们不再适合 By 约定方法。此列表显示了应用进程的 DbContext,以及 Discrimination 列的配置。

清单 8.24 更改了应用进程的 DbContext,添加了 Fluent API 配置

public class Chapter08DbContext : DbContext
{
    //… other DbSet<T> properties removed
    public DbSet<Payment> Payments { get; set; }  //定义可通过该属性访问所有付款(包括 PaymentCash 和 PaymentCard)
    public DbSet<SoldIt> SoldThings { get; set; }   //已售商品列表,以及指向付款的必需链接
    
    public Chapter08DbContext(DbContextOptions<Chapter08DbContext>  options) : base(options)
    { }

    protected override void OnModelCreating (ModelBuilder modelBuilder)
    {
        //… other configurations removed 
        modelBuilder.Entity<Payment>()
            .HasDiscriminator(b => b.PType)  //HasDiscriminator 方法将实体标识为 TPH,然后选择属性 PType 作为不同类型的鉴别器。在本例中,它是一个枚举,您将其设置为字节大小。
            .HasValue<PaymentCash>(PTypes.Cash)  //设置 PaymentCash 类型的鉴别器值
            .HasValue<PaymentCard>(PTypes.Card);  //设置 PaymentCard 类型的鉴别器值
    }
}

注意 此示例使用抽象类作为基类,我认为这更有用,但它也可以保留原始的 PaymentCash,并继承 PaymentCard。抽象基类可以更轻松地更改常见的 TPH 属性。

访问 TPH 实体

现在,您已经配置了一组 TPH 类,让我们来介绍一下 CRUD 操作中的任何差异。大多数 EF 数据库访问命令都是相同的,但有一些更改访问实体的 TPH 部分。EF Core 在处理 TPH 方面做得很好(就像 EF6.x 一样)。

首先,TPH 实体的创建非常简单。您可以创建所需的特定类型的实例。以下代码片段创建一个 PaymentCash 类型实体以用于销售:

var sold = new SoldIt()
{
    WhatSold = "A hat",
    Payment = new PaymentCash {Amount = 12}
};
context.Add(sold); 
context.SaveChanges();

然后,EF Core 保存该类型的正确数据版本,并设置鉴别器,以便它知道实例的 TPH 类类型。当您读回刚刚保存的 SoldIt 实体时,使用 Include 加载 Payment 导航属性时,加载的 Payment 实例的类型将是正确的类型(PaymentCash 或 PaymentCard),具体取决于将其写入数据库时使用的内容。此外,在此示例中,您设置为鉴别器的 Payment 属性 PType 会告诉您付款类型:现金或卡。

查询 TPH 数据时,EF Core OfType<T> 方法允许筛选数据以查找特定类。查询上下文。例如,Payments.OfType<PaymentCard>() 将仅返回使用卡的付款。还可以在“包含”中筛选 TPH 类。有关详细信息,请参阅此文章:http://mng.bz/QmBj

8.9.3 每种类型的表(TPT):每个类都有自己的表

EF Core 5 版本添加了“每个类型表”(TPT) 选项,该选项允许从基类继承的每个实体类都有自己的表。此选项与第 8.9.2 节中介绍的按层次结构 (TPH) 方法相反。如果继承层次结构中的每个类都有很多不同的信息,则 TPT 是一个很好的解决方案;当每个继承的类都具有较大的公共部分并且每个类的数据量很少时,TPH 会更好。

例如,您将为两种类型的集装箱构建 TPT 解决方案:散货船上使用的运输集装箱和塑料容器,如瓶子、罐子和盒子。两种类型的容器都有总高度、长度和深度,但除此之外,它们是不同的。下面的清单显示了三个实体类,首先是基 Container 抽象类,然后是 ShippingContainer 和 PlasticContainer。

清单 8.25 TPT 示例中使用的三个类

public abstract class Container  //Container 类被标记为抽象类,因为它不会被创建。
{
    [Key]
    public int ContainerId { get; set; }  //成为每个 TPT 表的主键
    //每个容器的共同部分是整体高度、宽度和深度
    public int HeightMm { get; set; } 
    public int WidthMm { get; set; } 
    public int DepthMm { get; set; }
}
 
public class ShippingContainer : Container  //该类继承了 Container 类。
{
    //这些特性对于集装箱运输来说是独一无二的。
    public int ThicknessMm { get; set; } 
    public string DoorType { get; set; } 
    public int StackingMax { get; set; } 
    public bool Refrigerated { get; set; }
}

public class PlasticContainer : Container
{
    //这些特性对于塑料容器来说是独一无二的。
    public int CapacityMl { get; set; } 
    public Shapes Shape { get; set; } 
    public string ColorARGB { get; set; }
}

接下来,需要配置应用进程的 DbContext,它包含两个部分:(a) 添加 DbSet 属性(用于访问所有容器)和 (b) 设置其他容器类型(ShippingContainer 和 PlasticContainer)以映射到它们自己的表。下面的清单显示了这两个部分。

清单 8.26 更新应用进程的 DbContext 以设置 TPT 容器

public class Chapter08DbContext : DbContext
{
    public Chapter08DbContext( DbContextOptions<Chapter08DbContext> options) : base(options)
    { }

    //… other DbSet<T> removed for clarity
    public DbSet<Container> Containers { get; set; }  //这个单一的 DbSet 用于访问所有不同的容器。

    protected override void OnModelCreating (ModelBuilder modelBuilder)
    {
        //… other configrations removed for clarity

        //这些 Fluent API 方法将每个容器映射到不同的表。
        modelBuilder.Entity<ShippingContainer>()
            .ToTable(nameof(ShippingContainer)); 
        modelBuilder.Entity<PlasticContainer>()
            .ToTable(nameof(PlasticContainer));
    }
}

更新应用进程的 DbContext 的结果是三个表:

  • 通过 DbSet 的 Containers 表,其中包含每个条目的通用数据
  • 包含 Container 和 ShippingContainer 属性的 ShippingContainer 表
  • 包含 Container 和 PlasticContainer 属性的 PlasticContainer 表

您可以按正常方式添加 ShippingContainer 和 PlasticContainer:使用上下文。Add 方法。但是,当您在应用进程的 DbContext 中查询 DbSet 容器时,神奇的是,对于返回的每个实体,它使用正确的类类型(ShippingContainer 或 PlasticContainer)返回所有容器。

有几个选项可用于加载一种类型的 TPT 类。以下是三种方法,最后最有效的方法:

  • 读取所有查询 -context.Containers.ToList() 选项将读取所有 TPT 类型,并且列表中的每个条目都将是其返回类型的正确类型(ShippingContainer 或 PlasticContainer)。仅当要列出所有容器的摘要时,此选项才有用。
  • OfType 查询 - context.Containers.OfType<ShippingContainer>().ToList()  选项仅读取 ShippingContainer 类型的条目。
  • 设置 query—context.Set<ShippingContainer>().ToList() ,此选项仅返回 ShippingContainer 类型(就像 OfType 查询一样),但 SQL 比 OfType 查询效率略高。

8.9.4 表拆分:将多个实体类映射到同一个表

下一个功能称为表拆分,允许您将多个实体映射到同一个表。如果您要为一个实体存储大量数据,但对此实体的正常查询只需要几列,则此功能非常有用。表拆分就像将 Select 查询构建到实体类中;查询会更快,因为您只加载整个实体数据的一小部分。它还可以通过将表拆分为两个或多个类来加快更新速度。

此示例有两个实体类:BookSummary 和 BookDetail,它们都映射到名为 Books 的数据库表。图 8.13 显示了将这两个实体类配置为表拆分的结果。

图 8.13 使用 EF Core 中的表拆分功能将两个实体类 BookSummary 和 BookDetail 映射到一个表 Books 的结果。这样做是因为一本书需要大量信息,但大多数查询只需要 BookSummary 部分。其效果是生成一组预先选择的列,以便更快地进行查询。

下面是配置代码。

清单 8.27 配置在 BookSummary 和 BookDetail 之间拆分的表

public class SplitOwnDbContext : DbContext
{
    //… other code removed

    protected override void OnModelCreating (ModelBuilder modelBuilder)
    {
        //将这两本书定义为具有与创建一对一关系相同的关系
        modelBuilder.Entity<BookSummary>()
            .HasOne(e => e.Details)
            .WithOne()
            .HasForeignKey<BookDetail> (e => e.BookDetailId);  //在这种情况下,HasForeignKey 方法必须引用 BookDetail 实体中的主键。
        //必须将这两个实体类映射到 Books 表才能触发表拆分。
        modelBuilder.Entity<BookSummary>()
            .ToTable("Books");

        modelBuilder.Entity<BookDetail>()
            .ToTable("Books");
    }
}

将两个实体配置为表拆分后,您可以单独查询 BookSummary 实体并获取摘要部分。要获取 BookDetails 部分,您可以查询 BookSummary 实体并同时加载 Details 关系属性(例如,使用 Include 方法),或者直接从数据库读取 BookDetails 部分。

注意在本书的第 3 部分中,我使用 Manning Publications 的真实图书数据构建了一个更加复杂的图书应用进程。我使用表拆分将详细书籍视图中使用的大型描述与书籍数据的主要部分分开。例如,书籍的 PublishedOn 属性的任何更新都会快得多,因为我不必阅读所有描述。

在离开这个话题之前,让我先说几点:

  • 可以单独更新表拆分中的单个实体类;您不必加载表拆分中涉及的所有实体来进行更新。
  • 您已经看到一个表拆分为两个实体类,但您可以将表拆分为任意数量的实体类。
  • 如果有并发令牌(参见10.6.2节),它们必须位于映射到同一个表的所有实体类中,以确保当只有一个实体类映射到表时,并发令牌值不会出现数据丢失的情况。表已更新。

8.9.5 属性包:使用字典作为实体类

EF Core 5 添加了一项称为属性包的功能,该功能使用 Dictionary 类型映射到数据库。属性包用于实现直接多对多关系功能,其中必须在配置时创建链接表。您还可以使用属性包,但它仅在特定区域有用,例如在结构由外部数据定义的表中创建属性包实体。

注意:属性包使用本书其他地方未描述的两个功能。第一个功能是共享实体类型,其中相同的类型可以映射到多个表。第二个功能使用实体类中的 C# 索引器属性来访问数据,例如 public object this[string key] … 。

例如,您将属性包映射到一个表,该表的名称和列由外部数据而不是类的结构定义。对于此示例,该表是在 TableSpec 类中定义的,该类假定已在启动时读取(可能来自 appsettings.json 文档)。以下清单显示了应用进程的 DbContext 以及通过 propertybag 实体配置和访问表所需的代码。

清单 8.28 使用属性包字典在启动时定义一个表

public class PropertyBagsDbContext : DbContext
{
    //传入一个包含表和属性规范的类。 
    private readonly TableSpec _tableSpec;

    public PropertyBagsDbContext( DbContextOptions<PropertyBagsDbContext> options, TableSpec tableSpec) : base(options)
    {
        _tableSpec = tableSpec;
    }

    public DbSet<Dictionary<string, object>> MyTable  //名为 MyTable 的 DbSet 链接到在 OnModelCreating 中生成的 SharedType 实体。
        => Set<Dictionary<string, object>>(_tableSpec.Name);

    protected override void OnModelCreating (ModelBuilder modelBuilder)
    {    
        //定义一个 SharedType 实体类型,该类型允许将同一类型映射到多个表
        modelBuilder.SharedTypeEntity <Dictionary<string, object>>(
            _tableSpec.Name, b =>  //你给这个共享实体类型一个名字,这样你就可以引用它。
        {
            foreach (var prop in _tableSpec.Properties)  //依次从 tableSpec 中添加每个属性
            {
                //添加一个索引属性,根据其名称查找主键
                var propConfig = b.IndexerProperty( prop.PropType, prop.Name);
                //将属性设置为不为 null(仅在可为 null 的类型(如字符串)上需要)
                if (prop.AddRequired) propConfig.IsRequired();
            }
        }).Model.AddAnnotation("Table", _tableSpec.Name);  //现在你映射到你想要访问的表。
    }	
}	

需要明确的是,TableSpec 类中的数据每次都必须相同,因为 EF Core 会缓存配置。属性包实体的配置在应用进程运行的整个过程中是固定的。若要访问 property-bag 实体,请使用下一个列表中所示的 MyTable 属性。此列表演示如何通过字典添加新条目,然后将其读回,包括在 LINQ 查询中访问属性包的属性。

列表 8.29 添加和查询属性包

var propBag = new Dictionary<string, object>  //属性包的类型为 Dictionary。
{
    //使用正常的字典方法设置各种属性
    ["Title"] = "My book",
    ["Price"] = 123.0
};
context.MyTable.Add(propBag); //对于共享类型(如属性包),必须提供要添加到的 DbSet。
context.SaveChanges();  //以正常方式保存。

var readInPropBag = context.MyTable  //要回读,使用映射到 property-bag 实体的 DbSet。
    .Single(x => (int)x["Id"] == 1);  //若要引用属性/列,需要使用索引器。您可能需要将对象强制转换为正确的类型。

var title = readInPropBag["Title"];  //您可以使用正常的字典访问方法访问结果。

此列表是一个特定示例,其中属性包是一个很好的解决方案,但你可以手动配置属性包。以下是有关属性包的更多信息:

  • 属性包的属性名称遵循按约定命名。例如,主键是 Id。但是,您可以像往常一样使用 Fluent API 命令覆盖此设置。
  • 您可以拥有多个属性包。SharedTypeEntity Fluent API 方法允许您将同一类型映射到不同的表。
  • 属性包可以与其他类或属性包有关系。使用 HasOne/HasMany Fluent API 方法,但无法在属性包中定义导航属性。
  • 添加 propertybag 实体时,不必设置字典中的每个属性。任何未设置的属性/列都将设置为类型的默认值。

总结

  • 如果遵循 By Convention 外键命名规则,EF Core 可以找到并配置大多数正常关系。
  • 两个数据注释为与名称不符合约定命名规则的外键相关的几个特定问题提供了解决方案。
  • Fluent API 是配置关系的最全面的方法。某些功能(例如设置删除依赖实体的操作)只能通过 Fluent API 获得。
  • 您可以通过添加在 DbContext 的 OnModelCreating 方法中运行的代码来自动化实体类的某些配置。
  • EF Core 使您能够控制导航属性的更新,包括停止、添加或删除集合导航属性中的条目。
  • EF Core 提供了多种将实体类映射到数据库表的方法。主要是拥有的类型、每个层次结构的表、每个类型的表、表分割和属性包。

对于熟悉 EF6 的读者:

  • EF Core 中配置关系的基本过程与 EF6.x 中相同,但 Fluent API 命令发生了显着变化。
  • 如果您忘记自己添加外键,EF6.x 会添加外键,但无法通过正常的 EF6.x 命令访问它们。 EF Core 允许您通过影子属性访问它们。
  • EF Core 5 版本添加了与 EF6.x 的多对多关系类似的功能,EF Core 现在自动创建链接表(请参阅第 3.4.4 节),但 EF Core 的实现方式与 EF6.x 的实现方式不同此功能。
  • EF Core 引入了新功能,例如访问影子属性、备用键和支持字段。
  • EF Core 拥有的类型提供了您在 EF6.x 的复杂类型中可以找到的功能。额外的功能包括将拥有的类型存储在自己的表中。
  • EF Core 的 TPH、TPT 和分表功能与 EF6.x 中的相应功能类似,但 EF6.x 中没有自有类型和属性包。