Skip to content
Merged
Next Next commit
Fix UseConnectionPaging on a nested EF collection (#574)
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>
  • Loading branch information
lukemurray and claude committed Oct 9, 2026
commit bac62874da2435679a24759139d6521cf762bf5f
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@

- #562 - Fixed invalid SDL for argument default values of value types. `Guid`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan` and `char` defaults were printed bare (`runId: ID! = 00000000-0000-0000-0000-000000000000`), which tools like GraphQL Code Generator reject. They are now quoted string literals (dates in ISO format). Numeric defaults are printed with the invariant culture (`3.14`, not `3,14` under `de-DE`) and string defaults are escaped (`"`, `\`, newlines).
- Fixed `UseOffsetPaging()` on a field that is not on the root query type when its collection comes from EF - e.g. `schema.Type<Actor>().ReplaceField("movies", new { }, (a, _) => a.Movies.OrderBy(m => m.Released)).UseOffsetPaging()` - failing with `The LINQ expression 'p_Movie => new Dynamic_items...' could not be translated`. The collection is part of the parent's projection, so EF has to translate the paging, and `items` was paged with EntityGraphQL's own `Skip`/`Take(int?)` helpers, which EF does not know. A collection that is not an `IQueryable` is now paged with `System.Linq`'s `Skip`/`Take` (a null `skip` is `0`, a null `take` is the rest of the collection - `int.MaxValue - skip`, as EF pages a nested collection with `row <= skip + take`, which would overflow an `int` on SQL Server and Postgres). `hasNextPage` on such a field is translated too: EF evaluated EntityGraphQL's `PageHasNext` helper in memory, loading the whole collection for every parent, and it is now an `EXISTS` query. Paging on root query fields was not affected.
- #574 - Fixed `UseConnectionPaging()` on a field that is not on the root query type when its collection comes from EF - e.g. `schema.Type<Actor>().ReplaceField("movies", a => a.Movies.OrderByDescending(m => m.Released)).UseConnectionPaging()` - failing with `The LINQ expression 'edgeNode => ...' could not be translated`. As with offset paging above, the edges are now paged with `System.Linq`'s `Skip`/`Take`. Cursors were assigned by `ConnectionHelper.ApplyCursors` while enumerating the page, which EF can not translate inside a parent's projection, so for such a collection they are now set on the edges after EF materializes them. `last` (without `before`) on such a collection pages from the end of each parent's own collection - it relied on one total count shared by the whole request - and is taken as `Reverse().Take(last)` so EF can translate it, which needs the collection to be ordered. `pageInfo { hasNextPage }` alone is an `EXISTS` query rather than loading the whole collection. Paging on root query fields is unchanged.
- Fixed a connection field paging a collection of the same type at the root (`IQueryable`) and on a parent object sharing one `ConnectionEdgeExtension`, which used whether the first configured field was `IQueryable` for both (`No generic method 'Skip' on type 'QueryableExtensions'`). It is now decided per use.
- Fixed connection paging `pageInfo.hasPreviousPage` being `false` for `last` without `before` when exactly one item comes before the page (e.g. `last: 2` of 3 items).
- Added `IFieldExtension.ProcessExpressionPostSelection` (a default interface method, and virtual on `BaseFieldExtension`), called with a list field's projected result so an extension can wrap it.

# 6.2.4

Expand Down
13 changes: 13 additions & 0 deletions src/EntityGraphQL/Compiler/GqlNodes/GraphQLListSelectionField.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using EntityGraphQL.Compiler.Util;
using EntityGraphQL.Extensions;
using EntityGraphQL.Schema;
using EntityGraphQL.Schema.FieldExtensions;

namespace EntityGraphQL.Compiler;

Expand Down Expand Up @@ -210,6 +211,18 @@ ParameterReplacer replacer
)
: Expression.Call(typeof(Enumerable), nameof(Enumerable.ToList), [resultElementType], resultExpression);

foreach (var extension in Field?.Extensions ?? [])
{
resultExpression = extension.ProcessExpressionPostSelection(
new FieldExtensionPostSelectionContext
{
ResultExpression = resultExpression,
SelectionExpressions = selectionFields,
ServicesPass = contextChanged,
}
);
}

return resultExpression;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ FieldExtensionSelectionContext context
return (context.BaseExpression, context.SelectionExpressions, context.SelectContextParameter);
}

