Fix EF Core DistinctBy query translation errors
Refactor all item query methods to avoid untranslatable DistinctBy
in IQueryable expressions. Replace ApplyGroupingFilter with a
two-phase pattern: load IDs and grouping keys to memory, apply
grouping and paging in-memory, then reload full entities by ID.
Add ApplyGroupingInMemory<T> and ApplyPagingToIds helpers.
Use AsEnumerable() before DistinctBy in GetItemValues to force
client-side evaluation. Resolves InvalidOperationException on
/Items, /Items/Filters, /Users/{id}/Items/Latest, and /Studios
endpoints across all supported databases. See
EF_CORE_QUERY_TRANSLATION_FIX.md for details.
This commit is contained in:
@@ -393,19 +393,40 @@ public sealed class BaseItemRepository
|
||||
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
|
||||
|
||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
// Count before grouping for accurate total
|
||||
result.TotalRecordCount = await dbQuery
|
||||
.CountAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
// Apply ordering before grouping so we get the right items
|
||||
dbQuery = ApplyOrder(dbQuery, filter, context);
|
||||
|
||||
var items = await dbQuery
|
||||
// Get IDs only, without DistinctBy to avoid translation errors
|
||||
var allIds = await dbQuery
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Apply grouping/distinct in memory
|
||||
var filteredIds = ApplyGroupingInMemory(allIds, filter);
|
||||
|
||||
// Apply paging to IDs
|
||||
var pagedIds = ApplyPagingToIds(filteredIds, filter);
|
||||
|
||||
if (pagedIds.Count == 0)
|
||||
{
|
||||
result.Items = [];
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load full entities with navigations
|
||||
var dbQueryWithNavs = ApplyNavigations(context.BaseItems.Where(e => pagedIds.Contains(e.Id)), filter);
|
||||
var items = await dbQueryWithNavs
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -437,23 +458,42 @@ public sealed class BaseItemRepository
|
||||
|
||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
// Apply ordering first, before grouping
|
||||
dbQuery = ApplyOrder(dbQuery, filter, context);
|
||||
|
||||
// Get IDs and keys to memory WITHOUT DistinctBy (which can't be translated)
|
||||
var itemsWithKeys = await dbQuery
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.PresentationUniqueKey,
|
||||
e.SeriesPresentationUniqueKey
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (itemsWithKeys.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItemDto>();
|
||||
}
|
||||
|
||||
// Apply grouping in memory
|
||||
var groupedIds = ApplyGroupingInMemory(itemsWithKeys, filter);
|
||||
|
||||
// Apply paging to IDs
|
||||
var pagedIds = ApplyPagingToIds(groupedIds, filter);
|
||||
|
||||
if (pagedIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItemDto>();
|
||||
}
|
||||
|
||||
// For random sort, we need to maintain the order of IDs
|
||||
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
|
||||
if (hasRandomSort)
|
||||
{
|
||||
var orderedIds = await dbQuery
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (orderedIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItemDto>();
|
||||
}
|
||||
|
||||
var items = await ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
|
||||
// Load items and maintain random order
|
||||
var items = await ApplyNavigations(context.BaseItems.Where(e => pagedIds.Contains(e.Id)), filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -462,12 +502,11 @@ public sealed class BaseItemRepository
|
||||
.Where(dto => dto is not null)
|
||||
.ToDictionary(i => i!.Id);
|
||||
|
||||
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
|
||||
return pagedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
|
||||
}
|
||||
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
|
||||
var results = await dbQuery
|
||||
// Load full entities with navigations
|
||||
var results = await ApplyNavigations(context.BaseItems.Where(e => pagedIds.Contains(e.Id)), filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -501,42 +540,132 @@ public sealed class BaseItemRepository
|
||||
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
// Subquery to group by SeriesNames/Album and get the max Date Created for each group.
|
||||
// Split into two queries to avoid untranslatable nested subquery with Min()
|
||||
// First query: get the most recent series/albums
|
||||
var subquery = PrepareItemQuery(context, filter);
|
||||
subquery = TranslateQuery(subquery, context, filter);
|
||||
var subqueryGrouped = subquery.GroupBy(g => collectionType == CollectionType.tvshows ? g.SeriesName : g.Album)
|
||||
|
||||
var subqueryGrouped = subquery
|
||||
.GroupBy(g => collectionType == CollectionType.tvshows ? g.SeriesName : g.Album)
|
||||
.Select(g => new
|
||||
{
|
||||
Key = g.Key,
|
||||
MaxDateCreated = g.Max(a => a.DateCreated)
|
||||
})
|
||||
.OrderByDescending(g => g.MaxDateCreated)
|
||||
.Select(g => g);
|
||||
.OrderByDescending(g => g.MaxDateCreated);
|
||||
|
||||
if (filter.Limit.HasValue && filter.Limit.Value > 0)
|
||||
{
|
||||
subqueryGrouped = subqueryGrouped.Take(filter.Limit.Value);
|
||||
// Execute the limited subquery to get date threshold
|
||||
var limitedResults = await subqueryGrouped
|
||||
.Take(filter.Limit.Value)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (limitedResults.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
var minDateThreshold = limitedResults.Min(s => s.MaxDateCreated);
|
||||
|
||||
filter.Limit = null;
|
||||
|
||||
// Second query: get items newer than threshold, then apply grouping
|
||||
var mainquery = PrepareItemQuery(context, filter);
|
||||
mainquery = TranslateQuery(mainquery, context, filter);
|
||||
mainquery = mainquery.Where(g => g.DateCreated >= minDateThreshold);
|
||||
|
||||
// Apply ordering first (before loading to memory)
|
||||
mainquery = ApplyOrder(mainquery, filter, context);
|
||||
|
||||
// Get IDs + keys to memory (without DistinctBy in the query)
|
||||
var itemsWithKeys = await mainquery
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (itemsWithKeys.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
// Apply grouping in-memory (client-side evaluation)
|
||||
var groupedIds = ApplyGroupingInMemory(itemsWithKeys, filter);
|
||||
|
||||
// Apply paging to IDs
|
||||
var pagedIds = ApplyPagingToIds(groupedIds, filter);
|
||||
|
||||
if (pagedIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
// Load full entities with navigations
|
||||
var results = await ApplyNavigations(context.BaseItems.Where(e => pagedIds.Contains(e.Id)), filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results
|
||||
.Where(e => e is not null)
|
||||
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||
.Where(dto => dto is not null)
|
||||
.ToArray()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No limit, execute subquery to get date threshold
|
||||
var groupedResults = await subqueryGrouped.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (groupedResults.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
filter.Limit = null;
|
||||
var minDateThreshold = groupedResults.Min(s => s.MaxDateCreated);
|
||||
|
||||
var mainquery = PrepareItemQuery(context, filter);
|
||||
mainquery = TranslateQuery(mainquery, context, filter);
|
||||
mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated));
|
||||
mainquery = ApplyGroupingFilter(context, mainquery, filter);
|
||||
mainquery = ApplyQueryPaging(mainquery, filter);
|
||||
filter.Limit = null;
|
||||
|
||||
mainquery = ApplyNavigations(mainquery, filter);
|
||||
// Second query: get items newer than threshold, then apply grouping
|
||||
var mainquery = PrepareItemQuery(context, filter);
|
||||
mainquery = TranslateQuery(mainquery, context, filter);
|
||||
mainquery = mainquery.Where(g => g.DateCreated >= minDateThreshold);
|
||||
|
||||
var results = await mainquery
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
// Apply ordering first (before loading to memory)
|
||||
mainquery = ApplyOrder(mainquery, filter, context);
|
||||
|
||||
return results
|
||||
.Where(e => e is not null)
|
||||
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||
.Where(dto => dto is not null)
|
||||
.ToArray()!;
|
||||
// Get IDs + keys to memory (without DistinctBy in the query)
|
||||
var itemsWithKeys = await mainquery
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (itemsWithKeys.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
// Apply grouping in-memory (client-side evaluation)
|
||||
var groupedIds = ApplyGroupingInMemory(itemsWithKeys, filter);
|
||||
|
||||
// Apply paging to IDs
|
||||
var pagedIds = ApplyPagingToIds(groupedIds, filter);
|
||||
|
||||
if (pagedIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
// Load full entities with navigations
|
||||
var results = await ApplyNavigations(context.BaseItems.Where(e => pagedIds.Contains(e.Id)), filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results
|
||||
.Where(e => e is not null)
|
||||
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||
.Where(dto => dto is not null)
|
||||
.ToArray()!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +782,64 @@ public sealed class BaseItemRepository
|
||||
return dbQuery;
|
||||
}
|
||||
|
||||
private List<Guid> ApplyGroupingInMemory<T>(List<T> items, InternalItemsQuery filter)
|
||||
where T : class
|
||||
{
|
||||
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
|
||||
|
||||
// Get properties via reflection since T is anonymous type
|
||||
var idProp = typeof(T).GetProperty("Id");
|
||||
var presentationKeyProp = typeof(T).GetProperty("PresentationUniqueKey");
|
||||
var seriesKeyProp = typeof(T).GetProperty("SeriesPresentationUniqueKey");
|
||||
|
||||
if (idProp == null || presentationKeyProp == null || seriesKeyProp == null)
|
||||
{
|
||||
throw new InvalidOperationException("Required properties not found on type");
|
||||
}
|
||||
|
||||
IEnumerable<T> filtered = items;
|
||||
|
||||
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
|
||||
{
|
||||
filtered = items.DistinctBy(e => new
|
||||
{
|
||||
PresentationKey = presentationKeyProp.GetValue(e),
|
||||
SeriesKey = seriesKeyProp.GetValue(e)
|
||||
});
|
||||
}
|
||||
else if (enableGroupByPresentationUniqueKey)
|
||||
{
|
||||
filtered = items.DistinctBy(e => presentationKeyProp.GetValue(e));
|
||||
}
|
||||
else if (filter.GroupBySeriesPresentationUniqueKey)
|
||||
{
|
||||
filtered = items.DistinctBy(e => seriesKeyProp.GetValue(e));
|
||||
}
|
||||
else
|
||||
{
|
||||
filtered = items.DistinctBy(e => idProp.GetValue(e));
|
||||
}
|
||||
|
||||
return filtered.Select(e => (Guid)idProp.GetValue(e)!).ToList();
|
||||
}
|
||||
|
||||
private List<Guid> ApplyPagingToIds(List<Guid> ids, InternalItemsQuery filter)
|
||||
{
|
||||
IEnumerable<Guid> paged = ids;
|
||||
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
|
||||
{
|
||||
paged = paged.Skip(filter.StartIndex.Value);
|
||||
}
|
||||
|
||||
if (filter.Limit.HasValue && filter.Limit.Value > 0)
|
||||
{
|
||||
paged = paged.Take(filter.Limit.Value);
|
||||
}
|
||||
|
||||
return paged.ToList();
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
|
||||
@@ -1661,11 +1848,14 @@ public sealed class BaseItemRepository
|
||||
ExcludeItemIds = filter.ExcludeItemIds
|
||||
};
|
||||
|
||||
// PERFORMANCE FIX: Use DistinctBy() instead of GroupBy().Select().FirstOrDefault()
|
||||
// to prevent correlated subqueries. Generates DISTINCT ON for PostgreSQL.
|
||||
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter)
|
||||
// Execute query to get IDs, then apply DistinctBy in memory
|
||||
// This avoids EF Core translation errors with DistinctBy
|
||||
var allItems = TranslateQuery(innerQuery, context, outerQueryFilter)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey })
|
||||
.AsEnumerable()
|
||||
.DistinctBy(e => e.PresentationUniqueKey)
|
||||
.Select(e => e.Id);
|
||||
.Select(e => e.Id)
|
||||
.ToList();
|
||||
|
||||
var query = context.BaseItems
|
||||
.Include(e => e.TrailerTypes)
|
||||
@@ -1673,7 +1863,7 @@ public sealed class BaseItemRepository
|
||||
.Include(e => e.LockedFields)
|
||||
.Include(e => e.Images)
|
||||
.AsSingleQuery()
|
||||
.Where(e => masterQuery.Contains(e.Id));
|
||||
.Where(e => allItems.Contains(e.Id));
|
||||
|
||||
query = ApplyOrder(query, filter, context);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user