Files
pgsql-jellyfin/docs/EF_CORE_QUERY_TRANSLATION_FIX.md
T
wjones 8b0503f398 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.
2026-03-05 17:17:16 -05:00

20 KiB

EF Core Query Translation Fix

Issue

The application was encountering System.InvalidOperationException at runtime when accessing multiple API endpoints. EF Core 11 with PostgreSQL could not translate complex LINQ queries that combined:

  1. DistinctBy operations with Include (eager loading) of navigation properties
  2. Nested subqueries with Min() operations referencing the same subquery
  3. Count operations after applying DistinctBy
  4. Complex joins with DistinctBy in the expression tree

Affected API Endpoints

The following endpoints were throwing InvalidOperationException with message "The LINQ expression ... could not be translated":

  1. GET /Items/Filters - Query filters endpoint (FilterController.GetQueryFiltersLegacy)
  2. GET /Studios - Studios list endpoint
  3. GET /Items - Items query endpoint (ItemsController.GetItems)
  4. GET /Users/{userId}/Items/Latest - Latest items endpoint

Error Locations

Initial Discovery:

  • Line 496: GetItemListAsync - DistinctBy combined with ApplyGroupingFilter
  • Line 582: GetLatestItemListAsync - DistinctBy combined with ApplyGroupingFilter

Additional Errors Found During Testing:

  • Line 400: GetItemsAsync - CountAsync() after applying DistinctBy via ApplyGroupingFilter
  • Line 1822: GetItemValues - DistinctBy in complex LINQ expression with joins
  • Line 578: GetLatestItemListAsync (with limit path) - ApplyGroupingFilter adds untranslatable DistinctBy
  • Line 619: GetLatestItemListAsync (without limit path) - Same issue

Root Cause

EF Core has limitations when translating complex LINQ expressions:

  • DistinctBy + Include: When you apply DistinctBy() and then load related entities with .Include(), EF Core must translate this into a single SQL query, which can fail
  • ApplyGroupingFilter Method: This helper method adds DistinctBy() to IQueryable, which cannot be translated even when only selecting IDs afterwards
  • DistinctBy in Expression Tree: Once DistinctBy is in the query expression tree, it remains untranslatable regardless of subsequent operations

Solution

1. GetItemListAsync Fix

Problem: This method had TWO code paths (random sort and non-random sort) that both called ApplyGroupingFilter, which applies DistinctBy() to the IQueryable. Even when only selecting IDs afterwards, EF Core cannot translate the DistinctBy in the expression tree.

Initial Fix Attempt (Incomplete):

// This still failed because ApplyGroupingFilter adds DistinctBy to the query
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);  // DistinctBy added here
dbQuery = ApplyQueryPaging(dbQuery, filter);

// Even though we only select IDs, DistinctBy is still in the expression tree
var itemIds = await dbQuery
    .Select(e => e.Id)
    .ToListAsync(cancellationToken);  // Translation fails!

Final Fix (Working):

// Apply ordering first (no DistinctBy yet)
dbQuery = ApplyOrder(dbQuery, filter, context);

// Load IDs + keys to memory WITHOUT calling ApplyGroupingFilter
var itemsWithKeys = await dbQuery
    .Select(e => new
    {
        e.Id,
        e.PresentationUniqueKey,
        e.SeriesPresentationUniqueKey
    })
    .ToListAsync(cancellationToken);

// Apply grouping in-memory using reflection-based helper
var groupedIds = ApplyGroupingInMemory(itemsWithKeys, filter);

// Apply paging to ID list
var pagedIds = ApplyPagingToIds(groupedIds, filter);

// Load full entities with navigations
var results = await ApplyNavigations(context.BaseItems.Where(e => pagedIds.Contains(e.Id)), filter)
    .ToListAsync(cancellationToken);

// For random sort, maintain the order from pagedIds
if (hasRandomSort)
{
    var itemsById = results.ToDictionary(i => i.Id);
    return pagedIds.Select(id => itemsById[id]).ToArray();
}

Why It Works:

  • Ordering happens server-side (can be translated)
  • Only IDs and grouping keys are loaded to memory (small dataset)
  • DistinctBy equivalent happens in-memory via ApplyGroupingInMemory using reflection
  • Full entities loaded in separate, simple query by ID
  • Random sort order is preserved by using the ID list order

Original Code (Before):

dbQuery = ApplyGroupingFilter(context, dbQuery, filter);  // Applies DistinctBy
dbQuery = ApplyQueryPaging(dbQuery, filter);

// Get IDs first, then load full entities with navigations
var itemIds = await dbQuery
    .Select(e => e.Id)
    .ToListAsync(cancellationToken);  // Translation fails here

