EF Code First 一對多、多對多關聯,如何加載子集合?


應用場景

先簡單描述一下標題的意思:使用 EF Code First 映射配置 Entity 之間的關系,可能是一對多關系,也可能是多對多關系,那如何加載 Entity 下關聯的 ICollection 集合對象呢?

上面的這個問題,我覺得大家應該都遇到過,當然前提是使用 EF Code First,有人會說,在 ICollection 集合對象前加 virtual 導航屬性,比如:

public virtual ICollection<Role> Roles { get; set; }

然后在 DbContext 初始化的時候,增加懶加載(或延遲加載)配置:

public UserDbContext()
    : base("name=UserDbContext")
{
    this.Configuration.LazyLoadingEnabled = false;
}

這種方式當然可以,也是我們常用的一種方式,但這種方式在一種場景中無法使用,就是對關聯 ICollection 集合增加 Where 條件,什么意思呢?我下描述一下用戶-角色應用場景,一個用戶有多個權限,一個權限也可能對應多個用戶,所以用戶和角色之間的關系是多對多,我們用 EF Code First 進行實現一下:

User(用戶)和 Role(角色)實體類:

namespace UserRoleDemo.Entities
{
    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Age { get; set; }
        public string Address { get; set; }
        public DateTime DateAdded { get; set; }
        public virtual ICollection<Role> Roles { get; set; }
    }
    public class Role
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime DateAdded { get; set; }
        public virtual ICollection<User> Users { get; set; }
    }
}

UserRoleDbContext 映射配置:

    public class UserRoleDbContext : DbContext
    {
        public UserRoleDbContext()
            : base("name=UserRoleDb")
        {
            //this.Configuration.LazyLoadingEnabled = false;
        }

        public virtual DbSet<User> Users { get; set; }
        public virtual DbSet<Role> Role { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder
                .Configurations
                .Add(new UserConfiguration())
                .Add(new RoleConfiguration());
            base.OnModelCreating(modelBuilder);
        }

        public class UserConfiguration : EntityTypeConfiguration<User>
        {
            public UserConfiguration()
            {
                HasKey(c => c.Id);
                Property(c => c.Id)
                    .IsRequired()
                    .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
                HasMany(t => t.Roles)
                    .WithMany(t => t.Users)
                    .Map(m =>
                    {
                        m.ToTable("UserRole");
                        m.MapLeftKey("UserId");
                        m.MapRightKey("RoleId");
                    });
            }
        }
        public class RoleConfiguration : EntityTypeConfiguration<Role>
        {
            public RoleConfiguration()
            {
                HasKey(c => c.Id);
                Property(c => c.Id)
                    .IsRequired()
                    .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
            }
        }
    }

生成對應數據庫:

可以看到,我們項目中只有 User 和 Role 兩個實體對象,但是生成數據庫多了一個 UserRole 表,這個是我們在 UserConfiguration 進行映射配置的結果,當然你不配置也可以,EF Code First 會自動幫你映射,但映射關聯表的名字和字段就不能自定義了,如果你深入使用 EF Code First 你會越發覺得它的強大之處,因為它會讓你感受不到數據庫的“存在”,在應用程序中,所有都是對象之間的操作,沒有了事務腳本模式的代碼,你可以專注於應用對象的“研究”,即使再復雜的映射配置,EF Code First 也會幫你完成。比如這樣一段代碼:user.Roles,如果常規的方式(SQL),你會去在應用程序中編寫“User join UserRole”的 SQL 代碼,但是如果使用 EF Code First,只要映射配置正確,直接 user.Roles 就可以了,當然它不僅如此。

咳咳,扯的有點遠了,有點像為微軟打廣告的意思,呵呵。

言歸正傳,用戶角色的場景就這么簡單,上面我說過不能使用懶加載方式解決的問題,比如我要獲取一個 User 對象,但在訪問 user.Roles 集合的時候,Roles 集合中 Role 對象的 DateAdded 必須大於昨天。這個就不能使用懶加載方式了,因為必須要在 user.Roles 去編寫 Where 條件,而懶加載方式是獲取所有關聯對象的集合,怎么解決這個實際問題呢?請看下面。

