Skip to content

Fix UseConnectionPaging on a nested EF collection - #576

Merged
lukemurray merged 7 commits into
mainfrom
fix/nested-connection-paging-ef
Oct 10, 2026
Merged

lukemurray merged 7 commits into
mainfrom
fix/nested-connection-paging-ef

Conversation

@lukemurray

Copy link
Copy Markdown
Collaborator

Fixes #574.

Problem

UseConnectionPaging() on a field that isn't on the root query type failed when its collection comes from EF (e.g. a navigation property) with The LINQ expression 'edgeNode => ...' could not be translated. The collection is part of the parent's projection, so EF has to translate the paging, and it couldn't.

Changes

  • Skip/Take: edges on a nested collection are paged with System.Linq's Skip/Take, the same as Fix UseOffsetPaging on a nested EF collection #573 did for offset paging.
  • Cursors: ApplyCursors assigns cursors while it enumerates the page, and EF can't translate it inside a projection. For a nested collection the cursor is now selected as null, and ConnectionHelper.SetCursors fills it in once EF has materialised the edges (row skip + i + 1). EF runs a client method in the final projection when it gets a materialised collection.
  • New extension hook: IFieldExtension.ProcessExpressionPostSelection lets an extension wrap a list field's projected result. It's a default interface method and a virtual on BaseFieldExtension, so existing extensions don't break.
  • last without before: this read arguments.TotalCount, a single value for the whole request, so it can't work per parent. Each parent's page now comes from the end of its own collection via Reverse().Take(last), and the page is flipped back after it's materialised. Skip(Count() - last) doesn't work: EF puts the outer row into a correlated subquery inside the LEFT JOIN, which SQLite rejects (and other providers would need APPLY). Reverse() needs an ordered collection, but last has no defined meaning on an unordered one anyway.
  • pageInfo { hasNextPage } alone: this called PageHasNext, which EF would have run in memory over the whole collection. It's now Skip(...).Any(), which translates to EXISTS.
  • Shared edges field: every field that pages the same type shares one edges field (and one ConnectionEdgeExtension). The extension used the isQueryable of whichever field was configured first, so a root IQueryable connection plus a nested one of the same type failed (No generic method 'Skip' on type 'QueryableExtensions'). It's now decided each time from the expression. The constructor parameter is kept for API compatibility and no longer used.
  • hasPreviousPage off by one: for last without before it was false when exactly one item comes before the page (e.g. last: 2 of 3). This affected root fields too.

Root field paging builds the same expressions as before. The only change on root fields is the hasPreviousPage fix. Nothing is breaking: public methods keep their signatures and the additions are new members.

Tests

  • PagingTests (EF/SQLite), all with nested collections:
    • first with totalCount and pageInfo
    • an aliased cursor
    • after
    • per-parent last on two actors with different counts
    • last + before
    • root and nested paging of the same type in one query
  • PagingSqlTests:
    • nested connection paging is a single query paged in SQL (ROW_NUMBER) with unused columns pruned, for both first/after and last
    • nested hasNextPage alone is EXISTS, with no COUNT
  • PageInfoTests: the hasPreviousPage off-by-one.

Full suite passes on net8.0, net9.0 and net10.0.

Note: dotnet tool restore fails on main. .config/dotnet-tools.json lists the command dotnet-csharpier, but csharpier 1.0.0 names it csharpier. I formatted with dnx csharpier@1.0.0 and left the manifest alone.

🤖 Generated with Claude Code

lukemurray and others added 4 commits October 9, 2026 19:35
A connection paged collection on a parent object (e.g. a navigation
property) is part of the parent's projection, so EF has to translate the
paging. It failed with "The LINQ expression 'edgeNode => ...' could not
be translated":

- edges were paged with our Skip/Take(int?) helpers. Use System.Linq's,
  as #573 did for offset paging.
- cursors were assigned by ApplyCursors while enumerating, which EF can't
  translate inside a projection. For a nested collection the cursor is
  selected as null and set by ConnectionHelper.SetCursors once EF has
  materialized the edges, via a new IFieldExtension.ProcessExpressionPostSelection
  hook (default interface method / virtual on BaseFieldExtension).
- last without before used arguments.TotalCount, one value per request.
  Page from the end of each parent's own collection with Reverse().Take(last)
  (Skip(Count() - last) puts the outer row in a correlated subquery SQLite
  and others reject) and flip the page back after materializing.
- pageInfo { hasNextPage } alone called PageHasNext, which EF would run in
  memory over the whole collection. Build Skip().Any() so it is EXISTS.

Also:
- the edges field (and its ConnectionEdgeExtension) is shared by every field
  paging the same type, and used whether the first one configured was
  IQueryable for all. Decide it per use from the expression.
- hasPreviousPage was false for last without before when exactly one item
  precedes the page.

Root field paging is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- last with after on a nested collection took the last N of the whole
  collection, ignoring after (last: 5, after: 8 gave Movie6-10, not
  Movie9-10). Skip(after).Reverse() needs SQL APPLY, which SQLite does
  not support, so keep Reverse().Take(last) in SQL and have SetCursors
  drop the rows up to after, using the per-parent Count() EF projects.
- cache the cursor FieldInfos per (type, field names) in SetCursors.
- obsolete ConnectionEdgeExtension(Type, bool) - isQueryable is unused -
  for ConnectionEdgeExtension(Type), to be removed in 7.0.
- document ProcessExpressionPostSelection in the custom extension docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Like the pre-selection, selection and scalar hooks, the extension loop lives in
BaseGraphQLField (ProcessExtensionsPostSelection) rather than inline in
GraphQLListSelectionField.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lukemurray and others added 3 commits October 9, 2026 20:35
… one EXISTS

- last + before with the cursor past the end of the collection returned too few
  items. Root paging limits before to the counted total; ConnectionPageInfo does
  the same with its own count. A nested collection's parents share the cursor, so
  each parent's page is picked from its own count: the page ending at the cursor,
  or its last items (also selected) when it does not reach the cursor. Taking the
  first before-1 then reversing needs SQL APPLY, which SQLite lacks.
- hasNextPage with before was false when the cursor is the last item.
- A non-null field read off an object built in the same expression (pageInfo) is
  not null checked, so EF no longer translates its EXISTS twice.

Checked against SQLite, Postgres 16 and SQL Server 2022.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lection

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
4ac897a skipped it for any non-null field read off an object built in the
expression. A user field like new Wrapper { Inner = null }.Inner declared
non-null then failed with a NullReferenceException instead of keeping its
previous behaviour. Limit it to Connection<T>.PageInfo, which is never null.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lukemurray
lukemurray merged commit 9847158 into main Oct 10, 2026
1 check passed
lukemurray added a commit that referenced this pull request Oct 10, 2026
#577 merged into the #576 branch after 6.3.0 was released, so its fix is in
6.3.1 rather than 6.3.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

UseConnectionPaging on a nested EF collection fails: LINQ expression could not be translated

1 participant