Skip to content

Fix UseOffsetPaging on a nested EF collection - #573

Merged
lukemurray merged 2 commits into
mainfrom
fix/nested-offset-paging-ef
Oct 9, 2026
Merged

lukemurray merged 2 commits into
mainfrom
fix/nested-offset-paging-ef

Conversation

@lukemurray

@lukemurray lukemurray commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

UseOffsetPaging() on a field that is not on the root query type fails when its collection comes from EF, for example a navigation property:

schema.Type<Actor>()
    .ReplaceField("movies", new { }, (a, _) => a.Movies.OrderByDescending(m => m.Released), "...")
    .UseOffsetPaging();
{ actor(id: 1) { movies(take: 10) { totalItems items { name } } } }
The LINQ expression 'p_Movie => new Dynamic_items_...{ name = p_Movie.Name }' could not be translated.

This happens with any nested offset paging over an EF collection, whether the parent is a single object or a list and whether or not the collection is ordered. It fails on every version I tried, from 5.7.1 through main.

Cause

OffsetPagingItemsExtension pages items with EntityGraphQL's own EnumerableExtensions.Skip/Take(int?). On a root field that is harmless, because the call doesn't depend on a row and EF evaluates it before translating. A nested collection depends on the parent row and is part of the parent's projection, so EF has to translate the call, and it doesn't know these helpers.

Fix

A collection that is not an IQueryable is now paged with System.Linq's Skip/Take. A null skip becomes 0. A null take becomes int.MaxValue - skip, which is "the rest of the collection" as the int? helpers did. It isn't plain int.MaxValue because EF pages a nested collection with ROW_NUMBER() and row <= skip + take, and that sum would overflow an int on SQL Server and Postgres for any skip > 0. The IQueryable path is unchanged.

hasNextPage on a nested collection had the same root cause. It called EnumerableExtensions.PageHasNext, which EF can't translate, so EF loaded every column of the whole collection for every parent and evaluated it in memory. It's now built from System.Linq calls (take.HasValue && source.Skip(skip + take).Any()), which EF translates to an EXISTS query.

Tests

PagingTests (EF/SQLite) gets two tests: a nested page with totalItems/hasNextPage/items under a single-object field, and one with hasNextPage/items only (the EXISTS path, without totalItems) under a list. Both fail without the fix.

PagingSqlTests gets two tests that check the SQL EF runs: nested hasNextPage is an EXISTS and doesn't select unrequested columns, and skip without take has no 2147483647 in it. SQLite's integers are 64-bit, so it can't show the overflow itself. Both fail without the second commit. The EntityGraphQL, AspNet and EF test projects all pass on net8.0, net9.0 and net10.0.

Not covered

Nested UseConnectionPaging() over an EF collection fails the same way. Its edges use the same Skip/Take(int?) helpers, but cursors are also assigned in memory by ConnectionHelper.ApplyCursors, so it still fails once Skip/Take is fixed. That needs a bigger change, so I've left it for a separate fix.

🤖 Generated with Claude Code

A paged collection on a parent object is part of the parent's projection, so EF
has to translate the paging. Items were paged with our Skip/Take(int?) helpers,
which EF does not know, failing with "The LINQ expression 'p_X => new
Dynamic_items...' could not be translated". Use System.Linq's Skip/Take for a
collection that is not an IQueryable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Page

Without a take, items was paged with Take(int.MaxValue). EF pages a nested
collection with ROW_NUMBER() and `row <= skip + take`, which overflows an int
on SQL Server and Postgres for any skip > 0. Take int.MaxValue - skip instead.

hasNextPage on a nested collection called EnumerableExtensions.PageHasNext,
which EF cannot translate, so it loaded every column of the whole collection
for every parent and evaluated it in memory. Build the same check from
System.Linq calls (take.HasValue && Skip(skip + take).Any()) so it is an
EXISTS query.

PagingSqlTests pins both against the SQL EF executes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lukemurray
lukemurray merged commit 1a16919 into main Oct 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant