動態

詳情 返回 返回

【EF Core】“多對多”關係與跳躍導航 - 動態 詳情

“多對多”關係不像“一對多”那麼“單純”,它內部涉及到“連接實體”(Join Entity)的概念。咱們先放下這個概念不表,來了解一下多對多數據表為什麼需要一個“輔助表”來建立關係。

假設有兩張表:一張表示學生,一張表示選修課。那麼,這裏頭的關係是你可以選多門課,而一門課可以被多人選。這是多對多關係,沒問題吧。

image

按照數據庫存儲的原則,學生表中每位學生的信息都不應重複,而課程表也是如此。這麼一看,多對多的關係不能直接在這兩個表中創建了。

那就只能引入第三個表,專門保存前兩個表的信息了。

image

經過這樣處理後,多對多的關係被拆解成兩個一對多關係:

左邊:學生(1)--- 中間表(N);

右邊:課程(1)--- 中間表(N)。

這個中間表負責”連接“兩個數據表。轉換為實體類開,這個中間表就是”連接實體“了。

------------------------------------------------------------------------------------------------------------------------

接下來先弄個開胃菜,一個很簡單的例子

1、定義實體。

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string Code { get; set; } = null!;
    public string? Email { get; set; }

    // 注意這個屬性
    public IList<Course> SelectedCourses { get; set; } = new List<Course>();
}

public class Course
{
    public Guid Id { get; set; }
    public string Name { get; set; } = null!;
    public string? Tags { get; set; }

    // 注意這個屬性
    public IList<Student> Students { get; set; } = new List<Student>();
}

實體類沒什麼,就是一個學生類,一個課程類。不過,請留意一下被標記的屬性,後面會考。

2、定義數據庫上下文。

public class TestContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder ob)
    {
        ob.UseSqlServer("server=(localdb)\\mssqllocaldb;database=MySchool");
    }

    #region 數據集合
    public DbSet<Student> StudentSet { get; set; }
    public DbSet<Course> CourseSet { get; set; }
    #endregion
}

上下文這樣就可以了,這裏可以不寫配置數據庫模型的代碼,因為 EF Core 內置的約定類會幫我們自動完成。

a、通過 DbContext 或子類定義的 DbSet 類型的屬性,自動向模型添加 Student、Course 實體;

b、通過上面標記的特殊屬性(你看,考點來了),自動識別出這是多對多的關係。

      Student 類的 SelectedCourses 屬性導航到 Course;

      Course 類的 Students 屬性導航到 Student。

兩個導航屬性都是集合類型,因此兩者的關係是多對多。此處,SelectedCourses 和 Students 屬性有個專用名字,叫“跳躍導航”(Skip Navigation)。這裏不應該翻譯為“跳過導航”,因為那樣翻譯意思就不太好理解,所以應取“跳躍”。

解釋一下為什麼會跳躍。還記得前文的分析嗎?兩個表如果是多對多關係,那麼它們需要一個“連接”表來存儲對應關係。也就是説,正常情況下,Student 類的導航屬性應該指向中間實體(映射到連接表),Course 實體的導航屬性也應該指向中間實體,再通過中間實體把二者連接起來。可是我們再回頭看看示例,Student 的導航屬性直接指向了 Course,而 Course 實體的導航屬性也直接指向了 Student 實體。即它們都跨過(跳過)中間實體,兩者直接連接起來了

老周畫了一個不專業的簡圖。

image

這裏也產生了一個疑問:我們沒創建中間實體啊,難道是 EF Core 幫我們創建了?還真是,不妨打印一下數據庫模型。

static void Main(string[] args)
{
    using var context = new TestContext();
    // 獲取數據庫模型
    IModel model = context.Model;
    // 打印
    Console.WriteLine(model.ToDebugString());
}

然後,運行代碼,看看輸出什麼。

Model: 
  EntityType: Course
    Properties: 
      Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Name (string) Required
      Tags (string)
    Skip navigations:
      Students (IList<Student>) CollectionStudent Inverse: SelectedCourses
    Keys:
      Id PK
  EntityType: CourseStudent (Dictionary<string, object>) CLR Type: Dictionary<string, object>
    Properties:
      SelectedCoursesId (no field, Guid) Indexer Required PK FK AfterSave:Throw
      StudentsId (no field, int) Indexer Required PK FK Index AfterSave:Throw
    Keys:
      SelectedCoursesId, StudentsId PK
    Foreign keys:
      CourseStudent (Dictionary<string, object>) {'SelectedCoursesId'} -> Course {'Id'} Required Cascade   
      CourseStudent (Dictionary<string, object>) {'StudentsId'} -> Student {'Id'} Required Cascade
    Indexes:
      StudentsId
  EntityType: Student
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Code (string) Required
      Email (string)
      Name (string) Required
    Skip navigations:
      SelectedCourses (IList<Course>) CollectionCourse Inverse: Students
    Keys:
      Id PK

