“多對多”關係不像“一對多”那麼“單純”,它內部涉及到“連接實體”(Join Entity)的概念。咱們先放下這個概念不表,來了解一下多對多數據表為什麼需要一個“輔助表”來建立關係。
假設有兩張表:一張表示學生,一張表示選修課。那麼,這裏頭的關係是你可以選多門課,而一門課可以被多人選。這是多對多關係,沒問題吧。
按照數據庫存儲的原則,學生表中每位學生的信息都不應重複,而課程表也是如此。這麼一看,多對多的關係不能直接在這兩個表中創建了。
那就只能引入第三個表,專門保存前兩個表的信息了。
經過這樣處理後,多對多的關係被拆解成兩個一對多關係:
左邊:學生(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 實體。即它們都跨過(跳過)中間實體,兩者直接連接起來了。
老周畫了一個不專業的簡圖。
這裏也產生了一個疑問:我們沒創建中間實體啊,難道是 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 的境界又提高了。