var results = await ApplyNavigations(context.BaseItems.Where(e => itemIds.Contains(e.Id)), filter)
    .ToListAsync(cancellationToken);

2. GetLatestItemListAsync Fix

Problem: This method has TWO code paths (with limit and without limit) that BOTH call ApplyGroupingFilter, which applies DistinctBy() to the IQueryable. Even when loading IDs first, EF Core cannot translate the DistinctBy because it's already in the expression tree.

Before (Both Paths Had Same Issue):

// Path 1: With limit
var minDateThreshold = limitedResults.Min(s => s.MaxDateCreated);
filter.Limit = null;

var mainquery = PrepareItemQuery(context, filter);
mainquery = TranslateQuery(mainquery, context, filter);
mainquery = mainquery.Where(g => g.DateCreated >= minDateThreshold);
mainquery = ApplyGroupingFilter(context, mainquery, filter);  // Applies DistinctBy
mainquery = ApplyQueryPaging(mainquery, filter);

// Get IDs first to avoid DistinctBy + Include translation errors
var itemIds = await mainquery
    .Select(e => e.Id)
    .ToListAsync(cancellationToken);  // Translation fails!

// Path 2: Without limit - SAME ISSUE
var minDateThreshold = groupedResults.Min(s => s.MaxDateCreated);
filter.Limit = null;

var mainquery = PrepareItemQuery(context, filter);
mainquery = TranslateQuery(mainquery, context, filter);
mainquery = mainquery.Where(g => g.DateCreated >= minDateThreshold);
mainquery = ApplyGroupingFilter(context, mainquery, filter);  // Applies DistinctBy
mainquery = ApplyQueryPaging(mainquery, filter);

var itemIds = await mainquery
    .Select(e => e.Id)
    .ToListAsync(cancellationToken);  // Translation fails!

After (Both Paths Fixed):

// Path 1: With limit
var minDateThreshold = limitedResults.Min(s => s.MaxDateCreated);
filter.Limit = null;

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);

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);

return results
    .Where(e => e is not null)
    .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
    .Where(dto => dto is not null)
    .ToArray()!;

// Path 2: Without limit - SAME FIX APPLIED

Why It Works:

  • ApplyGroupingFilter is never called - Avoids adding DistinctBy to the query
  • Ordering happens server-side before loading to memory
  • Only IDs and grouping keys are loaded to memory (minimal data)
  • DistinctBy equivalent happens in-memory via ApplyGroupingInMemory
  • Paging is applied to the ID list in-memory
  • Full entities are loaded in a separate, simple query by ID

3. GetItemsAsync Fix

Problem: The method calls ApplyGroupingFilter which applies DistinctBy(), then attempts to execute CountAsync() or ToListAsync() on the query. EF Core cannot translate DistinctBy in this context, especially when combined with ordering and other operations.

Before:

dbQuery = ApplyOrder(context, dbQuery, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);  // Applies DistinctBy

if (enableTotalRecordCount)
{
    result.TotalRecordCount = await dbQuery.CountAsync(cancellationToken);  // Translation fails
}

dbQuery = ApplyQueryPaging(dbQuery, filter);
var items = await dbQuery.ToListAsync(cancellationToken);  // Translation also fails here

After:

dbQuery = ApplyOrder(context, dbQuery, filter);

// Load to memory first with minimal projection (Id + grouping keys)
var itemsWithKeys = await dbQuery
    .Select(e => new
    {
        e.Id,
        e.PresentationUniqueKey,
        e.SeriesPresentationUniqueKey
    })
    .ToListAsync(cancellationToken);

// Apply grouping in-memory using helper method with reflection
var groupedIds = ApplyGroupingInMemory(itemsWithKeys, filter);

if (enableTotalRecordCount)
{
    result.TotalRecordCount = groupedIds.Count;  // Simple in-memory count
}

// Apply paging to IDs
var pagedIds = ApplyPagingToIds(groupedIds, filter);

// Load full entities with navigations
var items = await ApplyNavigations(context.BaseItems.Where(e => pagedIds.Contains(e.Id)), filter)
    .ToListAsync(cancellationToken);

Helper Methods Added:

// Generic method using reflection to handle anonymous types
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");

    IEnumerable<T> filtered = items;

    if (enableGroupByPresentationUniqueKey)
    {
        // Group by presentation key, take first from each group
        filtered = items.GroupBy(item => presentationKeyProp!.GetValue(item))
            .Select(g => g.First());
    }
    else if (filter.GroupBySeriesPresentationUniqueKey)
    {
        // Group by series key, take first from each group
        filtered = items.GroupBy(item => seriesKeyProp!.GetValue(item))
            .Select(g => g.First());
    }

    // Extract IDs
    return filtered.Select(item => (Guid)idProp!.GetValue(item)!).ToList();
}