有沒有發現多了一個實體,叫 CourseStudent。雖然我們在代碼中沒有定義這樣的類,但 EF Core 的 ManyToManyJoinEntityTypeConvention 約定類會自動給數據庫模型添加一個實體,類型是共享的 Dictionary<string, object>。這可是個萬能實體類型,當你不想給項目定義一堆實體類時,你甚至可以把所有實體全註冊為字典類型。當然,這樣做對於面向對象,對閲讀你代碼的人來説就不友好了。

 protected virtual void CreateJoinEntityType(
     string joinEntityTypeName,
     IConventionSkipNavigation skipNavigation)
 {
     var model = skipNavigation.DeclaringEntityType.Model;

     // DefaultPropertyBagType 就是字典類型
     var joinEntityTypeBuilder = model.Builder.SharedTypeEntity(joinEntityTypeName, Model.DefaultPropertyBagType)!;

     var inverseSkipNavigation = skipNavigation.Inverse!;
     CreateSkipNavigationForeignKey(skipNavigation, joinEntityTypeBuilder);
     CreateSkipNavigationForeignKey(inverseSkipNavigation, joinEntityTypeBuilder);
 }
    

可以看看 DefaultPropertyBagType 字段在 Model 類中的定義(Model 類從用途上不對外公開,但類本身是 public 的)。

public static readonly Type DefaultPropertyBagType = typeof(Dictionary<string, object>);

那這個自動添加的中間實體怎麼命名呢?繼續看源代碼。

protected virtual string GenerateJoinTypeName(IConventionSkipNavigation skipNavigation)
{
    var inverseSkipNavigation = skipNavigation.Inverse;
    Check.DebugAssert(
        inverseSkipNavigation?.Inverse == skipNavigation,
        "Inverse's inverse should be the original skip navigation");

    var declaringEntityType = skipNavigation.DeclaringEntityType;
    var inverseEntityType = inverseSkipNavigation.DeclaringEntityType;
    var model = declaringEntityType.Model;
    var joinEntityTypeName = !declaringEntityType.HasSharedClrType
        ? declaringEntityType.ClrType.ShortDisplayName()
        : declaringEntityType.ShortName();
    var inverseName = !inverseEntityType.HasSharedClrType
        ? inverseEntityType.ClrType.ShortDisplayName()
        : inverseEntityType.ShortName();
    joinEntityTypeName = StringComparer.Ordinal.Compare(joinEntityTypeName, inverseName) < 0
        ? joinEntityTypeName + inverseName
        : inverseName + joinEntityTypeName;

    if (model.FindEntityType(joinEntityTypeName) != null)
    {
        var otherIdentifiers = model.GetEntityTypes().ToDictionary(et => et.Name, _ => 0);
        joinEntityTypeName = Uniquifier.Uniquify(
            joinEntityTypeName,
            otherIdentifiers,
            int.MaxValue);
    }

    return joinEntityTypeName;
}

亂七八糟一大段,總結起來就是:

1、分別獲取跳躍導航兩端的類型,即多對多關係中的兩實體(Student 和 Course);

2、將兩實體的名稱按字符排序,排在前面的作為前半段名字,排序在後面的作為後半段名字。比如,Student 與 Course 排序,字母 C 在 S 前面,所以,中間實體的名字就是 CourseStudent;

3、向中間實體添加兩個屬性,兩個屬性共同構成主鍵。同時,它們也是外鍵,一個指向 Student,一個指向 Course。即這兩個屬性同時是主鍵和外鍵。

從中間實體到 Student 的導航叫“左邊”,從中間實體到 Course 實體的導航叫 “右邊”。

 

如果咱們不想用 EF Core 約定的中間實體,也可以自己去定義。

public class StudentCourseJoin
{
    public Student TheStudent { get; set; } = null!;
    public Course TheCourse { get; set; } = null!;
}

有大夥伴會説了,你這實體沒有作為外鍵的屬性啊。沒事,外鍵屬性可以作為影子屬性(Shadow Property)來添加,反正有 TheStudent 等導航屬性,不需要藉助外鍵屬性也可以引用其實體。

下面是非常複雜的配置代碼,各位可以先讓時間停止然後慢慢看。

public class TestContext : DbContext
{
    ……

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Student>()
                // 一個學生選多門課
                .HasMany(s => s.SelectedCourses)
                // 一門課多位學生選
                .WithMany(c => c.Students)
                // 中間實體
                .UsingEntity<StudentCourseJoin>(
                    // 右邊:StudentCourseJoin >>> Course
                    // 一個 StudentCourseJoin 只引用一個 Course
                    right => right.HasOne(e => e.TheCourse)
                                // 一個Course可引用多個StudentCourseJoin
                                // 但此處省略了
                                .WithMany()
                                // 外鍵
                                .HasForeignKey("Course_ID"),
                    // 左邊:StudentCourseJoin >>> Student
                    // 一個StudentCourseJoin引用一個Student
                    left => left.HasOne(e => e.TheStudent)
                                // 一個Student可引用多個StudentCourseJoin
                                // 但這裏省略了
                                .WithMany()
                                // 外鍵
                                .HasForeignKey("Student_ID"),

                    ent =>
                    {
                        // 因為這兩個是影子屬性,必須顯式配置
                        // 否則找不到屬性,會報錯
                        ent.Property<int>("Student_ID");
                        ent.Property<Guid>("Course_ID");
                        // 兩個屬性都是主鍵
                        ent.HasKey("Student_ID", "Course_ID");
                    }
                );
    }
}

最外層調用 modelBuilder.Entity<Student>() 的代碼就是配置 Student 和 Course 的關係的,相信各位都懂的。複雜的部分是 UsingEntity 方法開始的,配置中間實體(連接實體)的。

首先,咱們把中間實體的關係拆開:

A、Student 對中間實體:一對多,左邊;

B、Course 對中間實體:一對多,右邊。

所以,UsingEntity 方法的第一個委託配置右邊。

right => right.HasOne(e => e.TheCourse)
            // 一個Course可引用多個StudentCourseJoin
            // 但此處省略了
            .WithMany()
            // 外鍵
            .HasForeignKey("Course_ID")

不要問為什麼,因為微軟定義這個方法就是先右後左的。HasOne 就是從中間實體(StudentCourseJoin)出發,它引用了幾個 Course?一個吧,嗯,所以是One嘛;然後 WithMany 反過來,Curse 可以引用幾個中間實體?多個吧(不明白的可以想想,中間表裏面是不是可以重複出現課程?)。因為 Course 類沒有定義導航屬性去引用中間實體,所以 WithMany 參數空白。最後是設置外鍵,誰引用誰?是中間實體引用 Course 吧,所以,需要一個叫 Course_ID 屬性來保存課程ID。

好了,右邊幹完了,到左邊了。

left => left.HasOne(e => e.TheStudent)
            // 一個Student可引用多個StudentCourseJoin
            // 但這裏省略了
            .WithMany()
            // 外鍵
            .HasForeignKey("Student_ID")

左邊是誰跟誰?從中間實體出發,它可以引用幾個 Student?一個吧,所以是 HasOne;反過來,Student 可以引用幾個中間實體?由於學生可以多次出現在中間實體中,所以是 WithMany,但 Student 類沒有指向中間實體的導航屬性,所以參數空。最後是外鍵,誰引用誰?是中間實體引用 Student 類吧?所以,中間實體要有一個 Student_ID 屬性來保存學生ID。

可是,Student_ID 和 Course_ID 在中間實體中是沒有定義的屬性,如果不手動配置,EF Core 是找不到的。

ent =>
{
    // 因為這兩個是影子屬性,必須顯式配置
    // 否則找不到屬性,會報錯
    ent.Property<int>("Student_ID");
    ent.Property<Guid>("Course_ID");
    // 兩個屬性都是主鍵
    ent.HasKey("Student_ID", "Course_ID");
}

這兩個屬性因為實體類中沒有定義,所以要作為影子屬性用,然後是兩個屬性都是主鍵。完事了。

 

這個代碼你要是看懂了,説明你學習 EF Core 的境界又提高了。

 

Add a new 評論

Some HTML is okay.