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 下载:

非常珍贵的参考资料:

原文地址:https://www.cnblogs.com/xishuai/p/ef-code-first-loading-filtered-child-collections-for-many-to-many.html