惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

量子位
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
F
Fortinet All Blogs
博客园 - 聂微东
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Hugging Face - Blog
Hugging Face - Blog
V
Visual Studio Blog
小众软件
小众软件
有赞技术团队
有赞技术团队
雷峰网
雷峰网
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
AWS News Blog
AWS News Blog
C
Cisco Blogs
美团技术团队
T
Threat Research - Cisco Blogs
C
CERT Recently Published Vulnerability Notes
人人都是产品经理
人人都是产品经理
宝玉的分享
宝玉的分享
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
酷 壳 – CoolShell
酷 壳 – CoolShell
Stack Overflow Blog
Stack Overflow Blog
W
WeLiveSecurity
D
DataBreaches.Net
博客园 - 司徒正美
Blog — PlanetScale
Blog — PlanetScale
IT之家
IT之家
云风的 BLOG
云风的 BLOG
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Simon Willison's Weblog
Simon Willison's Weblog
Google DeepMind News
Google DeepMind News
T
The Blog of Author Tim Ferriss
Know Your Adversary
Know Your Adversary
NISL@THU
NISL@THU
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
Vercel News
Vercel News
月光博客
月光博客
T
Tailwind CSS Blog
H
Help Net Security
aimingoo的专栏
aimingoo的专栏
P
Proofpoint News Feed
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Spread Privacy
Spread Privacy
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Cisco Talos Blog
Cisco Talos Blog
Microsoft Security Blog
Microsoft Security Blog
V
V2EX
WordPress大学
WordPress大学
Cyberwarzone
Cyberwarzone
Recent Announcements
Recent Announcements

博客园 - 东邪独孤

【ASP.NET Core】在 Windows Forms 中运行 Web 服务器 【EF Core】级联删除行为 【EF Core】继承策略——TPC 【EF Core】继承策略——TPT 【EF Core】继承策略——TPH 【EF Core】使用自定义的值比较器 【EF Core】值转换器 【EF Core】直接更新数据 【EF Core】实体追踪——Entry中记录的数据 【EF Core】实体状态与变更追踪 【EF Core】“Code First”方案下以编程方式生成迁移 【EF Core】“DB First”方案下用编程方式生成数据库模型代码 【EF Core】三种方法记录生成的 SQL 语句 【EF Core】未定义实体类的数据库模型 【EF Core】“多对多”关系与跳跃导航 【EF Core】FromExpression 方法有什么用? 【EF Core】通过 DbContext 选项扩展框架 【EF Core】框架底层的数据库连接管理 【EF Core】再谈普通实体关系与 Owned 关系的区别 【EF Core】实体类的依赖注入 【EF Core】优化后的模型 【EF Core】使用外部 Model 【EF Core】聊聊“复合”属性 【EF Core】为 DatabaseFacade 扩展“创建”与“删除”数据表功能 【EF Core】带主键实体与无主键实体 【EF Core】框架是如何识别实体类的属性和主键的
【EF Core】将一个实体映射到多个表的正确方法
东邪独孤 · 2025-12-26 · via 博客园 - 东邪独孤

把一个实体类型映射到多个表,官方叫法是 Entity splitting,这个称呼有点难搞,要是翻译为“实体拆分”或“拆分实体”,你第一感觉会不会认为是把一个表拆分为多个实体的意思。可它的含义是正好相反。为了避免大伙伴们产生误解,老周直接叫它“一个实体映射到多个表”,虽然不言简,但很意赅。

把一个实体类对应到数据库中的多个表,本质上是啥呢?一对一,是不是?举个例子,看图。

image

恭喜你猜对了,正如上图所示,假设老周收了几个徒弟,上述三个表其实都是【学生】实体类拆开的。第一个表是学生的基础信息,第二个表是补充信息,第三个表是学生的联系方式。第二、三个表中的行必须与第一个表中的行一一对应。

基于这样的理解,咱们可以得出:第一个表有主键A,第二个表有个外键FA引用主键A,第三个表有个外键FB引用主键A。同时,考虑到第二、三个表中的数据是完全依赖第一个表的,所以,第二、三个表中可以把主键和外键设定为同一个列。说人话就是有一列既做当前表的主键,也做外键引用第一个表。这使得第二、三个表中每一条记录的主键列的值必须与第一个表中的主键列相同。

image

下面咱们举个例子说明一下。假设有这样一个实体。

/// <summary>
/// 宠物
/// </summary>
public class Pet
{
    /// <summary>
    /// 主键
    /// </summary>
    public int PetId { get; set; }
    /// <summary>
    /// 昵称
    /// </summary>
    public string NickName { get; set; } = "天外物种";
    /// <summary>
    /// 体重
    /// </summary>
    public float? Weight { get; set; }
    /// <summary>
    /// 体长
    /// </summary>
    public int? Length { get; set; }
    /// <summary>
    /// 毛色
    /// </summary>
    public string? Color { get; set; }
    /// <summary>
    /// 分类
    /// </summary>
    public string? Category { get; set; }
    /// <summary>
    /// 爱好
    /// </summary>
    public string[] Hobbies { get; set; } = [];
    /// <summary>
    /// 性格
    /// </summary>
    public string? Temperament { get; set; }
}

