EntityFramework 多對多操作

在EntityFramework的世界裡,遇過最奇妙的部份其中一個就是多對多的轉置關係了,它非常好用,但也帶了一些副作用。

多對多其實在一、兩年前就已經遇過,最近又遇到類似的問題,剛好把這部份的資料整理起來跟大家分享

當資料有做正規化的話,有時候就會遇到多對多的處理情況

什麼是多對多呢?

最簡單的例子就是,每一個台灣人都可以擁有多重國藉

所以你會有使用者(Users, key: UserID), 國藉(Nations, Key: NationID)

以及使用者對應國藉(User2Nation, Key: UserID + NationID)這三個資料表

很習慣地我們會把這三個資料表加到EntityFramework designer頁面去

然而你會發現一個奇怪的景象

User2Nation不見了!!!
User2Nation不見了!!!
User2Nation不見了!!!

取而代之的只有Users與Nations這兩個資料表,但這不是Bug

在navigation property中,也可以很清楚發現這是沒有問題的

    public partial class Users
    {
        public Users()
        {
            this.ExpenseDetail = new HashSet<ExpenseDetail>();
            this.Nations = new HashSet<Nations>();
        }
    
        public int userID { get; set; }
        public string userName { get; set; }
        public virtual ICollection<ExpenseDetail> ExpenseDetail { get; set; }
        public virtual ICollection<Nations> Nations { get; set; }
    }

我們馬上就來測試這個方便的功能

找出擁有多重國藉的使用者,並將他的國藉顯示出來 
                using (StreamWriter sw = new StreamWriter(string.Format(@"{0}\log.txt", logPath)))
                {
                    using (var db = new NorthwindEntities())
                    {
                        db.Database.Log = s => Log(sw, s);
                        //db.Database.Log = Console.Write;

                        var result = db.Users
                            .Where(m => m.Nations.Count > 1)
                            .Select(m => new
                            {
                                Nmae = m.userName,
                                lstNation = m.Nations
                            }).ToList();

                        foreach (var elment in result)
                        {
                            Console.WriteLine("User:" + elment.Nmae);
                            foreach (var nation in elment.lstNation)
                                Console.WriteLine("=====>" + nation.NationName);
                        }
                    }
                }

若對於把sql log寫到folder 或是sql效能有疑問者可以參考前一篇 EntityFramework 效能檢視

效能上因為有加FK,所以沒有太大問題,在使用上又更上一層樓

但請注意天下沒有白吃的午餐,這麼好用的東西當然也會有要付出代價的地方

多對多 CRUD

Create

新增的方式其實很簡單

如果你要針對一個新的User增加新的國藉

只要使用newUser.Nations.Add( new country object ) 即可

EntityFramework就會在User2Nation與Nation這兩個表格做Insert

如果我們用以下的方法Create一個新的User,會發生什麼事呢?

                        Nations tw = new Nations() { NationID = "TW", NationName = "Taiwan" };
                        Nations gr = new Nations() { NationID = "GR", NationName = "Greece" };
                        //insert user and nations
                        Users newUser = new Users() { userID = 99, userName = "new99" };
                        //TW exist
                        newUser.Nations.Add(tw);
                        //GR not exist
                        newUser.Nations.Add(gr);
                        db.SaveChanges();

Insert TW這個國藉的時候就會失敗,為什麼呢?

原因很簡單,因為TW這個國藉在Nations已經存在

在處理多對多的時候,最麻煩的就是我們得知道哪些資料是已經存在,哪些是不存在

因為TW已經存在於Nations,所以我們得改tw的來源

                        Nations tw = db.Nations.Where(m => m.NationID == "TW").FirstOrDefault();
                        Nations gr = new Nations() { NationID = "GR", NationName = "Greece" };
                        //insert user and nations
                        Users newUser = new Users() { userID = 99, userName = "new99" };
                        //TW exist
                        newUser.Nations.Add(tw);
                        //GR not exist
                        newUser.Nations.Add(gr);
                        db.SaveChanges();

這樣子EntityFramework才知道TW是從已經存在的資料抓出來的,它沒必要去Insert這筆資料

以這個例子來看,Users會Insert 一筆,User2Nation會Insert 2筆 (TW, GR),而Nations 也只會Insert一筆 (GR)

 

Delete

在講Update之前,我們先來看看Delete

Users user99 = db.Users.Where(m => m.userID == 99).FirstOrDefault();
user99.Nations.Clear();
db.SaveChanges();

User2Nation會Delete 2筆 (TW, GR), Users跟Nations不變

Update

之所以會先講Delete再講Update,你可能猜到為什麼了

由於是多對多的關係,User2Nation的欄位通常為兩個FK所組成的Key

在EntityFramework中Key是無法被更改的

Update = Delete + Insert

由於Entity Framework是有包Trasaction的,所以並不會有Delete成功,但Insert failed的情況,要嘛就是全部成功全部失敗