/// <summary>
/// Called after a list field's selection has been built. Lets an extension wrap the projected result
/// </summary>
public virtual Expression ProcessExpressionPostSelection(FieldExtensionPostSelectionContext context)
{
return context.ResultExpression;
}

/// <summary>
/// Called when a scalar field expression is being finalized for execution
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ public class ConnectionEdgeExtension : BaseFieldExtension
{
private readonly Type listType;
private readonly ParameterExpression firstSelectParam;
private readonly bool isQueryable;

/// <param name="listType">The element type of the paged collection</param>
/// <param name="isQueryable">Not used. The edges field is shared by every field paging a collection of listType, root
/// (IQueryable) or nested (e.g. a navigation property), so this is decided per use from the expression</param>
public ConnectionEdgeExtension(Type listType, bool isQueryable)
{
_ = isQueryable;
this.listType = listType;
this.isQueryable = isQueryable;
firstSelectParam = Expression.Parameter(listType, "edgeNode");
}

Expand Down Expand Up @@ -115,6 +117,7 @@ FieldExtensionExpressionContext context
}

arguments ??= new { };
var isQueryable = expression.Type.IsGenericTypeQueryable();

// check and set up arguments
if (arguments.Before != null && arguments.After != null)
Expand Down Expand Up @@ -145,25 +148,60 @@ FieldExtensionExpressionContext context
if (arguments.First == null && arguments.Last == null && pagingExtension.DefaultPageSize != null)
arguments.First = pagingExtension.DefaultPageSize;

Expression? edgeExpression = Expression.Call(
isQueryable ? typeof(QueryableExtensions) : typeof(EnumerableExtensions),
nameof(EnumerableExtensions.Take),
[listType],
Expression.Call(
Expression skipExp = Expression.Call(typeof(ConnectionHelper), nameof(ConnectionHelper.GetSkipNumber), null, argumentParam, Expression.Constant(true));
Expression takeExp = Expression.Call(
typeof(ConnectionHelper),
nameof(ConnectionHelper.GetTakeNumber),
null,
argumentParam,
Expression.Call(typeof(ConnectionHelper), nameof(ConnectionHelper.GetSkipNumber), null, argumentParam, Expression.Constant(false))
);
Expression? edgeExpression;
// A collection on a parent object (e.g. a navigation property) is part of the parent's projection, so a LINQ
// provider like EF has to translate it. A root field's collection is executed by itself, as is the services pass in memory
var nestedProjection = !isQueryable && !servicesPass && fieldNode.ParentNode.IsRootField != true;
if (!nestedProjection)
{
edgeExpression = Expression.Call(
isQueryable ? typeof(QueryableExtensions) : typeof(EnumerableExtensions),
nameof(EnumerableExtensions.Skip),
nameof(EnumerableExtensions.Take),
[listType],
expression,
Expression.Call(typeof(ConnectionHelper), nameof(ConnectionHelper.GetSkipNumber), null, argumentParam, Expression.Constant(true))
),
Expression.Call(
typeof(ConnectionHelper),
nameof(ConnectionHelper.GetTakeNumber),
null,
argumentParam,
Expression.Call(typeof(ConnectionHelper), nameof(ConnectionHelper.GetSkipNumber), null, argumentParam, Expression.Constant(false))
)
);
Expression.Call(isQueryable ? typeof(QueryableExtensions) : typeof(EnumerableExtensions), nameof(EnumerableExtensions.Skip), [listType], expression, skipExp),
takeExp
);
}
else
{
// EF knows System.Linq's Skip/Take but not our int? overloads
if (arguments.Last != null && arguments.BeforeNum == null)
{
// The last N of each parent's own collection. GetSkipNumber/GetTakeNumber use arguments.TotalCount, which
// is one value per request so only works for a root field. Skip(Count() - N) has the outer row in a
// correlated subquery EF can't translate on every provider, so take the first N in reverse order and
// flip them back once materialized (ProcessExpressionPostSelection)
edgeExpression = Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Take),
[listType],
Expression.Call(typeof(Enumerable), nameof(Enumerable.Reverse), [listType], expression),
Expression.Constant((int)arguments.Last)
);
}
else
{
skipExp = Expression.Coalesce(skipExp, Expression.Constant(0));
// No take means the rest of the collection: int.MaxValue - skip rather than int.MaxValue, as EF pages a nested
// collection with ROW_NUMBER() and `row <= skip + take`, which overflows an int on SQL Server/Postgres
takeExp = Expression.Coalesce(takeExp, Expression.Subtract(Expression.Constant(int.MaxValue), skipExp));
edgeExpression = Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Take),
[listType],
Expression.Call(typeof(Enumerable), nameof(Enumerable.Skip), [listType], expression, skipExp),
takeExp
);
}
}