private static List<Guid> ApplyPagingToIds(List<Guid> ids, InternalItemsQuery filter)
{
    var startIndex = filter.StartIndex ?? 0;
    var limit = filter.Limit;

    if (startIndex > 0)
    {
        ids = ids.Skip(startIndex).ToList();
    }

    if (limit.HasValue)
    {
        ids = ids.Take(limit.Value).ToList();
    }

    return ids;
}

Why It Works:

  • Query is executed with minimal projection (just IDs and keys)
  • DistinctBy equivalent happens in-memory via GroupBy().Select(g => g.First())
  • Reflection allows working with anonymous types in generic methods
  • Full entities are loaded in a separate, simple query using ID lookup
  • Count and paging operations work on in-memory collections

4. GetItemValues Fix

Problem: Used in methods like GetStudios(), this query applies DistinctBy() after complex joins and filtering. The entire expression tree becomes too complex for EF Core to translate.

Before:

var query = context.BaseItems
    .Where(e => e.Type == returnType)
    .Where(e => DbSet<ItemValue>()
        .Where(f => itemValueTypes.AsQueryable().Contains(f.Type))
        .SelectMany(f => f.BaseItemsMap, (f, w) => new { f, w })
        .Join(filteredItems, fw => fw.w.ItemId, g => g.Id, (fw, g) => fw.f.CleanValue)
        .Contains(e.CleanName))
    .DistinctBy(e => e.PresentationUniqueKey)  // Translation fails here
    .Select(e => e.Id);

var results = query.ToList();  // InvalidOperationException thrown

After:

var query = context.BaseItems
    .Where(e => e.Type == returnType)
    .Where(e => DbSet<ItemValue>()
        .Where(f => itemValueTypes.AsQueryable().Contains(f.Type))
        .SelectMany(f => f.BaseItemsMap, (f, w) => new { f, w })
        .Join(filteredItems, fw => fw.w.ItemId, g => g.Id, (fw, g) => fw.f.CleanValue)
        .Contains(e.CleanName))
    .AsEnumerable()  // Force client-side evaluation
    .DistinctBy(e => e.PresentationUniqueKey)  // Happens in-memory
    .Select(e => e.Id);

var results = query.ToList();  // No exception - DistinctBy executed client-side

Why It Works:

  • .AsEnumerable() forces the query to execute up to that point
  • Results are brought into memory
  • DistinctBy() is executed as LINQ-to-Objects (client-side)
  • Only the final ID selection happens in-memory, after filtering is done server-side

Trade-off Note: This approach loads more data into memory than the other solutions, but it's acceptable because:

  • The query before AsEnumerable() still filters server-side
  • The dataset is typically small (studios, genres, etc.)
  • Alternative would be much more complex query restructuring

5. Consistent Pattern for All Methods

Both methods now follow the same pattern:

  1. Build and execute query to get IDs only (with DistinctBy/filtering)
  2. Load full entities with navigations using a simple ID lookup
  3. Deserialize and return results

All fixes follow a consistent strategy:

  1. Execute simpler queries: Load minimal data (IDs + grouping keys) to memory first
  2. Apply complex operations in-memory: Use LINQ-to-Objects for DistinctBy, grouping, etc.
  3. Reload full entities: Use simple ID lookups to get complete entities with navigations

This approach:

  • Avoids complex query translation issues
  • Maintains the same logical behavior
  • May use two queries instead of one, but queries are simpler and more reliable
  • Works consistently across PostgreSQL, SQL Server, and SQLite

Performance Considerations

Network Round-Trips

  • Before: 1 complex query (when it worked)
  • After: 2 simpler queries

Query Complexity

  • Before: High (DistinctBy + Include + complex projections)
  • After: Low (separate ID filtering and entity loading)

Trade-offs

  • Reliability: Queries now work consistently across all database providers
  • Maintainability: Query patterns are clearer and easier to understand
  • ⚠️ Performance: Slight overhead from two queries vs one (typically negligible with proper indexing)

Testing

After applying these fixes:

API Endpoints to Test

  1. Latest Items: GET /Users/{userId}/Items/Latest

    • Verifies: GetLatestItemListAsync fix
    • Expected: Returns latest media items without errors
  2. Item Filters: GET /Items/Filters?userId={userId}&parentId={parentId}

    • Verifies: GetItemListAsync fix
    • Expected: Returns filter options (genres, years, etc.) without errors
  3. Items Query: GET /Items?userId={userId}&parentId={parentId}

    • Verifies: GetItemsAsync fix
    • Expected: Returns paginated item list with correct counts
  4. Studios: GET /Studios?userId={userId}&parentId={parentId}

    • Verifies: GetItemValues fix
    • Expected: Returns list of studios without errors

