继承

EF 可以将 .NET 类型层次结构映射到数据库。 这允许你像往常一样使用基类型和派生类型在代码中编写 .NET 实体,并让 EF 无缝创建适当的数据库架构、发出查询等。有关如何映射类型层次结构的实际细节取决于提供程序;本页介绍关系数据库上下文中的继承支持。

实体类型层次结构映射

按照约定,EF 不会自动扫描基类型或派生类型;这意味着,如果要映射层次结构中的 CLR 类型,就必须在模型上显式指定该类型。 例如,仅指定层次结构的基类型不会导致 EF Core 隐式包含其所有子类型。

以下示例将为 Blog 及其子类 RssBlog 公开 DbSet。 如果 Blog 有任何其他子类,它不会包含在模型中。

internal class MyContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<RssBlog> RssBlogs { get; set; }
}

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }
}

public class RssBlog : Blog
{
    public string RssUrl { get; set; }
}

注意

使用 TPH 映射时,数据库列会根据需要自动设置为可为 null。 例如,RssUrl 列可为 null,因为常规 Blog 实例没有该属性。

如果不想为层次结构中的一个或多个实体公开 DbSet,还可以使用 Fluent API 确保将它们包含在模型中。

提示

如果不依赖约定,则可以使用 HasBaseType 显式指定基类型。 还可以使用 .HasBaseType((Type)null) 从层次结构中删除实体类型。

每个层次结构一张表和鉴别器配置

默认情况下,EF 使用每个层次结构一张表 (TPH) 模式来映射继承。 TPH 使用单个表来存储层次结构中所有类型的数据,并使用鉴别器列来标识每行表示的类型。

上面的模型映射到以下数据库架构(注意隐式创建的 Discriminator 列,它标识了每行中存储的 Blog 类型)。

Screenshot of the results of querying the Blog entity hierarchy using table-per-hierarchy pattern

可以配置鉴别器列的名称和类型以及用于标识层次结构中每种类型的值:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator<string>("blog_type")
        .HasValue<Blog>("blog_base")
        .HasValue<RssBlog>("blog_rss");
}

在上面的示例中,EF 在层次结构的基本实体上隐式添加了鉴别器作为影子属性。 可以像配置任何其他属性一样配置此属性:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .Property("Discriminator")
        .HasMaxLength(200);
}

最后,鉴别器也可以映射到实体中的常规 .NET 属性:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator(b => b.BlogType);

    modelBuilder.Entity<Blog>()
        .Property(e => e.BlogType)
        .HasMaxLength(200)
        .HasColumnName("blog_type");
        
    modelBuilder.Entity<RssBlog>();
}

查询使用 TPH 模式的派生实体时,EF Core 会在查询中添加一个基于鉴别器列的谓词。 此筛选器确保对于结果中没有的基类型或同级类型,我们不会获得任何附加行。 对于基本实体类型,将跳过此筛选器谓词,因为查询基本实体将获得层次结构中所有实体的结果。 在具体化查询结果时,如果遇到未映射到模型中任何实体类型的鉴别器值,我们将引发异常,因为我们不知道如何具体化结果。 仅当数据库包含的行具有鉴别器值并且这些值未映射到 EF 模型时,才会发生此错误。 如果你有这样的数据,可以将 EF Core 模型中的鉴别器映射标记为不完整,以指示我们应始终添加筛选器谓词来查询层次结构中的任意类型。 IsComplete(false) 在鉴别器配置上调用会将映射标记为不完整。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator()
        .IsComplete(false);
}

共享列

默认情况下,当层次结构中的两个同级实体类型具有同名的属性时,它们将映射到两个单独的列。 但是,如果它们的类型相同,则可以映射到相同的数据库列:

public class MyContext : DbContext
{
    public DbSet<BlogBase> Blogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .Property(b => b.Url)
            .HasColumnName("Url");

        modelBuilder.Entity<RssBlog>()
            .Property(b => b.Url)
            .HasColumnName("Url");
    }
}

public abstract class BlogBase
{
    public int BlogId { get; set; }
}

public class Blog : BlogBase
{
    public string Url { get; set; }
}

public class RssBlog : BlogBase
{
    public string Url { get; set; }
}

注意

使用强制转换查询共享列时,关系数据库提供程序(例如 SQL Server)不会自动使用鉴别器谓词。 查询 Url = (blog as RssBlog).Url 还将返回同级 Blog 行的 Url 值。 若要将查询限制为 RssBlog 实体,你需要在鉴别器上手动添加筛选器,例如 Url = blog is RssBlog ? (blog as RssBlog).Url : null

每个类型一张表配置

在 TPT 映射模式中,所有类型都分别映射到各自的表。 仅属于某个基类型或派生类型的属性存储在映射到该类型的一个表中。 映射到派生类型的表还会存储外键来联接派生表与基表。

modelBuilder.Entity<Blog>().ToTable("Blogs");
modelBuilder.Entity<RssBlog>().ToTable("RssBlogs");

提示