// we have moved the expression from the parent node to here. We need to call the before callback
if (fieldNode.ParentNode?.IsRootField == true)
Expand Down Expand Up @@ -217,6 +255,7 @@ FieldExtensionSelectionContext context
// we now know the fields they want to select so we rebuild the base expression
// remove the above Select(new ConnectionEdge<T>(), ...)
baseExpression = ((MethodCallExpression)baseExpression).Arguments[0];
var isQueryable = baseExpression.Type.IsGenericTypeQueryable();
// remove null check as it is not required
var nodeField = selectionExpressions.First(f => f.Key.SchemaName == "node").Value;
var anonNewExpression = nodeField.Expression;
Expand All @@ -236,14 +275,84 @@ FieldExtensionSelectionContext context
Expression.Lambda(Expression.MemberInit(Expression.New(edgeType), new List<MemberBinding> { Expression.Bind(edgeType.GetProperty("Node")!, newNodeExpression) }), firstSelectParam)
);

// assign cursors while enumerating (in memory - this is what executes the DB query above).
// ApplyCursors computes the argument-dependent cursor base once per request rather than per row.
baseExpression = Expression.Call(typeof(ConnectionHelper), nameof(ConnectionHelper.ApplyCursors), [anonType], baseExpression, argumentParam);

nodeField.Expression = Expression.PropertyOrField(edgeParam, "Node");
if (selectionExpressions.Any(f => f.Key.Name == "cursor"))
selectionExpressions.First(f => f.Key.Name == "cursor").Value.Expression = Expression.PropertyOrField(edgeParam, "Cursor");
if (FindPagingCall(baseExpression).pagingCall == null)
{
// assign cursors while enumerating (in memory - this is what executes the DB query above).
// ApplyCursors computes the argument-dependent cursor base once per request rather than per row.
baseExpression = Expression.Call(typeof(ConnectionHelper), nameof(ConnectionHelper.ApplyCursors), [anonType], baseExpression, argumentParam);
foreach (var cursorField in selectionExpressions.Where(f => f.Key.SchemaName == "cursor"))
cursorField.Value.Expression = Expression.PropertyOrField(edgeParam, "Cursor");
}
else
{
// A nested collection is part of the parent's projection and the provider (e.g. EF) can not translate
// ApplyCursors there. Select no cursor here and set it once the edges are a list - see ProcessExpressionPostSelection
foreach (var cursorField in selectionExpressions.Where(f => f.Key.SchemaName == "cursor"))
cursorField.Value.Expression = Expression.Constant(null, typeof(string));
}

return (baseExpression, selectionExpressions, edgeParam);
}

public override Expression ProcessExpressionPostSelection(FieldExtensionPostSelectionContext context)
{
var resultExpression = context.ResultExpression;
if (context.ServicesPass)
return resultExpression;
// only the nested case left the cursors to us - see ProcessExpressionSelection
var cursorFields = context.SelectionExpressions.Where(f => f.Key.SchemaName == "cursor" && f.Value.Expression is ConstantExpression { Value: null }).Select(f => f.Key.Name).ToArray();
var elementType = resultExpression.Type.GetEnumerableOrArrayType();
if (cursorFields.Length == 0 || elementType == null || !typeof(List<>).MakeGenericType(elementType).IsAssignableFrom(resultExpression.Type))
return resultExpression;

// The edges of a page are rows skip + 1, skip + 2, ... of the collection. Use the paging built in GetExpressionAndArguments
// as it is per parent for `last`. EF translates it alongside the page and calls SetCursors with the materialized list
var (pagingCall, takeArg) = FindPagingCall(resultExpression);
if (pagingCall == null)
return resultExpression;
Expression skip;
if (pagingCall.Method.Name == nameof(Enumerable.Reverse))
{
var count = Expression.Call(typeof(Enumerable), nameof(Enumerable.Count), [listType], pagingCall.Arguments[0]);
skip = Expression.Condition(Expression.GreaterThan(count, takeArg!), Expression.Subtract(count, takeArg!), Expression.Constant(0));
}
else
skip = pagingCall.Arguments[1];

// field names as a string - EF rejects non-primitive constants passed to a method in a client projection
return Expression.Call(
typeof(ConnectionHelper),
nameof(ConnectionHelper.SetCursors),
[elementType],
resultExpression,
skip,
Expression.Constant(pagingCall.Method.Name == nameof(Enumerable.Reverse)),
Expression.Constant(string.Join(",", cursorFields))
);
}