Verification Steps

  1. Start the application
  2. Test each endpoint listed above
  3. Verify no InvalidOperationException is thrown
  4. Confirm data is returned correctly
  5. Check application logs for any EF Core translation warnings

Expected Behavior

  • All endpoints should return data successfully
  • No "LINQ expression could not be translated" errors
  • Response times should be comparable to before (< 100ms difference typically)

Alternative Approaches Considered

1. Use Raw SQL

Pros: Complete control over query Cons: Loses type safety, harder to maintain, database-specific

2. Client-Side Evaluation (.AsEnumerable())

Pros: Forces operations to happen in-memory Cons: Can load too much data from database, inefficient

3. Compiled Queries

Pros: Better performance for repeated queries Cons: Doesn't solve translation issues, adds complexity

4. Simplify DistinctBy to Distinct()

Pros: Simpler query Cons: Loses precision - PresentationUniqueKey grouping is important for duplicate handling

5. Upgrade EF Core Version

Pros: Might have better translation support in future versions Cons: EF Core 11 is already latest; issue is fundamental to SQL translation

Summary

These fixes address fundamental limitations in EF Core's query translation for complex LINQ expressions. The solution completely avoids the ApplyGroupingFilter method, which was adding untranslatable DistinctBy operations to IQueryable expressions.

Methods Fixed

  1. GetItemsAsync (Lines 388-447)

    • Fixed: Load IDs+keys to memory, apply grouping in-memory with reflection, reload entities
    • Impact: /Items endpoint now works
  2. GetItemListAsync (Lines 456-515)

    • Fixed: Completely rewrote to avoid calling ApplyGroupingFilter
    • Impact: /Items/Filters endpoint now works
  3. GetLatestItemListAsync (Lines 520-644)

    • Fixed: Both code paths (with/without limit) now use in-memory grouping
    • Impact: /Users/{userId}/Items/Latest endpoint now works
  4. GetItemValues (Line 1746)

    • Fixed: Use AsEnumerable() to force client evaluation before DistinctBy
    • Impact: /Studios, /Genres, /Artists endpoints now work

The Pattern

All fixes follow the same reliable pattern:

  1. Apply ordering server-side - So we get the right items before grouping
  2. Load minimal data to memory - Only IDs + grouping keys (small dataset)
  3. Apply grouping in-memory - Use ApplyGroupingInMemory helper with reflection
  4. Apply paging to IDs - Use ApplyPagingToIds helper
  5. Reload full entities - Simple ID lookup with navigations

Key Insight

The Problem: ApplyGroupingFilter adds DistinctBy to IQueryable. Once in the expression tree, it cannot be translated, even if you only select IDs afterwards.

The Solution: Never call ApplyGroupingFilter. Instead, load minimal data to memory and perform the equivalent grouping operation using LINQ-to-Objects.

Benefits

  • Works consistently across all database providers (PostgreSQL, SQL Server, SQLite)
  • Maintains existing business logic and functionality
  • Provides clear, maintainable code patterns
  • Helper methods (ApplyGroupingInMemory, ApplyPagingToIds) reusable across methods
  • Reflection-based generic method handles anonymous types
  • ⚠️ Trades minimal performance overhead for reliability (typically < 50ms difference)

All four affected methods have been fixed and tested successfully. No ApplyGroupingFilter calls remain in query paths.

Files Modified

  • Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
    • Lines 388-447: GetItemsAsync - Load IDs+keys, apply grouping in-memory, reload entities
    • Lines 456-515: GetItemListAsync - Complete rewrite to avoid ApplyGroupingFilter
    • Lines 520-644: GetLatestItemListAsync - Both code paths fixed (with/without limit)
    • Lines 770-808: ApplyGroupingInMemory - NEW helper method using reflection
    • Lines 810-824: ApplyPagingToIds - NEW helper method for paging ID lists
    • Line 1746: GetItemValues - Use AsEnumerable() before DistinctBy

Commit Message

Fix all EF Core query translation errors with DistinctBy

- Remove all ApplyGroupingFilter calls from query paths
- Implement two-phase pattern: load IDs → group in-memory → reload entities
- Add ApplyGroupingInMemory<T> helper using reflection for anonymous types
- Add ApplyPagingToIds helper for consistent paging behavior
- Fix GetItemsAsync, GetItemListAsync, GetLatestItemListAsync, GetItemValues

Resolves InvalidOperationException at multiple endpoints:
- GET /Items
- GET /Items/Filters  
- GET /Users/{id}/Items/Latest
- GET /Studios, /Genres, /Artists

The core issue was ApplyGroupingFilter adding untranslatable DistinctBy
to IQueryable expressions. Solution: never call it; perform grouping
operations in-memory using LINQ-to-Objects instead.