于是我有个想法,把这个实体映射到一个表中好像太长,拆开为三个表多好。

1、基本信息。ID,名称,宠物类别;

2、基础特征。毛色,体长体重等;

3、额外信息。爱好,性格等。

一、错误用法

脑细胞活跃的大伙伴们可能想到了怎么做了,于是:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Pet>(entity =>
    {
        // 文本类型的配置一下长度,不然全是 MAX 也不划算
        entity.Property(d => d.NickName).HasMaxLength(20);
        entity.Property(d => d.Color).HasMaxLength(12);
        entity.Property(d => d.Category).HasMaxLength(15);
        entity.Property(d => d.Hobbies).HasMaxLength(100);
        entity.Property(d => d.Temperament).HasMaxLength(30);
        // 给主键命个名
        entity.HasKey(d => d.PetId).HasName("PK_my_pet");

        entity.ToTable("tb_pet", tb =>
        {
            tb.Property(x => x.PetId).HasColumnName("pet_id");
            tb.Property(x => x.NickName).HasColumnName("name");
            tb.Property(x => x.Category).HasColumnName("cate");
        });
        entity.ToTable("tb_pet_chars", tb =>
        {
            tb.Property(p => p.PetId).HasColumnName("_pid");
            tb.Property(p => p.Weight).HasColumnName("weight");
            tb.Property(p => p.Length).HasColumnName("len");
            tb.Property(p => p.Color).HasColumnName("fur_color");
        });
        entity.ToTable("tb_pet_other", tb =>
        {
            tb.Property(x => x.PetId).HasColumnName("_pid");
            tb.Property(x => x.Temperament).HasColumnName("tempera");
            tb.Property(x => x.Hobbies).HasColumnName("hobbies");
        });

        // 配置外键
        entity.HasOne<Pet>()
                 .WithOne()
                 .HasForeignKey<Pet>(p => p.PetId)
                 .HasConstraintName("FK_petid");
    });
}

映射了三个表,最后创建一个外键,指向主键——自己引用自己。代码看着挺合理,但运行会报错。

image

错误是在模型验证过程中发生的,即验证失败。该异常是在 RelationalModelValidator 类的 ValidatePropertyOverrides 方法中抛出的,咱们进去看看源代码。

protected virtual void ValidatePropertyOverrides(
    IModel model,
    IDiagnosticsLogger<DbLoggerCategory.Model.Validation> logger)
{
    foreach (var entityType in model.GetEntityTypes())
    {
        foreach (var property in entityType.GetDeclaredProperties())
        {
            var storeObjectOverrides = RelationalPropertyOverrides.Get(property);
            if (storeObjectOverrides == null)
            {
                continue;
            }

            foreach (var storeObjectOverride in storeObjectOverrides)
            {
                if (GetAllMappedStoreObjects(property, storeObjectOverride.StoreObject.StoreObjectType)
                    .Any(o => o == storeObjectOverride.StoreObject))
                {
                    continue;
                }

                var storeObject = storeObjectOverride.StoreObject;
                switch (storeObject.StoreObjectType)
                {
                    case StoreObjectType.Table:
                        throw new InvalidOperationException(
                            RelationalStrings.TableOverrideMismatch(
                                entityType.DisplayName() + "." + property.Name,
                                storeObjectOverride.StoreObject.DisplayName()));
                    case StoreObjectType.View:
                        throw new InvalidOperationException(
                            RelationalStrings.ViewOverrideMismatch(
                                entityType.DisplayName() + "." + property.Name,
                                storeObjectOverride.StoreObject.DisplayName()));
                    case StoreObjectType.SqlQuery:
                        throw new InvalidOperationException(
                            RelationalStrings.SqlQueryOverrideMismatch(
                                entityType.DisplayName() + "." + property.Name,
                                storeObjectOverride.StoreObject.DisplayName()));
                    case StoreObjectType.Function:
                        throw new InvalidOperationException(
                            RelationalStrings.FunctionOverrideMismatch(
                                entityType.DisplayName() + "." + property.Name,
                                storeObjectOverride.StoreObject.DisplayName()));
                    case StoreObjectType.InsertStoredProcedure:
                    case StoreObjectType.DeleteStoredProcedure:
                    case StoreObjectType.UpdateStoredProcedure:
                        throw new InvalidOperationException(
                            RelationalStrings.StoredProcedureOverrideMismatch(
                                entityType.DisplayName() + "." + property.Name,
                                storeObjectOverride.StoreObject.DisplayName()));
                    default:
                        throw new NotSupportedException(storeObject.StoreObjectType.ToString());
                }
            }
        }
    }
}

上面源代码中高亮部分就是抛出异常的地方。有大伙伴会说:老周你这是瞎扯啊,把一个实体映射到多个表,在官方文档上就有,只要看过文档的都不会犯这个错误。老周为了介绍其背后的知识,所以故意虚构了这个故事嘛。