可以对每个根实体类型调用 modelBuilder.Entity<Blog>().UseTptMappingStrategy(),而不是对每个实体类型调用 ToTable,表名将由 EF 生成。

提示

要为每个表中的主键列配置不同的列名,请参阅特定于表的方面配置

EF 将为上述模型创建以下数据库架构。

CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL IDENTITY,
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId]),
    CONSTRAINT [FK_RssBlogs_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([BlogId]) ON DELETE NO ACTION
);

注意

如果重命名主键约束,新名称将应用于映射到层次结构的所有表。当问题 19970修复后,未来的 EF 版本将允许仅对特定表重命名约束。

如果使用批量配置,可以通过调用 GetColumnName(IProperty, StoreObjectIdentifier) 来检索特定表的列名。

foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    var tableIdentifier = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table);

    Console.WriteLine($"{entityType.DisplayName()}\t\t{tableIdentifier}");
    Console.WriteLine(" Property\tColumn");

    foreach (var property in entityType.GetProperties())
    {
        var columnName = property.GetColumnName(tableIdentifier.Value);
        Console.WriteLine($" {property.Name,-10}\t{columnName}");
    }

    Console.WriteLine();
}

警告

在许多情况下,与 TPH 相比,TPT 性能较差。 有关详细信息,请参阅性能文档

注意

派生类型的列映射到不同的表,因此无法在数据库中创建同时使用继承属性和声明属性的复合 FK 约束和索引。

每个具体类型一张表配置

注意

EF Core 7.0 中引入了每个具体类型一张表 (TPC) 功能。

在 TPC 映射模式中,所有类型都分别映射到各自的表。 每张表都包含相应实体类型上所有属性的列。 这解决了 TPT 策略的一些常见性能问题。

提示

EF 团队在 .NET 数据社区站立会议的一集中演示并深入讨论了 TPC 映射。 与所有社区站立会议剧集一样,你可以观看 YouTube 上的 TPC 剧集

modelBuilder.Entity<Blog>().UseTpcMappingStrategy()
    .ToTable("Blogs");
modelBuilder.Entity<RssBlog>()
    .ToTable("RssBlogs");

提示

无需对每个实体类型调用 ToTable,只需对每个根实体类型调用 modelBuilder.Entity<Blog>().UseTpcMappingStrategy() 即可按照约定生成表名。

提示

要为每个表中的主键列配置不同的列名,请参阅特定于表的方面配置

EF 将为上述模型创建以下数据库架构。

CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId])
);

TPC 数据库架构

TPC 策略类似于 TPT 策略,除了为层次结构中每个具体类型创建不同的表,但表不是为抽象类型创建的,因此名称为“每个具体类型一张表”。 与 TPT 一样,表本身指示已保存对象的类型。 但是,与 TPT 映射不同,每个表都包含具体类型及其基类型中每个属性的列。 TPC 数据库架构是非规范化的。

例如,请考虑映射以下层次结构:

public abstract class Animal
{
    protected Animal(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public abstract string Species { get; }

    public Food? Food { get; set; }
}

public abstract class Pet : Animal
{
    protected Pet(string name)
        : base(name)
    {
    }

    public string? Vet { get; set; }

    public ICollection<Human> Humans { get; } = new List<Human>();
}

public class FarmAnimal : Animal
{
    public FarmAnimal(string name, string species)
        : base(name)
    {
        Species = species;
    }

    public override string Species { get; }

    [Precision(18, 2)]
    public decimal Value { get; set; }

    public override string ToString()
        => $"Farm animal '{Name}' ({Species}/{Id}) worth {Value:C} eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Cat : Pet
{
    public Cat(string name, string educationLevel)
        : base(name)
    {
        EducationLevel = educationLevel;
    }

    public string EducationLevel { get; set; }
    public override string Species => "Felis catus";

    public override string ToString()
        => $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Dog : Pet
{
    public Dog(string name, string favoriteToy)
        : base(name)
    {
        FavoriteToy = favoriteToy;
    }

    public string FavoriteToy { get; set; }
    public override string Species => "Canis familiaris";

    public override string ToString()
        => $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Human : Animal
{
    public Human(string name)
        : base(name)
    {
    }

    public override string Species => "Homo sapiens";

    public Animal? FavoriteAnimal { get; set; }
    public ICollection<Pet> Pets { get; } = new List<Pet>();

    public override string ToString()
        => $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'" +
           $" eats {Food?.ToString() ?? "<Unknown>"}";
}

使用 SQL Server 时,为此层次结构创建的表包括:

CREATE TABLE [Cats] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [EducationLevel] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([Id]));

CREATE TABLE [Dogs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [FavoriteToy] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([Id]));

CREATE TABLE [FarmAnimals] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Value] decimal(18,2) NOT NULL,
    [Species] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_FarmAnimals] PRIMARY KEY ([Id]));

CREATE TABLE [Humans] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [FavoriteAnimalId] int NULL,
    CONSTRAINT [PK_Humans] PRIMARY KEY ([Id]));