/// <summary>
/// Finds the Skip() (or Reverse() for last) built for a nested collection in GetExpressionAndArguments, and the Take()
/// count above it. Null if the paging was not built for a nested collection
/// </summary>
private (MethodCallExpression? pagingCall, Expression? takeArg) FindPagingCall(Expression expression)
{
while (expression is MethodCallExpression call)
{
// the paging Take() is the outermost one on the collection
if (call.Method.Name == nameof(Enumerable.Take) && call.Method.IsGenericMethod && call.Method.GetGenericArguments()[0] == listType)
{
if (
call.Method.DeclaringType == typeof(Enumerable)
&& call.Arguments[0] is MethodCallExpression { Method.Name: nameof(Enumerable.Skip) or nameof(Enumerable.Reverse) } paging
&& paging.Method.DeclaringType == typeof(Enumerable)
)
return (paging, call.Arguments[1]);
return (null, null);
}
expression = call.Arguments[0];
}
return (null, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@ public static System.Collections.Generic.IEnumerable<ConnectionEdge<TEntity>> Ap
}
}

/// <summary>
/// Used at runtime for a nested collection: sets the cursor fields of the already-projected edges. The edges are
/// rows skip + 1, skip + 2, ... of the collection. reverse when the page was taken in reverse order (last).
/// cursorFields is a comma separated list of field names
/// </summary>
public static System.Collections.Generic.List<TEdge>? SetCursors<TEdge>(System.Collections.Generic.List<TEdge>? edges, int skip, bool reverse, string cursorFields)
{
if (edges == null)
return null;
if (reverse)
edges.Reverse();
var fields = Array.ConvertAll(cursorFields.Split(','), name => typeof(TEdge).GetField(name)!);
for (var i = 0; i < edges.Count; i++)
{
var cursor = SerializeCursor(skip + i + 1);
foreach (var field in fields)
field.SetValue(edges[i], cursor);
}
return edges;
}

/// <summary>
/// Used at runtime in the expression built above
/// </summary>
Expand All @@ -132,13 +153,24 @@ public static System.Collections.Generic.IEnumerable<ConnectionEdge<TEntity>> Ap
/// forward paging (first/after) - see ConnectionPagingExtension.
/// </summary>
public static bool PageHasNext<TSource>(System.Linq.IQueryable<TSource> source, dynamic arguments)
{
int? skip = GetHasNextSkip(arguments);
if (skip == null)
return false; // no page size = the whole remaining collection was returned
return System.Linq.Queryable.Any(System.Linq.Queryable.Skip(source, skip.Value));
}

/// <summary>
/// Used at runtime: how many items to skip to check for an item past the current page (forward paging), null when
/// there is no page size so the whole remaining collection was returned
/// </summary>
public static int? GetHasNextSkip(dynamic arguments)
{
int? first = arguments.First;
if (first == null)
return false; // no page size = the whole remaining collection was returned
return null;
string? after = arguments.After;
int skip = (DeserializeCursor(after) ?? 0) + first.Value;
return System.Linq.Queryable.Any(System.Linq.Queryable.Skip(source, skip));
return (DeserializeCursor(after) ?? 0) + first.Value;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,6 @@ public string StartCursor
public bool HasNextPage => hasNextPageOverride ?? (arguments.First != null ? ((arguments.AfterNum ?? 0) + arguments.First) < totalCount : arguments.BeforeNum < totalCount);

[Description("If there is data previous to this page")]
public bool HasPreviousPage => (arguments.AfterNum ?? 0) > 0 || (arguments.BeforeNum ?? totalCount) - (arguments.Last ?? totalCount) > 1;
// without before, last counts back from just past the end - as StartCursor
public bool HasPreviousPage => (arguments.AfterNum ?? 0) > 0 || (arguments.BeforeNum ?? (totalCount + 1)) - (arguments.Last ?? totalCount) > 1;
}
Loading
Loading