好了,咱们简单说说原因。这里有一个概念,叫做 Property Override。说人话就是实体属性到数据列的映射可以存在覆盖关系。通常,咱们通过 PropertyBuilder 配置的列名、列的数据类型等是调用扩展方法 HasColumnXXXXX,例如

modelBuilder.Entity<Pet>(entity =>
{
    entity.Property(c => c.PetId).HasColumnName("pet_id");

    ……
});

实际上它是在代表属性的元数据上直接添加名为 Relational:ColumnName 的 Annotation(这个可以翻译为“注释”)。Annotations 本质上是一个以字符串为 key,以 object 为 value 的字典结构。EF Core 中许多元数据都是用 Annotation 的方式存储的。再比如,你在 EntityTypeBuilder 上调用 ToTable 扩展方法,所配置的数据表名称,是以 Relational:TableName 的Key存入 Annotation 字典中的。就像这样

Model:
  EntityType: Pet
    Properties:
      PetId (int) Required PK FK AfterSave:Throw ValueGenerated.OnAdd
        Annotations:
          Relational:ColumnName: pet_id
          SqlServer:ValueGenerationStrategy: IdentityColumn
      Category (string) MaxLength(15)
        Annotations:
          MaxLength: 15
          SqlServer:ValueGenerationStrategy: None
      Color (string) MaxLength(12)
        Annotations:
          MaxLength: 12
          SqlServer:ValueGenerationStrategy: None
      Hobbies (string[]) Required MaxLength(100) Element type: string Required
        Annotations:
          ElementType: Element type: string Required
          MaxLength: 100
          SqlServer:ValueGenerationStrategy: None
      Length (int?)
        Annotations:
          SqlServer:ValueGenerationStrategy: None
      NickName (string) Required MaxLength(20)
        Annotations:
          MaxLength: 20
          SqlServer:ValueGenerationStrategy: None
      Temperament (string) MaxLength(30)
        Annotations:
          MaxLength: 30
          SqlServer:ValueGenerationStrategy: None
      Weight (float?)
        Annotations:
          SqlServer:ValueGenerationStrategy: None
    Keys:
      PetId PK
        Annotations:
          Relational:Name: PK_my_pet
    Foreign keys:
      Pet {'PetId'} -> Pet {'PetId'} Unique Required Cascade
        Annotations:
          Relational:Name: FK_petid
    Annotations:
      Relational:FunctionName:
      Relational:Schema:
      Relational:SqlQuery:
      Relational:TableName: Pet
      Relational:ViewName:
      Relational:ViewSchema:
Annotations:
  ProductVersion: 10.0.1
  Relational:MaxIdentifierLength: 128
  SqlServer:ValueGenerationStrategy: IdentityColumn