请注意:

  • AnimalPet 类型没有表,因为它们在对象模型中属于 abstract。 请记住,C# 不允许抽象类型的实例,因此不存在将抽象类型实例保存到数据库的情况。

  • 对于每个具体类型,都会重复基类型中的属性映射。 例如,每个表都有一个 Name 列,Cats 和 Dogs 都有一个 Vet 列。

  • 将某些数据保存到此数据库中会导致以下结果:

Cats 表

Id 名称 FoodId Vet EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Preschool
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Bothell Pet Hospital BSc

Dogs 表

Id 名称 FoodId Vet FavoriteToy
3 toast 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly Mr. Squirrel

FarmAnimals 表

Id 名称 FoodId 种类
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

Humans 表

Id 名称 FoodId FavoriteAnimalId
5 Wendy 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Katie Null 8

请注意,与 TPT 映射不同,单个对象的所有信息都包含在单个表中。 而且,与 TPH 映射不同,任何表中的列和行都不存在模型从未使用过的组合。 下面介绍这些特征对于查询和存储有何重要意义。

密钥生成

选择的继承映射策略对如何生成和管理主键值产生了影响。 TPH 中的键很简单,因为每个实体实例都由单个表中的单个行表示。 可以使用任何类型的键值生成,无需其他约束。

对于 TPT 策略,表中始终有一行映射到层次结构的基类型。 此行可以使用任何类型的键生成,其他表的键使用外键约束链接到此表。

对于 TPC 来说,事情会变得更加复杂。 首先,必须了解 EF Core 要求层次结构中的所有实体都必须具有唯一键值,即使实体具有不同的类型也是如此。 例如,使用我们的示例模型,“狗”不能与“猫”具有相同的 ID 键值。 其次,与 TPT 不同,没有一个通用表可以作为存放和生成键值的单一位置。 这意味着无法使用简单的 Identity 列。

对于支持序列的数据库,可以使用每个表的默认约束中引用的单个序列来生成键值。 这是上面所示的 TPC 表中使用的策略,其中每个表都有以下内容:

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence])

AnimalSequence 是由 EF Core 创建的数据库序列。 使用适用于 SQL Server 的 EF Core 数据库提供程序时,此策略默认用于 TPC 层次结构。 支持序列的其他数据库的数据库提供程序应具有类似的默认值。 使用序列的其他键生成策略(如 Hi-Lo 模式)也可用于 TPC。

虽然标准标识列不适用于 TPC,但如果每个表都配置了适当的种子和增量,则可以使用标识列,以便为每个表生成的值永远不会冲突。 例如:

modelBuilder.Entity<Cat>().ToTable("Cats", tb => tb.Property(e => e.Id).UseIdentityColumn(1, 4));
modelBuilder.Entity<Dog>().ToTable("Dogs", tb => tb.Property(e => e.Id).UseIdentityColumn(2, 4));
modelBuilder.Entity<FarmAnimal>().ToTable("FarmAnimals", tb => tb.Property(e => e.Id).UseIdentityColumn(3, 4));
modelBuilder.Entity<Human>().ToTable("Humans", tb => tb.Property(e => e.Id).UseIdentityColumn(4, 4));

重要

使用此策略会使以后添加派生类型变得更加困难,因为它需要事先知道层次结构中的类型总数。

SQLite 不支持序列或标识种子/增量,因此在将 SQLite 与 TPC 策略结合使用时不支持整数键值生成。 但是,任何数据库(包括 SQLite)都支持客户端生成或全局唯一密钥(例如 GUID)。

外键约束

TPC 映射策略会创建非规范化 SQL 架构,这是一些数据库纯粹主义者反对它的原因之一。 例如,考虑外键列 FavoriteAnimalId。 此列中的值必须与某些动物的主键值匹配。 使用 TPH 或 TPT 时,可以使用简单的 FK 约束在数据库中强制执行此操作。 例如:

CONSTRAINT [FK_Animals_Animals_FavoriteAnimalId] FOREIGN KEY ([FavoriteAnimalId]) REFERENCES [Animals] ([Id])

但是,使用 TPC 时,任何给定动物的主要键存储在该动物的具体类型对应的表中。 例如,猫的主键存储在 Cats.Id 列中,而狗的主键存储在 Dogs.Id 列中,等等。 这意味着无法为此关系创建 FK 约束。

实际上,只要应用程序不尝试插入无效数据,这就不是问题。 例如,如果 EF Core 插入所有数据并使用导航来关联实体,则保证 FK 列将随时包含有效的 PK 值。

总结和指南

总之,TPH 通常适用于大多数应用程序,并且对于各种方案而言都是一个很好的默认值,因此,如果不需要 TPC,请不要添加 TPC 来增加复杂性。 具体而言,如果代码主要查询许多类型的实体,例如针对基类型编写查询,则倾向于使用 TPH,而不是 TPC。

尽管如此,当代码主要查询单个叶类型的实体并且你基准测试显示与 TPH 相比有所改进时,TPC 也是一种很好的映射策略。

仅当受外部因素约束时,才使用 TPT。