問題分析

查詢場景:獲取 Id 為 1 的 User 對象,並且 User 下的 Roles 集合的 DateAdded 大於昨天。

問題很簡單,就是這段話怎么翻譯成代碼?或者怎么用 Linq 的方式寫出來?

有人可能會想到 Include,但使用這種方式就沒必要 user.Roles 了,這種方式不可取,然后我再網上找了另一種方式,使用 Any 或 All,大致代碼如下:

using (var context = new UserRoleDbContext())
{
    var user = context.Users
        .Where(u => u.Id == 1)
        .Where(u => u.Roles.All(r => r.DateAdded > DateTime.Now.AddDays(-1)))
        .FirstOrDefault();
    foreach (var role in user.Roles)
    {
        Console.WriteLine(role.DateAdded);
    }
}

使用 Sql Server Profiler 跟蹤生成的 SQL 代碼,就會發現,我們寫的 DateAdded > DateTime.Now.AddDays(-1) 條件會出現在 User 獲取中,下面 user.Roles 遍歷的時候,還是會加載關聯下的所有集合對象,當然這種方式使用必須要開啟懶加載。

我個人覺得,這個問題應該在很多應用場景中都會出現,但遺憾的是網上實在找不到響應的解決方案(映射配置的比較多,但獲取方式的基本上沒有),當然不是說沒有方式解決,最簡單的就是把集合全部加載出來,然后在內存中進行過濾,項目簡單的還好,如果數據量非常大,這種方式也是不可取的,最后在 MSDN 上找到一篇很多年的博客:Using DbContext in EF 4.1 Part 6: Loading Related Entities,注意 EF 版本是 4.1,現在 7.0 都快出來了,哎!

看到“Loading Related Entities”這個標題,我就知道這篇博客就是我想要的,然后按照它描述的,配置如下:

首先,禁止懶加載:

this.Configuration.LazyLoadingEnabled = false;

Linq 查詢代碼:

using (var context = new UserRoleDbContext())
{
    var user = context.Users
        .Where(u => u.Id == 1)
        .FirstOrDefault();
    context.Entry(user)
        .Collection(u => u.Roles)
        .Query()
        .Where(r => r.DateAdded > DateTime.Now.AddDays(-1))
        .Load();
    foreach (var role in user.Roles)
    {
        Console.WriteLine(role.DateAdded);
    }
}

先說明一下,這段代碼是不能運行的,因為 user.Roles 集合的值為 null,至於原因,我是后來才知道的,這種方式只適用於“一對多”的關系,哪篇博客中的演示場景也是“一對多”,如果我們把 Query() 和后面的 Where 代碼去掉,沒有了條件查詢,這段代碼時可以運行的,至於原因,我覺得沒有了 where,那和懶加載又有什么區別呢。

“一對多”的方式是這種,那“多對多”的呢?答案是在 Collection 后加 Include,示例代碼:

using (var context = new UserRoleDbContext())
{
    var user = context.Users
        .Where(u => u.Id == 1)
        .FirstOrDefault();
    context.Entry(user)
        .Collection(u => u.Roles)
        .Query()
        .Include(r => r.Users)
        .Where(r => r.DateAdded > DateTime.Now.AddDays(-1))
        .Load();
    foreach (var role in user.Roles)
    {
        Console.WriteLine(role.DateAdded);
    }
}

這種方式確實是可以運行成功的,也是我們想要的效果,但如果你看一下跟蹤生成的 SQL 代碼,你就不想使用它了,為什么?我們看一下生成的 SQL 代碼:

SELECT 
    [Project1].[UserId] AS [UserId], 
    [Project1].[RoleId] AS [RoleId], 
    [Project1].[Id] AS [Id], 
    [Project1].[Name] AS [Name], 
    [Project1].[DateAdded] AS [DateAdded], 
    [Project1].[C1] AS [C1], 
    [Project1].[Id1] AS [Id1], 
    [Project1].[Name1] AS [Name1], 
    [Project1].[Age] AS [Age], 
    [Project1].[Address] AS [Address], 
    [Project1].[DateAdded1] AS [DateAdded1]
    FROM ( SELECT 
        [Extent1].[UserId] AS [UserId], 
        [Extent1].[RoleId] AS [RoleId], 
        [Extent2].[Id] AS [Id], 
        [Extent2].[Name] AS [Name], 
        [Extent2].[DateAdded] AS [DateAdded], 
        [Join2].[Id] AS [Id1], 
        [Join2].[Name] AS [Name1], 
        [Join2].[Age] AS [Age], 
        [Join2].[Address] AS [Address], 
        [Join2].[DateAdded] AS [DateAdded1], 
        CASE WHEN ([Join2].[UserId] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1]
        FROM   [dbo].[UserRole] AS [Extent1]
        INNER JOIN [dbo].[Roles] AS [Extent2] ON [Extent1].[RoleId] = [Extent2].[Id]
        LEFT OUTER JOIN  (SELECT [Extent3].[UserId] AS [UserId], [Extent3].[RoleId] AS [RoleId], [Extent4].[Id] AS [Id], [Extent4].[Name] AS [Name], [Extent4].[Age] AS [Age], [Extent4].[Address] AS [Address], [Extent4].[DateAdded] AS [DateAdded]
            FROM  [dbo].[UserRole] AS [Extent3]
            INNER JOIN [dbo].[Users] AS [Extent4] ON [Extent4].[Id] = [Extent3].[UserId] ) AS [Join2] ON [Extent2].[Id] = [Join2].[RoleId]
        WHERE ([Extent1].[UserId] = @EntityKeyValue1) AND ([Extent2].[DateAdded] > (SysDateTime()))
    )  AS [Project1]
    ORDER BY [Project1].[UserId] ASC, [Project1].[RoleId] ASC, [Project1].[Id] ASC, [Project1].[C1] ASC

看見這一坨的代碼就心煩,而且這只是兩段 SQL 代碼的一個,因為上面我們使用:context.Users.FirstOrDefault(),也會生成一坨 SQL 代碼,只不過沒那么復雜而已,其實復雜之處,就是我們使用 Include 方式,把 User、Role 和 UserRole 表關聯起來使用了,其實我們只是想獲取某個 user 下的 Role 集合而已,在 stackoverflow 中有人也有同樣的問題:EF 4.1 loading filtered child collections not working for many-to-many,當然講的比我詳細多了。

其實最后的解決方式有點“無語”,為什么呢?看一下代碼就知道了:

using (var context = new UserRoleDbContext())
{
    var user = context.Users
        .Where(u => u.Id == 1)
        .FirstOrDefault();
    user.Roles = context.Entry(user)
        .Collection(u => u.Roles)
        .Query()
        .Where(r => r.DateAdded > DateTime.Now)
        .ToList();
    foreach (var role in user.Roles)
    {
        Console.WriteLine(role.DateAdded);
    }
}

你可能發現了與上面代碼的不同,就是我們使用 Entry 獲取集合對象,重新給 user.Roles 屬性賦值,因為 ToList 了,同樣會產生兩條 SQL 代碼,但這種代碼,我們是可以接受的:

SELECT 
    [Extent2].[Id] AS [Id], 
    [Extent2].[Name] AS [Name], 
    [Extent2].[DateAdded] AS [DateAdded]
    FROM  [dbo].[UserRole] AS [Extent1]
    INNER JOIN [dbo].[Roles] AS [Extent2] ON [Extent1].[RoleId] = [Extent2].[Id]
    WHERE ([Extent1].[UserId] = @EntityKeyValue1) AND ([Extent2].[DateAdded] > (SysDateTime()))

示例 Demo 下載:

非常珍貴的參考資料:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM