【问题标题】:Returning one of each object based on list of Ids and other property using EF/Linq使用 EF/Linq 根据 Id 列表和其他属性返回每个对象之一
【发布时间】:2022-01-05 21:25:10
【问题描述】:

我有一个包含对象列表的 SQL 表,我正在尝试使用许多不同的条件返回一个列表。

这是我的对象(与 SQL 表相同):

class Photo{
        public int Id { get; set; }
        public string FileName { get; set; }
        public DateTime Uploaded { get; set; }
        public int? ProjectId { get; set; }
        public int GalleryOrder { get; set; }
}  

在 SQL 表中,随着时间的推移上传了许多照片,这些照片可能会或可能不会用ProjectIds 标记。每个ProjectId 都有许多照片标签。分批上传,这样可能会有很多张同一个DateTime Uploaded的照片,然后用GalleryOrder整理。

鉴于ProjectIds 的列表,我试图根据以下参数为每个项目返回一张代表照片:

  • 每个 ProjectId 一张照片
  • 最近上传日期
  • 最低画廊顺序

我有一些代码似乎可以与我的测试数据库(大约 20 个条目)一起使用,但它会提取过多的数据并且有多种类型。我不确定如何简化和优化它。

这是我当前的查询:

public async Task<List<Photo>> GetOneImageFilesPerProject(List<int> projectIds)
{
    using var context = _contextFactory.CreateDbContext();

    var results = await context.Photos.Where(x => x.ProjectId != null 
                                      && projectIds.Contains((int)x.ProjectId))
                                      .ToListAsync();
                                             
    results = results.OrderBy(x => x.ProjectId)
                     .ThenByDescending(x => x.Uploaded)
                     .ThenBy(x => x.GalleryOrder)
                     .GroupBy(x => x.ProjectId)
                     .Select(x => x.First())
                     .ToList();

    return results;
}

该程序使用在.Where 调用之后添加的OrderByGroupBySelect 调用进行编译,但它似乎在此时挂起并且永远不会返回最终列表。这就是为什么它分为两个处理步骤。

我的另一个想法是从 Db 中获取列表,然后通过 foreach 循环构建最终列表。不确定这是否比使用.GroupBy.Select 更快。无论哪种方式似乎有点不雅和蛮力。如果有一个直接的 SQL 查询会是一个更好的解决方案,我愿意接受!

是否有更直接的方法可以根据对象列表中的其他条件返回具有一个唯一项的列表?

就规模而言,这不会是一个庞大的应用程序,但它会一次查找约 10-20 个项目 ID,每个项目可能有约 40-50 个标记的照片。许多案例会小一点(2-5 张照片),但有些更大(100-200 张照片)。用户会非常频繁地运行此功能,例如一天几次,并且照片列表会频繁变化。

编辑:使用 .NET 5 和 EF Core 5

【问题讨论】:

  • 哪个 EF Core 版本?
  • EF core 5, .NET 5。如果它有很大的不同,我可以更新到 .NET 6,但它不在近期计划中。
  • 是的,EF Core 6 应该翻译这个查询。您只需从第一个查询中删除 ToListAsync 并将其保留为 IQueryable。它应该更快,但不是非常理想。

标签: c# entity-framework linq entity-framework-core


【解决方案1】:

试试这个查询,它应该适用于 EF Core 5

public async Task<List<Photo>> GetOneImageFilesPerProject(List<int> projectIds)
{
    using var context = _contextFactory.CreateDbContext();

    var photos = context.Photos.Where(x => x.ProjectId != null 
                                      && projectIds.Contains((int)x.ProjectId));

    var query = 
        from dp in photos.Select(x => new { x.ProjectId }).Distinct()
        from p in photos.Where(p => p.ProjectId == dp.ProjectId)
            .OrderByDescending(p => p.Uploaded)
            .ThenBy(p => p.GalleryOrder)
            .Take(1)
        select p;

    var results = await query.ToListAsync();
                                        
    return results;
}

【讨论】:

    【解决方案2】:

    EFC5 会翻译这个,只要您在项目中有一个包含照片列表的导航属性:

    var some = new [] {1,2,3}.ToList();
    var v = await context.Projects
      .Where(p => some.Contains(p.ProjectId))
      .Select(p => p.Photos
          .OrderByDescending(ph => ph.Uploaded)
          .ThenBy(ph => ph.Gallery)
          .First()
      ).ToListAsync();
    

    翻译成这样的:

    SELECT y.*
    FROM 
      Projects p
      LEFT JOIN
      ( 
        SELECT *
        FROM
          (
            SELECT *, ROW_NUMBER() OVER(PARTITION BY ProjectId ORDER BY Uploaded DESC, Gallery) rn 
            FROM Photos
          ) x
        WHERE rn = 1
      ) y
    WHERE p.ProjectId IN (1,2,3)
    

    这应该是相当高性能的。您还可以将谓词移到 Select 内部:

    var some = new [] {1,2,3}.ToList();
    var v = await context.Projects
      .Select(p => p.Photos 
          .Where(p => some.Contains(p.ProjectId))
          .OrderByDescending(ph => ph.Uploaded)
            .ThenBy(ph => ph.Gallery)
         .First()
      )
      .ToListAsync();
    

    这将生成相同的查询,但将 IN 移动到内部查询:

    SELECT y.*
    FROM 
      Projects p
      LEFT JOIN
      ( 
        SELECT *
        FROM
          (
            SELECT *, ROW_NUMBER() OVER(PARTITION BY ProjectId ORDER BY Uploaded DESC, Gallery) rn 
            FROM Photos
            WHERE p.ProjectId IN (1,2,3)
          ) x
        WHERE rn = 1
      ) y
    

    这并不重要; SQLS 应该同样执行这些

    那么“从项目开始对我们有什么好处?”你可能会问..

    ..好吧,它本质上为您提供了“每个项目”的标准:它导致 EF 写入 PARTITION BY ProjectId,这对于获取每个项目的图像至关重要。如果您直接从context.Orders 尝试,EF 将无法理解“每个项目”部分


    您也可以始终执行此操作:

        var ids = new[]{1,2,3};
        var idsStr = string.Join(",", ids);
    
        //yes, raw is intended here because of the IN, not interpolated, even though it's an interpstring
        context.Photos.FromSqlRaw($@"SELECT *
    FROM (
      SELECT *, ROW_NUMBER() OVER(PARTITION BY ProjectId ORDER BY Uploaded DESC, Gallery) rn 
      FROM Photos
      WHERE ProjectId IN ({idsStr})
    )
    WHERE rn = 1").ToList();
    

    或者,如果将值连接到原始数据中让您感到紧张(应该如此,尽管使用 int 数组进行 SQL 注入的空间不大),您可以在原始数据上编写:

        var ids = new[]{1,2,3}.ToList();
    
        context.Photos.FromSqlRaw($@"SELECT *
    FROM (
      SELECT *, ROW_NUMBER() OVER(PARTITION BY ProjectId ORDER BY Uploaded DESC, Gallery) rn 
      FROM Photos
    )
    WHERE rn = 1").Where(p => ids.Contains(p.ProjectId));
    

    EF 会将原始数据捆绑为子查询,但 SQLS 应该能够将 projectid 谓词下推到最内层的查询中,并且本质上与另一个相同地执行这个原始数据

    【讨论】:

    • 其实还是梦想。
    【解决方案3】:

    不幸的是,EF Core 尚未赶上 EF6,它可以设法从 GroupBy 表达式构建交叉应用,您希望从分组结果中提取特定行。自 EF Core 3.1 或什至更早的版本以来,已记录了对这种支持的请求,而 AFAIK 尚未合并它们。 (https://github.com/dotnet/efcore/issues/12088)

    典型的解决方法是使用GroupBy 表达式来获得值的唯一表示,以重新加入表中。这需要一些假设,因为您有一个日期和一个序列号 (GalleryOrder),因为我们需要假设 GalleryOrder 总是从 1 开始,并且这些项目不会被删除。否则,您可以使用查询来接近以查找每个适用订单和日期的所有照片,但最终需要从内存中选择最低图库:

    List<int> projectIds = new[] { 1, 2 }.ToList(); // Just for testing...
    
    var photos = context.Photos
        .Where(x => x.ProjectId.HasValue && projectIds.Contains(x.ProjectId.Value))
        .GroupBy(x => x.ProjectId)
        .Select(g => new
        {
            ProjectId = g.Key,
            Uploaded = g.Max(x => x.Uploaded)
        }).Join(context.Photos.Where(x => x.GalleryOrder == 1), 
            x => x, 
            x => new { x.ProjectId, x.Uploaded }
            , (_, r) => r).ToList();
    

    如果可以在我们无法保证 GalleryOrder 为 1 的情况下删除照片,或者我们想要切换到最高画廊顺序之类的内容,则最终选择需要在内存中完成。 (编辑:删除第二个选项,直到我有机会测试更新的分组,因为它没有解决最新的日期。)

    Edit2:好的,我对第二个查询背后的想法有所了解。可以通过 Linq 在一次操作中获取所需的数据,但是根据我们正在讨论的数据量和选择的项目的数量,加载所选项目的所有照片并进行分组/使用可以进行组内选择的 Linq2Object 进行最小化。 (直到 EF Core 6 可能重新启用此功能)

                var photos = context.Photos
                    .Where(x => x.ProjectId.HasValue && projectIds.Contains(x.ProjectId.Value))
                    .GroupBy(x => new { x.ProjectId, x.Uploaded })
                    .Select(g => new
                    {
                        ProjectId = g.Key.ProjectId,
                        Uploaded = g.Key.Uploaded,
                        GalleryOrder = g.Min(x => x.GalleryOrer)
                    }).Join(context.Photos, x => x, x => new { x.ProjectId, x.Uploaded, x.GalleryOrder }
                    , (_, r) => r)
                    .GroupBy(x => new { x.ProjectId, x.GalleryOrder })
                    .Select(g => new
                    {
                        ProjectId = g.Key.ProjectId,
                        Uploaded = g.Max(x => x.Uploaded),
                        GalleryOrder = g.Key.GalleryOrder
                    }).Join(context.Photos, x => x, x => new { x.ProjectId, x.Uploaded, x.GalleryOrder }
                    , (_, r) => r)
                    .ToList();
    

    如果需要检索大量的项目 ID,和/或每个项目有大量的照片,则需要考虑一些事项。或者为每个适用的项目加载照片,如果需要,对项目 ID 进行批处理,并使用 Linq2Object 有序分组获取适当的照片。

    【讨论】:

    • 添加了 EF 核心讨论相关行为的链接。它为 EF Core 6 制定了路线图,没有迹象表明它是否真的在那里实施。不确定匿名投票的目的是什么......
    • 第一次查询不遵守顺序。第二个没有意义 - 客户端更好的组。
    • 第一个确实尊重顺序,尽管我确实提到了只有在项目没有被删除并且全部从 1(或 0,数字是任意的)开始时才会起作用的警告我们取最大日期涵盖最近的日期。我看到的第二个选项是缺少日期考虑。在我有机会测试修改后的分组之前,我已将其删除。如果行数合理,加载每个项目 ID 的所有照片可能没问题,但我们可能能够获取最新日期的照片,只需要过滤内存中的照片。
    • 更新了第二个示例,它可以在一次调用中完成操作,但我认为我不会使用,除非它涉及到可能会拉回大量数据,如果你要拉回所有照片适用项目并在内存中按顺序分组。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-07-19
    • 1970-01-01
    • 2021-07-26
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多