Linq to Entity多对多/多对一关联下动态自定义分组实现问题
问题背景
我曾尝试通过表达式树实现动态多字段分组功能但没有成功,为了方便梳理问题,我搭建了示例数据库,以下是数据库初始化脚本:
SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[Location]( [Id] [int] IDENTITY(1,1) NOT NULL, [Place] [nvarchar](50) NOT NULL, [HousePaintEnum] [int] NULL, CONSTRAINT [PK_Location_1] PRIMARY KEY CLUSTERED ( [Id] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[Person]( [Id] [int] IDENTITY(1,1) NOT NULL, [Name] [nvarchar](50) NOT NULL, [Status_Id] [int] NULL, CONSTRAINT [PK_Person] PRIMARY KEY CLUSTERED ( [Id] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[Person_Location]( [Person_Id] [int] NOT NULL, [Location_Id] [int] NOT NULL, CONSTRAINT [PK_Person_Location] PRIMARY KEY CLUSTERED ( [Person_Id] ASC, [Location_Id] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[Status]( [Id] [int] IDENTITY(1,1) NOT NULL, [Maritalstatus] [nvarchar](50) NOT NULL, [Healthstatus] [nvarchar](50) NOT NULL, CONSTRAINT [PK_Status] PRIMARY KEY CLUSTERED ( [Id] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] GO SET IDENTITY_INSERT [dbo].[Location] ON GO INSERT [dbo].[Location] ([Id], [Place], [HousePaintEnum]) VALUES (1, N'Mars', 1) GO INSERT [dbo].[Location] ([Id], [Place], [HousePaintEnum]) VALUES (2, N'Earth', 2) GO INSERT [dbo].[Location] ([Id], [Place], [HousePaintEnum]) VALUES (3, N'Moon', 3) GO INSERT [dbo].[Location] ([Id], [Place], [HousePaintEnum]) VALUES (4, N'Moon', 1) GO INSERT [dbo].[Location] ([Id], [Place], [HousePaintEnum]) VALUES (5, N'Earth', 4) GO INSERT [dbo].[Location] ([Id], [Place], [HousePaintEnum]) VALUES (6, N'Earth', 3) GO INSERT [dbo].[Location] ([Id], [Place], [HousePaintEnum]) VALUES (7, N'Mars', 2) GO INSERT [dbo].[Location] ([Id], [Place], [HousePaintEnum]) VALUES (8, N'Moon', 2) GO SET IDENTITY_INSERT [dbo].[Location] OFF GO SET IDENTITY_INSERT [dbo].[Person] ON GO INSERT [dbo].[Person] ([Id], [Name], [Status_Id]) VALUES (1, N'John', 2) GO INSERT [dbo].[Person] ([Id], [Name], [Status_Id]) VALUES (2, N'Erik', 4) GO INSERT [dbo].[Person] ([Id], [Name], [Status_Id]) VALUES (3, N'Lisa', 1) GO INSERT [dbo].[Person] ([Id], [Name], [Status_Id]) VALUES (4, N'Edward', 4) GO INSERT [dbo].[Person] ([Id], [Name], [Status_Id]) VALUES (5, N'Emma', 2) GO INSERT [dbo].[Person] ([Id], [Name], [Status_Id]) VALUES (6, N'Lars', 4) GO INSERT [dbo].[Person] ([Id], [Name], [Status_Id]) VALUES (7, N'Joe', 5) GO SET IDENTITY_INSERT [dbo].[Person] OFF GO INSERT [dbo].[Person_Location] ([Person_Id], [Location_Id]) VALUES (1, 1) GO INSERT [dbo].[Person_Location] ([Person_Id], [Location_Id]) VALUES (1, 2) GO INSERT [dbo].[Person_Location] ([Person_Id], [Location_Id]) VALUES (2, 2) GO INSERT [dbo].[Person_Location] ([Person_Id], [Location_Id]) VALUES (2, 3) GO INSERT [dbo].[Person_Location] ([Person_Id], [Location_Id]) VALUES (3, 3) GO INSERT [dbo].[Person_Location] ([Person_Id], [Location_Id]) VALUES (4, 2) GO INSERT [dbo].[Person_Location] ([Person_Id], [Location_Id]) VALUES (4, 4) GO INSERT [dbo].[Person_Location] ([Person_Id], [Location_Id]) VALUES (5, 5) GO SET IDENTITY_INSERT [dbo].[Status] ON GO INSERT [dbo].[Status] ([Id], [Maritalstatus], [Healthstatus]) VALUES (1, N'Married', N'Bad') GO INSERT [dbo].[Status] ([Id], [Maritalstatus], [Healthstatus]) VALUES (2, N'Single', N'Good') GO INSERT [dbo].[Status] ([Id], [Maritalstatus], [Healthstatus]) VALUES (3, N'Unknown', N'Best') GO INSERT [dbo].[Status] ([Id], [Maritalstatus], [Healthstatus]) VALUES (4, N'Single', N'Bad') GO INSERT [dbo].[Status] ([Id], [Maritalstatus], [Healthstatus]) VALUES (5, N'Single', N'Unknown') GO SET IDENTITY_INSERT [dbo].[Status] OFF GO ALTER TABLE [dbo].[Person] WITH CHECK ADD CONSTRAINT [FK_Person_Status] FOREIGN KEY([Status_Id]) REFERENCES [dbo].[Status] ([Id]) GO ALTER TABLE [dbo].[Person] CHECK CONSTRAINT [FK_Person_Status] GO ALTER TABLE [dbo].[Person_Location] WITH CHECK ADD CONSTRAINT [FK_Person_Location_Location] FOREIGN KEY([Location_Id]) REFERENCES [dbo].[Location] ([Id]) GO ALTER TABLE [dbo].[Person_Location] CHECK CONSTRAINT [FK_Person_Location_Location] GO ALTER TABLE [dbo].[Person_Location] WITH CHECK ADD CONSTRAINT [FK_Person_Location_Person] FOREIGN KEY([Person_Id]) REFERENCES [dbo].[Person] ([Id]) GO ALTER TABLE [dbo].[Person_Location] CHECK CONSTRAINT [FK_Person_Location_Person] GO
需求说明
- 支持用户自行选择要分组的列,选中的列以字符串数组形式提交
- 表结构存在多对多关联关系
- 需要动态生成支持选择任意分组字段的表达式树,传入参数为指定分组所属表与对应字段的数组
- 最终生成的查询效果类似以下示例(实际业务场景有50张表,单表最多20个属性):
SELECT COUNT(DISTINCT this_.Id) AS y0_, lo.Place AS y1_, lo.HousePaintEnum AS y2_, lo.Place AS y3_, lo.HousePaintEnum AS y4_ FROM Person this_ INNER JOIN Person_Location pl ON this_.Id = pl.Person_Id INNER JOIN Location lo ON pl.Location_Id = lo.Id GROUP BY lo.Place, lo.HousePaintEnum;
SELECT COUNT(DISTINCT this_.Id) AS y0_, lo.Place AS y1_, lo.HousePaintEnum AS y2_, lo.Place AS y3_, lo.HousePaintEnum AS y4_, s.Maritalstatus as y5_ FROM Person this_ INNER JOIN Person_Location pl ON this_.Id = pl.Person_Id INNER JOIN Location lo ON pl.Location_Id = lo.Id INNER JOIN Status s ON this_.Status_Id=s.Id GROUP BY lo.Place, lo.HousePaintEnum, s.Maritalstatus
目前无法使用Dynamic Linq框架,针对关联子属性进行多字段GroupBy的表达式树实现难度较高,求可行的实现建议。
实现建议
- 优先维护元数据映射
提前在代码中维护所有实体、关联关系、字段的元数据映射表,记录每个字段的所属实体、对应的导航属性访问路径(比如Location.Place对应从Person出发的路径是Person.PersonLocations.Select(pl => pl.Location).Place)、字段的CLR类型。用户提交选中的分组字段后,先将字符串格式的字段标识转换为对应的访问路径和类型信息。 - 动态构建分组键类型
多字段GroupBy需要自定义类型作为分组键,你可以用TypeBuilder在运行时内存中动态生成对应分组键的类,类的属性就是用户选中的所有分组字段,属性顺序、类型和选中字段完全匹配。 - 编写GroupBy表达式树构建逻辑
先定义泛型GroupBy方法作为模板,针对动态生成的分组键类型,用Expression.PropertyOrField方法逐层访问导航属性:比如要访问Location的Place字段,就从Person实体参数出发,先访问Person_Location集合,再选中对应的Location实体,最后取Place属性。将这些属性访问表达式作为动态分组键类的属性初始化赋值,拼接成分组的Lambda表达式后,用Expression.Call调用Queryable.GroupBy方法,将当前的IQueryable对象和分组Lambda作为参数传入。 - 构建投影表达式树
GroupBy返回的结果是IGrouping<TKey, TSource>类型,你需要继续构建投影的Lambda表达式:先从分组Key中取出各个属性作为返回的分组字段,再用Expression.Call调用Queryable.Distinct和Count方法,实现COUNT(DISTINCT Person.Id)的逻辑,对应需要的统计字段。 - 自动处理关联加载
提前解析用户选中的分组字段涉及的所有导航关联,在执行GroupBy之前先调用Include/ThenInclude加载需要的关联表,确保ORM能生成正确的JOIN语句,避免出现懒加载或关联缺失报错。 - 更简单的替代方案:直接动态拼接SQL
如果表达式树实现成本太高,你可以直接动态拼接SQL执行。提前维护好各个字段对应的表别名、关联关系的JOIN语句模板,用户选中字段后,动态拼接SELECT字段、JOIN语句、GROUP BY字段即可,注意做参数化处理防止SQL注入,这种方式实现难度比写表达式树低很多,更适合表数量多的业务场景。
内容的提问来源于stack exchange,提问作者Jerker Pihl
相关产品推荐
相关产品推荐

