【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 来拆表就行。

 

<think>好的,我现在需要帮助用户了解如何使用EntityFramework Core将数据库映射实体类。首先,我得回忆一下EFCore的基础知识,特别是关于实体映射的部分。用户可能已经知道一些基本概念,但需要具体的步骤和示例。根据用户提供的引用,特别是引用[3],里面提到从数据库反向生成模型,这可能涉及到Scaffold-DbContext命令。所以第一步应该是使用这个命令根据现有数据库生成实体类和DbContext。这应该是一个关键点,需要详细说明。接下来,用户可能需要知道如何手动创建实体类,比如当没有现有数据库时。这时候需要解释如何通过属性配置和数据注解来映射结构,比如[Table]和[Column]属性。同时,Fluent API也是重要的配置方式,尤其是在更复杂的映射情况下,比如主键、外键和索引的设置。 然后,示例代码会很有帮助。用户可能需要看到具体的C#类示例,比如Product类对应数据库,以及如何在DbContext中配置DbSet。同时,配置部分需要展示两种方式:数据注解和Fluent API,让用户了解不同的方法。 另外,用户可能关心如何处理关系,比如一对一、一对多或多对多。这部分需要简要提及,但可能不需要深入,除非用户特别问到。不过考虑到用户的问题是关于映射,可能暂时不需要深入关系,但可以提到配置导航属性。最后,常见问题部分应该包括是否需要手动创建、如何更新模型以及性能注意事项。这些是实际开发中容易遇到的问题,提前解答可以节省用户时间。 现在需要检查是否覆盖了所有关键步骤,并且示例是否清晰。同时,确保引用了正确的来源,比如引用[3]提到的反向生成,引用[4]提到的映射工具,但用户的问题可能更关注基础映射,所以重点在引用[3]和EFCore官方文档。</think>以下是使用Entity Framework Core将数据库映射实体类的详细方法和示例: ### 一、核心映射方法 #### 1. 通过数据库生成实体类(Database First) 使用`Scaffold-DbContext`命令从现有数据库反向生成实体类: ```powershell Scaffold-DbContext "Server=localhost;Database=mydb;User=root;Password=123456;" Pomelo.EntityFrameworkCore.MySql -OutputDir Models ``` 这会自动完成: - 创建与结构对应的实体类(如`Product.cs`) - 生成继承`DbContext`的数据库上下文类 - 自动配置主键、字段类型和关系映射[^3] #### 2. 手动创建实体类(Code First) (1) 基础字段映射示例: ```csharp [Table("products")] // 指定对应名 public class Product { [Column("product_id")] // 指定列名 public int Id { get; set; } [Column("product_name", TypeName = "varchar(100)")] public string Name { get; set; } [Column("unit_price", TypeName = "decimal(18,2)")] public decimal Price { get; set; } } ``` (2) 上下文类配置: ```csharp public class AppDbContext : DbContext { public DbSet<Product> Products { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseMySql("your_connection_string"); } ``` ### 二、高级映射配置 #### 1. Fluent API配置(推荐方式) ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>(entity => { entity.ToTable("products"); entity.HasKey(e => e.Id).HasName("pk_product"); entity.Property(e => e.Id).HasColumnName("product_id"); entity.Property(e => e.Name) .HasColumnName("product_name") .HasMaxLength(100); }); } ``` #### 2. 关系映射示例(一对多) ```csharp public class Order { public int OrderId { get; set; } public List<OrderItem> Items { get; set; } // 导航属性 } public class OrderItem { public int OrderItemId { get; set; } public int OrderId { get; set; } // 外键 public Order Order { get; set; } // 导航属性 } ``` ### 三、最佳实践建议 1. **命名规范**: - 实体类使用单数形式(Product) - 数据库名使用复数形式(products) - 主键字段建议命名为`Id`或`[EntityName]Id` 2. **类型映射参考**: | C#类型 | MySQL类型 | |----------------|----------------| | string | varchar(n) | | decimal | decimal(18,2) | | DateTime | datetime | | bool | tinyint(1) | 3. **自动包含属性**: ```csharp [NotMapped] // 排除映射字段 public string DisplayName => $"{Name} ({Price:C})"; ``` ### 四、常见问题解决 1. **名不符合约定**: - 使用`[Table("custom_table")]`特性 - 或通过Fluent API的`.ToTable()`方法 2. **字段类型不匹配**: ```csharp [Column(TypeName = "text")] // 指定精确类型 public string Description { get; set; } ``` 3. **复合主键配置**: ```csharp modelBuilder.Entity<OrderItem>() .HasKey(e => new { e.OrderId, e.ItemId }); ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值