把一個實體類型映射到多個表,官方叫法是 Entity splitting,這個稱呼有點難搞,要是翻譯為“實體拆分”或“拆分實體”,你第一感覺會不會認為是把一個表拆分為多個實體的意思。可它的含義是正好相反。為了避免大夥伴們產生誤解,老周直接叫它“一個實體映射到多個表”,雖然不言簡,但很意賅。

把一個實體類對應到數據庫中的多個表,本質上是啥呢?一對一,是不是?舉個例子,看圖。

EF單實對應多表_外鍵

恭喜你猜對了,正如上圖所示,假設老周收了幾個徒弟,上述三個表其實都是【學生】實體類拆開的。第一個表是學生的基礎信息,第二個表是補充信息,第三個表是學生的聯繫方式。第二、三個表中的行必須與第一個表中的行一一對應。

基於這樣的理解,咱們可以得出:第一個表有主鍵A,第二個表有個外鍵FA引用主鍵A,第三個表有個外鍵FB引用主鍵A。同時,考慮到第二、三個表中的數據是完全依賴第一個表的,所以,第二、三個表中可以把主鍵和外鍵設定為同一個列。説人話就是有一列既做當前表的主鍵,也做外鍵引用第一個表。這使得第二、三個表中每一條記錄的主鍵列的值必須與第一個表中的主鍵列相同。

EF單實對應多表_主鍵_02

 

下面咱們舉個例子説明一下。假設有這樣一個實體。

/// <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");
    });
}

映射了三個表,最後創建一個外鍵,指向主鍵——自己引用自己。代碼看着挺合理,但運行會報錯。

EF單實對應多表_ide_03

錯誤是在模型驗證過程中發生的,即驗證失敗。該異常是在 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 來拆表就行。