但是,在 ToTable 方法调用时,如果使用 TableBuilder 的 HasColumnName 方法所配置的列名,并不是保存到 key 为 Relational:ColumnName 的 Annotation 字典中的。咱们不妨验证一下。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Pet>(entity =>
    {
        ……

        entity.ToTable("tb_pet", tb =>
        {
            tb.Property(x => x.PetId).HasColumnName("pet_id");
            tb.Property(x => x.NickName).HasColumnName("name");
            tb.Property(x => x.Category).HasColumnName("cate");
        });
        ……
}

/*--------------------------------------------------------------------------------*/

using TestContext context = new();
// 获得设计时模型
IDesignTimeModel dsmodelsvc = context.GetService<IDesignTimeModel>();
IModel dsmodel = dsmodelsvc.Model;
// 枚举出每个实体,每个实体的属性中的 Annotations
foreach(var entity in dsmodel.GetEntityTypes())
{
    Console.WriteLine($"实体:{entity.DisplayName()}");
    foreach(var prop in entity.GetProperties())
    {
        Console.WriteLine($"  {prop.Name}的注释:");
        foreach(var anno in prop.GetAnnotations())
        {
            Console.WriteLine($"    {anno.Name}= {anno.Value}");
        }
    }
}

运行的结果如下:

实体:Pet
  PetId的注释:
    Relational:ColumnName= pet_id
    Relational:RelationalOverrides= Microsoft.EntityFrameworkCore.Metadata.StoreObjectDictionary`1[Microsoft.EntityFrameworkCore.Metadata.Internal.RelationalPropertyOverrides]
    SqlServer:ValueGenerationStrategy= IdentityColumn
  Category的注释:
    MaxLength= 15
    Relational:RelationalOverrides= Microsoft.EntityFrameworkCore.Metadata.StoreObjectDictionary`1[Microsoft.EntityFrameworkCore.Metadata.Internal.RelationalPropertyOverrides]
  Color的注释:
    MaxLength= 12
  Hobbies的注释:
    ElementType= Element type: string Required
    MaxLength= 100
    ValueConverter=
    ValueConverterType=
  …………

有没有发现多了个 Key 为 Relational:RelationalOverrides 的注释项?而且它是个 StoreObjectDictionary 类型的字典。它的声明如下:

public class StoreObjectDictionary<T> : Microsoft.EntityFrameworkCore.Metadata.IReadOnlyStoreObjectDictionary<T> where T : class

在这里,T 是 RelationalPropertyOverrides 类,这个类在用途上不对外公开(位于 Microsoft.EntityFrameworkCore.Metadata.Internal 命名空间),看命名空间就知道这货是和元数据有关的。其中,这个类公开了 SetColumnName 方法,设置的列名存放在 _columnName 字段中。

1、调用 EntityTypeBuilder 的 ToTable 扩展方法时,可得到 TableBuilder;

2、从 TableBuilder 的 Property 方法返回得到一个 ColumnBuilder 对象;

3、调用 ColumnBuilder 对象的 HasColumnName 方法,这个方法调用了上面 RelationalPropertyOverrides 类的 SetColumnName 方法。

所以,你每调用一次 ToTable 方法,并用 TableBuilder 对象配置一次列名,那么 StoreObjectDictionary 字典里就会多一个 RelationalPropertyOverrides 元素。

咱们继续实验,把前面的代码改一下,专门打印 RelationalOverrides 注释的内容。

#pragma warning disable EF1001

namespace WTF;

internal class Program
{
    static void Main(string[] args)
    {
        using TestContext context = new();
        // 获得设计时模型
        IDesignTimeModel dsmodelsvc = context.GetService<IDesignTimeModel>();
        IModel dsmodel = dsmodelsvc.Model;
        // 枚举出每个实体
        foreach(var entity in dsmodel.GetEntityTypes())
        {
            Console.WriteLine($"实体:{entity.DisplayName()}");
            foreach(var prop in entity.GetProperties())
            {
                var anno = prop.FindAnnotation(RelationalAnnotationNames.RelationalOverrides);
                var dics = anno?.Value as StoreObjectDictionary<RelationalPropertyOverrides>;
                if(dics != null)
                {
                    foreach(var item in dics.GetValues())
                    {
                        Console.WriteLine($"    {item.DebugView.LongView}");
                    }
                }
            }
        }
    }
}

先用 FindAnnotation 方法查找出各个属性中的 RelationalOverrides 注释,然后把注释的值转换为 StoreObjectDictionary<RelationalPropertyOverrides> 字典,最后枚举字典中的项。

运行结果如下:

实体:Pet
    Override: tb_pet ColumnName: pet_id
    Override: tb_pet ColumnName: cate
    Override: tb_pet ColumnName: name

如果调用 ToTable 方法映射三个表,RelationalOverrides 字典中的项就会增加。由于模型验证会导致异常,咱们写一个验证服务类,暂时忽略掉对属性覆盖的验证。

public class MyModelValidator : RelationalModelValidator
{
    // 构造函数的参数不用管,往基类传就是了,它是靠依赖注入取值的
    public MyModelValidator(
        ModelValidatorDependencies dependencies,
        RelationalModelValidatorDependencies relationalDependencies)
        : base(dependencies, relationalDependencies)
    {
    }

    // 重写需要忽略的成员
    protected override void ValidatePropertyOverrides(IModel model, IDiagnosticsLogger<DbLoggerCategory.Model.Validation> logger)
    {
        // 直接返回,不执行基类的代码
        return;
        //base.ValidatePropertyOverrides(model, logger);
    }
}

然后在数据库上下文类的 OnConfiguring 方法中替换默认服务。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer("server=...")
                            .ReplaceService<IModelValidator, MyModelValidator>();
}

在实际开发中可不要这么干,这样容易破坏原有的验证逻辑。

这时候我们让 Pet 实体映射成三个表。

entity.ToTable("tb_pet", tb =>
{
    tb.Property(x => x.PetId).HasColumnName("pet_id");
    tb.Property(x => x.NickName).HasColumnName("name");
    tb.Property(x => x.Category).HasColumnName("cate");
});
entity.ToTable("tb_pet_chars", tb =>
{
    tb.Property(p => p.PetId).HasColumnName("_pid");
    tb.Property(p => p.Weight).HasColumnName("weight");
    tb.Property(p => p.Length).HasColumnName("len");
    tb.Property(p => p.Color).HasColumnName("fur_color");
});
entity.ToTable("tb_pet_other", tb =>
{
    tb.Property(x => x.PetId).HasColumnName("_pid");
    tb.Property(x => x.Temperament).HasColumnName("tempera");
    tb.Property(x => x.Hobbies).HasColumnName("hobbies");
});

最后输出的 RelationalOverrides 如下:

实体:Pet
    Override: tb_pet ColumnName: pet_id
    Override: tb_pet_chars ColumnName: _pid
    Override: tb_pet_other ColumnName: _pid
    Override: tb_pet ColumnName: cate
    Override: tb_pet_chars ColumnName: fur_color
    Override: tb_pet_other ColumnName: hobbies
    Override: tb_pet_chars ColumnName: len
    Override: tb_pet ColumnName: name
    Override: tb_pet_other ColumnName: tempera
    Override: tb_pet_chars ColumnName: weight

这东西有点复杂,不知道各位看懂了没有。其实就是你调用 ToTable 方法时,如果用 TableBuilder.Property(...).HasColumnName(...) 等方法配置一次,就会在 Overrides 字典里添加一条记录。但是,这个覆盖只针对属性和列之间的映射,而不针对表的。啥意思呢,咱们继补充一下代码,打印出实体中 TableName 注释的值。

    static void Main(string[] args)
    {
        using TestContext context = new();
        // 获得设计时模型
        IDesignTimeModel dsmodelsvc = context.GetService<IDesignTimeModel>();
        IModel dsmodel = dsmodelsvc.Model;
        // 枚举出每个实体,每个实体的属性中的 Annotations
        foreach (var entity in dsmodel.GetEntityTypes())
        {
            var tbName = entity.FindAnnotation(RelationalAnnotationNames.TableName)?.Value as string;
            Console.Write($"实体:{entity.DisplayName()}");
            if (tbName is not (null or { Length: 0 }))
            {
                Console.Write("   表名:{0}\n", tbName);
            }
            else
            {
                Console.Write("\n");
            }
           ……
          }
     |

这里其实可以直接调用 GetTableName 方法获取表名的: entity.GetTableName()。

运行后输出的内容如下:

实体:Pet   表名:tb_pet_other
    Override: tb_pet ColumnName: pet_id
    Override: tb_pet_chars ColumnName: _pid
    Override: tb_pet_other ColumnName: _pid
    Override: tb_pet ColumnName: cate
    Override: tb_pet_chars ColumnName: fur_color
    Override: tb_pet_other ColumnName: hobbies
    Override: tb_pet_chars ColumnName: len
    Override: tb_pet ColumnName: name
    Override: tb_pet_other ColumnName: tempera
    Override: tb_pet_chars ColumnName: weight

咱们设置表名的顺序是 tb_pet -> tb_chars -> tb_other。而保存表名的就只有一个 Relational:TableName 的 key。也就是说,不管你调用多少次 ToTable 方法,不管你设置了多少个表名,Relational:TableName 键所对应的表名只能是一个——最后设置的那个,因为后面设置的值把旧值替换了。

这个东西不太好讲述,可能老周也讲得不清楚,所以有必总结一下,这个试验到底验证了什么。

1、ToTable 扩展方法设置的表名存到实体的 Relational:TableName 注释中,永远只保留最后设置的表名。

2、TableBuilder 所设置的列名,没有用 Relational:ColumnName 注释去保存,而是新加了一个 Relational:RelationalOverrids 注释,然后以字典形式存储所有覆盖内容,要注意的是,覆盖行为是基于属性,而不是实体的。比如上面例子中的 PetId 属性,它的第一个配置是映射到 tb_pet 表的 pet_id 列;第二个是映射到 tb_chars 表的 _pid 列;第三个是映射到 tb_other 表的 _pid 列。

那么,什么情况下会直接用 Relational:ColumnName 注释存储属性与列的映射呢?答案是调用 PropertyBuilder 的 HasColumnName 方法。就像这样:

modelBuilder.Entity<Pet>(entity =>
{
    entity.Property(c => c.PetId).HasColumnName("pet_id");
    ……
}

可见,这两处的 HasColumnName 方法是完全不一样的,再重复一遍,因为这个怕大伙伴们不好理解,老周只好多点F话。

1、PropertyBuilder.HasColumnName(通过 EntityTypeBuilder.Property(...))直接在属性元数据中写入 Relational:ColumnName 注释。因此,这个 HasColumnName 不管调用多少次,保留都是最后一个设置的值,和 TableName 一样。

2、ColumnBuilder.HasColumnName(通过 ToTable => TableBuilder.Property(...))是在属性元数据上写入 Relational:RelationalOverrides 注释,并且其值是字典集合,你每调用一次 ToTable 它就会往集合里增加一个子项,即属性的列配置可以被覆盖很多次。

到了这里,有大伙伴可能有点悟了,这样不合理啊,实体与表之间的映射应该是唯一的。正是,所以我们开头那个示例就报错了啊,模型验证失败了呢。老周之所以绕了个大圈,现在才解释为啥抛异常,是担心大伙伴们看不懂,只好先说一下原理。我们现在回过头,看看 ValidatePropertyOverrides 方法的源代码。

protected virtual void ValidatePropertyOverrides(
    IModel model,
    IDiagnosticsLogger<DbLoggerCategory.Model.Validation> logger)
{
    // 逐个实体检查
    foreach (var entityType in model.GetEntityTypes())
    {
        // 实体中逐个属性检查
        foreach (var property in entityType.GetDeclaredProperties())
        {
            // 这一行其实是返回 Relational:RelationalOverrides 注释的内容(字典)
            // 集合中所有 Override 对象
            var storeObjectOverrides = RelationalPropertyOverrides.Get(property);
            if (storeObjectOverrides == null)
            {
                continue;   // 如果没有,说明列的配置没有被覆盖
            }
            
            // 遍历所有的覆盖配置
            foreach (var storeObjectOverride in storeObjectOverrides)
            {
                // 这里实际上是根据当前属性,找到包含这个属性的实体
                // 再根据这个实体,得到它映射的表名,这里读的是 Relational:TableName 注释
                // 而现在我们用了三个 ToTable 方法,导致实体映射的表名是 tb_other
                // 而 Overrides 集合中,这个属性可能对应了 tb_pet 表或 tb_chars 表
                // Any(o => o == storeObjectOverride.StoreObject) 方法的调用就是用来比较 Overrides 中的表名和 TableName 注释中的表名是否相同
                if (GetAllMappedStoreObjects(property, storeObjectOverride.StoreObject.StoreObjectType)
                    .Any(o => o == storeObjectOverride.StoreObject))
                {
                    continue;   // 如果存在任意一条是相同的,说明表名一致,就不会报错
                }

                // 代码走到这里,就说明上面的验证失败了,两处表名不一致
                // StoreObjectType 只是表明出错的映射是面向数据表,还是表值函数,还是存储过程
                var storeObject = storeObjectOverride.StoreObject;
                switch (storeObject.StoreObjectType)
                {
                    case StoreObjectType.Table:
                        // 示例程序报错的就是这里
                        throw new InvalidOperationException(
                            RelationalStrings.TableOverrideMismatch(
                                entityType.DisplayName() + "." + property.Name,
                                storeObjectOverride.StoreObject.DisplayName()));
                    case StoreObjectType.View:
                        throw new InvalidOperationException(
                            RelationalStrings.ViewOverrideMismatch(
                                entityType.DisplayName() + "." + property.Name,
                                storeObjectOverride.StoreObject.DisplayName()));
                    ……
                }
            }
        }
    }
}

我们再看看前面实验代码输出的 overrides 列表。

实体:Pet   表名:Relational:TableName = tb_pet_other
    Override: tb_pet ColumnName: pet_id
    Override: tb_pet_chars ColumnName: _pid
    Override: tb_pet_other ColumnName: _pid
    Override: tb_pet ColumnName: cate
    Override: tb_pet_chars ColumnName: fur_color
    Override: tb_pet_other ColumnName: hobbies
    Override: tb_pet_chars ColumnName: len
    Override: tb_pet ColumnName: name
    Override: tb_pet_other ColumnName: tempera
    Override: tb_pet_chars ColumnName: weight

根据源代码,首先是枚举实体,这里只有一个 Pet,然后枚举属性,那第一个就是 PetId 属性,接着枚举 PetId 属性的 Overrides,有三个:

1、映射 tb_pet 表的 pet_id 列;

2、映射 tb_chars 表的 _pid 列;

3、映射 tb_other 表的 _pid 列。

但是,GetAllMappedStoreObjects 方法是根据属性来创建 StoreObjectIdentifier 列表的,在本例中,这个 Identifire 就是 tb_other,这个 foreach 循环的意思就是所有 Override 的属性的表名都必须是 tb_other,如果有一个不是,就抛异常。foreach 循环第一个配置的是 tb_pet 表与 pet_id 列,然而现在的表名是 tb_other,所以,第一轮就匹配失败了,就 throw 了。

这样就保证了一个实体只能 Map 一个表。

 二、正确用法

那么,EF Core 用什么办法把一个实体分散到多个表的?它很狡猾,一方面坚持一实体 Map 一表的原则,另一方面,它又提供一个叫“分片”(Fragment)的概念。实体映射的主表存储在 RelationalOverrides 注释中,而将其余分表存储在名为 Relational:MappingFragments 的注释中,同理,它也是一个字典集合—— StoreObjectDictionary<EntityTypeMappingFragment>。一个分片由 EntityTypeMappingFragment 类表示,对外暴露三个接口:IEntityTypeMappingFragment、IMutableEntityTypeMappingFragment 和 IConventionEntityTypeMappingFragment。即

public class EntityTypeMappingFragment :
    ConventionAnnotatable,
    IEntityTypeMappingFragment,
    IMutableEntityTypeMappingFragment,
    IConventionEntityTypeMappingFragment
{
      ……
}

配置分片表调用的是 SplitToTable 扩展方法。和 TableBuilder 一样,属性与列的映射可以覆盖,并保存到 RelationalOverrides 注释中,只不过多了个 MappingFragments 注释。但多了这个分片,在模型验证时就不同了,GetAllMappedStoreObjects 方法中会循环遍历 Fragments 集合,并返回集合中所有表名。

if (property.IsPrimaryKey())      // 对于主键
{
    // 这个是对非分片的表
    var declaringStoreObject = StoreObjectIdentifier.Create(property.DeclaringType, storeObjectType);
    if (declaringStoreObject != null)
    {
        yield return declaringStoreObject.Value;
    }
     // 表值函数,或数据来源于 SQL 查询,终止
    if (storeObjectType is StoreObjectType.Function or StoreObjectType.SqlQuery)
    {
        yield break;
    }

    // 这里就针对分片,分片集合中所有表名都返回
    foreach (var fragment in property.DeclaringType.GetMappingFragments(storeObjectType))
    {
        yield return fragment.StoreObject;
    }

    // 当前实体的派生类也要返回(TPT 或 TPC 映射方式)
    // 如果是 TPH 映射,基类子类都存放在一个表中,只返回一个
    if (property.DeclaringType is IReadOnlyEntityType entityType)
    {
        foreach (var containingType in entityType.GetDerivedTypes())
        {
            var storeObject = StoreObjectIdentifier.Create(containingType, storeObjectType);
            if (storeObject != null)
            {
                yield return storeObject.Value;

                // TPH 映射就是基类实体和它的派生类全存放在一个表中,并用一个专用列来标识类型,所以它不再需要返回其他表名,故中止
                if (mappingStrategy == RelationalAnnotationNames.TphMappingStrategy)
                {
                    yield break;
                }
            }
        }
    }
}
else               // 对于非主键
{
    // 获取当前属性中 TableName 注释所配置的表名,或默认表名
    var declaringStoreObject = StoreObjectIdentifier.Create(property.DeclaringType, storeObjectType);
     // 表值函数和SQL查询的结果不需要多个表
    if (storeObjectType is StoreObjectType.Function or StoreObjectType.SqlQuery)
    {
        if (declaringStoreObject != null)
        {
            yield return declaringStoreObject.Value;
        }

        yield break;
    }

    if (declaringStoreObject != null)
    {
        // 枚举所有分片
        var fragments = property.DeclaringType.GetMappingFragments(storeObjectType).ToList();
        if (fragments.Count > 0)
        {
            // 只要 Overrides 中的任意一列与分片中的表名匹配,都返回
            var overrides = RelationalPropertyOverrides.Find(property, declaringStoreObject.Value);
            if (overrides != null)
            {
                yield return declaringStoreObject.Value;
            }

            foreach (var fragment in fragments)
            {
                overrides = RelationalPropertyOverrides.Find(property, fragment.StoreObject);
                if (overrides != null)
                {
                    yield return fragment.StoreObject;
                }
            }

            yield break;
        }

        // 要是没有配置分片,说明只映射一个表,返回它
        yield return declaringStoreObject.Value;
        if (mappingStrategy != RelationalAnnotationNames.TpcMappingStrategy)
        {
            yield break;
        }
    }

    if (property.DeclaringType is not IReadOnlyEntityType entityType)
    {
        yield break;
    }

    // 对于当前实体的派生类
    // 1、如果是TPH映射模式,那么全程只用一个表,所以只返回一个就够了
    // 2、TPC模式即每个派生类都要有一个表,所以全部返回
    var tableFound = false;
    var queue = new Queue<IReadOnlyEntityType>();
    queue.Enqueue(entityType);
    while (queue.Count > 0 && !tableFound)
    {
        // 枚举直接派生类,不含间接子类
        // TPC模式下,当前实体可能是抽象类
        foreach (var containingType in queue.Dequeue().GetDirectlyDerivedTypes())
        {
            // 获取派生类实体配置的表名
            var storeObject = StoreObjectIdentifier.Create(containingType, storeObjectType);
            if (storeObject != null)
            {
                yield return storeObject.Value;      // 至少返回一个
                tableFound = true;
                // TPH 映射模式下只需要一个表就行了,所以 break
                if (mappingStrategy == RelationalAnnotationNames.TphMappingStrategy)
                {
                    yield break;
                }
            }

            // 如果是 TPC 模式且找不到被映射的表,此时 containingType 可能是抽象类
            // 把抽象类扔回队列中,下一轮循环继续撸它的派生类
            if (!tableFound
                || mappingStrategy == RelationalAnnotationNames.TpcMappingStrategy)
            {
                queue.Enqueue(containingType);
            }
        }
    }
}

经过这么一处理,在 ValidatePropertyOverrides 方法中,只要任意一个 Override 的列的表名和分片中的表名匹配,就验证成功。这么一搞,就做到了一个实体可以 Map 多个表了。

于是,数据库上下文类里面,OnModelCreating 方法的代码你应该知道怎么改了吧。

modelBuilder.Entity<Pet>(entity =>
{
    entity.Property(c => c.PetId).HasColumnName("pet_id");
    // 文本类型的配置一下长度,不然全是 MAX 也不划算
    entity.Property(d => d.NickName).HasMaxLength(20);
    entity.Property(d => d.Color).HasMaxLength(12);
    entity.Property(d => d.Category).HasMaxLength(15);
    entity.Property(d => d.Hobbies).HasMaxLength(100);
    entity.Property(d => d.Temperament).HasMaxLength(30);
    // 给主键命个名
    entity.HasKey(d => d.PetId).HasName("PK_my_pet");

    // 第一个表是主表,配置不变
    entity.ToTable("tb_pet", tb =>
    {
        tb.Property(x => x.PetId).HasColumnName("pet_id");
        tb.Property(x => x.NickName).HasColumnName("name");
        tb.Property(x => x.Category).HasColumnName("cate");
    });
    // 第二个表
    entity.SplitToTable("tb_pet_chars", tb =>
    {
        tb.Property(p => p.PetId).HasColumnName("_pid");
        tb.Property(p => p.Weight).HasColumnName("weight");
        tb.Property(p => p.Length).HasColumnName("len");
        tb.Property(p => p.Color).HasColumnName("fur_color");
    });
    // 第三个表
    entity.SplitToTable("tb_pet_other", tb =>
    {
        tb.Property(x => x.PetId).HasColumnName("_pid");
        tb.Property(x => x.Temperament).HasColumnName("tempera");
        tb.Property(x => x.Hobbies).HasColumnName("hobbies");
    });

    // 配置外键
    entity.HasOne<Pet>()
             .WithOne()
             .HasForeignKey<Pet>(p => p.PetId)
             .HasConstraintName("FK_petid");
});

第一个表是主表,ToTable 保持不变;第二、三个表调用 SplitToTable 方法,列映射不需要改。

现在,把前面咱们替换的 IModelValidator 接口还原。在 OnConfiguring 方法中删除 ReplaceService 方法的调用。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer("server=...");
                            //.ReplaceService<IModelValidator, MyModelValidator>();
}

重新运行示例,现在不会报错了。也可以用以下代码打印一下各个分片的信息。

internal class Program
{
    static void Main(string[] args)
    {
        using TestContext context = new();
        // 获得设计时模型
        IDesignTimeModel dsmodelsvc = context.GetService<IDesignTimeModel>();
        IModel dsmodel = dsmodelsvc.Model;
        // 枚举出每个实体,每个实体的属性中的 Annotations
        foreach (var entity in dsmodel.GetEntityTypes())
        {
            // 获取表名也可以不用查找 TableName 注释,直接用 GetTableName 方法即可
            var tbName = entity.GetTableName();
            Console.Write($"实体:{entity.DisplayName()}");
            if (tbName is not (null or { Length: 0 }))
            {
                Console.Write("   表名:{0}\n",  tbName);
            }
            else
            {
                Console.Write("\n");
            }
            foreach (var prop in entity.GetProperties())
            {
                // 打印 overrides 的更简单方法,不用查找 RelationalOverrides 注释
                var overrides = prop.GetOverrides();
                foreach(var ovr in overrides)
                {
                    Console.WriteLine($"  {ovr.ToDebugString()}");
                }
            }
            // 打印分片
            Console.WriteLine("\n  分片:");
            foreach(var fragment in entity.GetMappingFragments())
            {
                Console.WriteLine($"    {fragment.ToDebugString()}");
            }
        }
    }
}

由于 EF 有相关的扩展方法,其实咱们不需要去手动查找注释的,如 GetTableName 方法获取表名,GetOverrides 方法获属性的覆盖配置,GetMappingFragments 方法获取分片列表。

再次运行示例,结果如下:

实体:Pet   表名:tb_pet
  Override: tb_pet ColumnName: pet_id
  Override: tb_pet_chars ColumnName: _pid
  Override: tb_pet_other ColumnName: _pid
  Override: tb_pet ColumnName: cate
  Override: tb_pet_chars ColumnName: fur_color
  Override: tb_pet_other ColumnName: hobbies
  Override: tb_pet_chars ColumnName: len
  Override: tb_pet ColumnName: name
  Override: tb_pet_other ColumnName: tempera
  Override: tb_pet_chars ColumnName: weight

  分片:
    Fragment: tb_pet_chars
    Fragment: tb_pet_other

咱们不妨获取一下创建数据表的 SQL 语句,检查一下是否正确。在 Main 方法结束之前放入以下代码。

string sql = context.Database.GenerateCreateScript();
Console.WriteLine("\n\n创建数据表SQL:\n{0}", sql);

生成的 SQL 语句如下:

CREATE TABLE [tb_pet] (
    [pet_id] int NOT NULL IDENTITY,
    [name] nvarchar(20) NOT NULL,
    [cate] nvarchar(15) NULL,
    CONSTRAINT [PK_my_pet] PRIMARY KEY ([pet_id])
);
GO


CREATE TABLE [tb_pet_chars] (
    [_pid] int NOT NULL,
    [weight] real NULL,
    [len] int NULL,
    [fur_color] nvarchar(12) NULL,
    CONSTRAINT [PK_my_pet] PRIMARY KEY ([_pid]),
    CONSTRAINT [FK_petid] FOREIGN KEY ([_pid]) REFERENCES [tb_pet] ([pet_id]) ON DELETE CASCADE
);
GO


CREATE TABLE [tb_pet_other] (
    [_pid] int NOT NULL,
    [hobbies] nvarchar(100) NOT NULL,
    [tempera] nvarchar(30) NULL,
    CONSTRAINT [PK_my_pet] PRIMARY KEY ([_pid]),
    CONSTRAINT [FK_petid] FOREIGN KEY ([_pid]) REFERENCES [tb_pet] ([pet_id]) ON DELETE CASCADE
);
GO

所有表的主键名称都统一为咱们所配置的 PK_my_pet。只有主表 tb_pet 的主键使用 IDENTITY 生成标识,其他的分表不使用自动生成,而是与主表相同的主键值。同时,分表都有一个外键 FK_petid,引用主表的主键。这个外键对应的列同时也是当前分表的主键。

这样可以保证在数据操作中,三个表的状态能保持一致。

好了,今天就聊到这儿了。这次的内容有点复杂,可能不太好懂,老周也没法保证能讲明白。如果弄不懂也不要紧,会用 SplitToTable 来拆表就行。