所以Update我們可以這樣做,就會長成如下方這樣的形式 (假設我們要更新10號User的國藉, 再假設TW, GR, JP這三個國家都存在)

                         //update 
                        //delete and then insert back
                        Users user10 = db.Users.Where(m => m.userID == 10).FirstOrDefault();
                        Nations tw = db.Nations.Where(m => m.NationID == "TW").FirstOrDefault();
                        Nations gr = db.Nations.Where(m => m.NationID == "GR").FirstOrDefault();
                        Nations jp = db.Nations.Where(m => m.NationID == "JP").FirstOrDefault();
                        user10.Nations.Clear();
                        user10.Nations.Add(tw);
                        user10.Nations.Add(gr);
                        user10.Nations.Add(jp);
                        db.SaveChanges();

並把SQL log抓出來看看

Opened connection at 2017/4/17 下午 01:26:16 +08:00
SELECT TOP (1) 
    [Extent1].[userID] AS [userID], 
    [Extent1].[userName] AS [userName]
    FROM [dbo].[Users] AS [Extent1]
    WHERE 10 = [Extent1].[userID]
-- Executing at 2017/4/17 下午 01:26:16 +08:00
-- Completed in 13 ms with result: SqlDataReader

Closed connection at 2017/4/17 下午 01:26:16 +08:00
Opened connection at 2017/4/17 下午 01:26:16 +08:00
SELECT TOP (1) 
    [Extent1].[NationID] AS [NationID], 
    [Extent1].[NationName] AS [NationName]
    FROM [dbo].[Nations] AS [Extent1]
    WHERE 'TW' = [Extent1].[NationID]
-- Executing at 2017/4/17 下午 01:26:16 +08:00
-- Completed in 7 ms with result: SqlDataReader

Closed connection at 2017/4/17 下午 01:26:16 +08:00
Opened connection at 2017/4/17 下午 01:26:16 +08:00
SELECT TOP (1) 
    [Extent1].[NationID] AS [NationID], 
    [Extent1].[NationName] AS [NationName]
    FROM [dbo].[Nations] AS [Extent1]
    WHERE 'GR' = [Extent1].[NationID]
-- Executing at 2017/4/17 下午 01:26:16 +08:00
-- Completed in 33 ms with result: SqlDataReader

Closed connection at 2017/4/17 下午 01:26:16 +08:00
Opened connection at 2017/4/17 下午 01:26:16 +08:00
SELECT TOP (1) 
    [Extent1].[NationID] AS [NationID], 
    [Extent1].[NationName] AS [NationName]
    FROM [dbo].[Nations] AS [Extent1]
    WHERE 'JP' = [Extent1].[NationID]
-- Executing at 2017/4/17 下午 01:26:16 +08:00
-- Completed in 32 ms with result: SqlDataReader

Closed connection at 2017/4/17 下午 01:26:16 +08:00
Opened connection at 2017/4/17 下午 01:26:16 +08:00
SELECT 
    [Extent2].[NationID] AS [NationID], 
    [Extent2].[NationName] AS [NationName]
    FROM  [dbo].[User2Nation] AS [Extent1]
    INNER JOIN [dbo].[Nations] AS [Extent2] ON [Extent1].[NationID] = [Extent2].[NationID]
    WHERE [Extent1].[UserID] = @EntityKeyValue1
-- EntityKeyValue1: '10' (Type = Int32, IsNullable = false)
-- Executing at 2017/4/17 下午 01:26:16 +08:00
-- Completed in 23 ms with result: SqlDataReader

Closed connection at 2017/4/17 下午 01:26:16 +08:00
Opened connection at 2017/4/17 下午 01:26:16 +08:00
Started transaction at 2017/4/17 下午 01:26:16 +08:00
INSERT [dbo].[User2Nation]([UserID], [NationID])
VALUES (@0, @1)
-- @0: '10' (Type = Int32)
-- @1: 'GR' (Type = AnsiString, Size = 10)
-- Executing at 2017/4/17 下午 01:26:16 +08:00
-- Completed in 23 ms with result: 1

INSERT [dbo].[User2Nation]([UserID], [NationID])
VALUES (@0, @1)
-- @0: '10' (Type = Int32)
-- @1: 'JP' (Type = AnsiString, Size = 10)
-- Executing at 2017/4/17 下午 01:26:16 +08:00
-- Completed in 13 ms with result: 1

INSERT [dbo].[User2Nation]([UserID], [NationID])
VALUES (@0, @1)
-- @0: '10' (Type = Int32)
-- @1: 'TW' (Type = AnsiString, Size = 10)
-- Executing at 2017/4/17 下午 01:26:16 +08:00
-- Completed in 12 ms with result: 1

Committed transaction at 2017/4/17 下午 01:26:17 +08:00
Closed connection at 2017/4/17 下午 01:26:17 +08:00

在這邊我們可以發現,如果User2Nation筆數夠大的話,效率並不會太快

因為Entity Framework是一筆一筆塞的,並不是採用Bulk Insert的方式來做

但若是筆數不多的情況下,用這樣的方式處理並不會有太大的效能問題

 

參考資料

https://www.codeproject.com/tips/893609/crud-many-to-many-entity-framework