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:
2026-03-05 17:17:16 -05:00
parent 7eb2b445cb
commit 8b0503f398
3 changed files with 774 additions and 47 deletions
@@ -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);