【EF Core】将一个实体映射到多个表的正确方法

把一个实体类型映射到多个表,官方叫法是 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 来拆表就行。

 

posted @ 2025-12-26 12:32  东邪独孤  阅读(194)  评论(0)    收藏  举报