From ac34a90b0f797e7da2df81abe5ead5751e145280 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 28 Sep 2026 20:35:58 +1000 Subject: [PATCH 1/2] Remove a resolver's query splitting when the projection loads no collection A resolver can call AsSplitQuery for the collections a request might select. When the request selects none, the select projection loads no collection, so the split does nothing, and Verify.EntityFramework 16 throws for it. ApplyProjection now removes AsSplitQuery and AsSingleQuery in that case. With a collection selected, or when projection falls back to includes, the query is left alone. --- .../IncludeAppender.cs | 47 +++++++++++ src/GraphQL.EntityFramework/QuerySplitting.cs | 43 ++++++++++ ...n_split_query_with_collection.verified.txt | 84 +++++++++++++++++++ ...plit_query_without_collection.verified.txt | 39 +++++++++ .../IntegrationTests.SchemaPrint.verified.txt | 12 +++ ...IntegrationTests_connection_split_query.cs | 49 +++++++++++ src/Tests/IntegrationTests/Query.cs | 8 ++ 7 files changed, 282 insertions(+) create mode 100644 src/GraphQL.EntityFramework/QuerySplitting.cs create mode 100644 src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_with_collection.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_without_collection.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests_connection_split_query.cs diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index 7145a3ad..6ef4169f 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -39,6 +39,11 @@ public IQueryable ApplyProjection( return AddIncludesFromProjection(query, projection); } + if (!LoadsCollection(projection)) + { + query = QuerySplitting.Remove(query); + } + foreach (var includePath in includePaths) { query = query.Include(includePath); @@ -49,6 +54,48 @@ public IQueryable ApplyProjection( return query.Select(expression); } + /// + /// Whether the projection loads a collection at any depth, the only case where query splitting + /// changes anything. + /// + static bool LoadsCollection(FieldProjectionInfo projection) + { + if (projection.Navigations is not null && + LoadsCollection(projection.Navigations)) + { + return true; + } + + if (projection.DerivedNavigations is null) + { + return false; + } + + foreach (var navigations in projection.DerivedNavigations.Values) + { + if (LoadsCollection(navigations)) + { + return true; + } + } + + return false; + } + + static bool LoadsCollection(Dictionary navigations) + { + foreach (var navigation in navigations.Values) + { + if (navigation.IsCollection || + LoadsCollection(navigation.Projection)) + { + return true; + } + } + + return false; + } + IQueryable AddIncludesFromProjection( IQueryable query, FieldProjectionInfo projection) diff --git a/src/GraphQL.EntityFramework/QuerySplitting.cs b/src/GraphQL.EntityFramework/QuerySplitting.cs new file mode 100644 index 00000000..217608ba --- /dev/null +++ b/src/GraphQL.EntityFramework/QuerySplitting.cs @@ -0,0 +1,43 @@ +/// +/// A resolver can call AsSplitQuery or AsSingleQuery for the collections a request might select. +/// When the select projection loads no collection they do nothing, and EF ignores them, so they +/// are removed. +/// +static class QuerySplitting +{ + public static IQueryable Remove(IQueryable queryable) + { + var expression = Remove(queryable.Expression); + if (expression == queryable.Expression) + { + return queryable; + } + + return queryable.Provider.CreateQuery(expression); + } + + static Expression Remove(Expression expression) + { + // Queryable operators, and extensions such as AsSplitQuery, take the query they build on + // as their first argument. Unlike ordering, splitting never decides which rows a Skip or + // Take keeps, so the whole chain is walked. + if (expression is not MethodCallExpression { Object: null, Arguments.Count: > 0 } methodCall || + !typeof(IQueryable).IsAssignableFrom(methodCall.Arguments[0].Type)) + { + return expression; + } + + if (methodCall.Method.Name is "AsSplitQuery" or "AsSingleQuery") + { + return Remove(methodCall.Arguments[0]); + } + + var source = Remove(methodCall.Arguments[0]); + if (source == methodCall.Arguments[0]) + { + return expression; + } + + return methodCall.Update(methodCall.Object, [source, .. methodCall.Arguments.Skip(1)]); + } +} diff --git a/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_with_collection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_with_collection.verified.txt new file mode 100644 index 00000000..20bbaa81 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_with_collection.verified.txt @@ -0,0 +1,84 @@ +{ + target: { + Data: { + splitParentEntitiesConnection: { + totalCount: 3, + items: [ + { + property: Ignore, + children: [ + { + property: Child3 + } + ] + }, + { + property: Parent1, + children: [ + { + property: Child1 + }, + { + property: Ignore + } + ] + }, + { + property: Parent2, + children: [ + { + property: Ignore + }, + { + property: Child2 + } + ] + } + ] + } + } + }, + sql: [ + { + Text: +select COUNT(*) +from ParentEntities as p + }, + { + Text: +select p.Id, + p.Property +from ParentEntities as p +order by p.Property, + p.Id +offset @p rows fetch next @p1 rows only, + Parameters: { + @p: 0, + @p1: 10 + } + }, + { + Text: +select c1.Id, + c1.ParentId, + c1.Property, + p0.Id +from (select p.Id, + p.Property + from ParentEntities as p + order by p.Property, + p.Id + offset @p rows fetch next @p1 rows only) as p0 + inner join + ChildEntities as c1 + on p0.Id = c1.ParentId +order by p0.Property, + p0.Id, + c1.Id, + Parameters: { + @p: 0, + @p1: 10 + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_without_collection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_without_collection.verified.txt new file mode 100644 index 00000000..6b2120af --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_without_collection.verified.txt @@ -0,0 +1,39 @@ +{ + target: { + Data: { + splitParentEntitiesConnection: { + totalCount: 3, + items: [ + { + property: Ignore + }, + { + property: Parent1 + }, + { + property: Parent2 + } + ] + } + } + }, + sql: [ + { + Text: +select COUNT(*) +from ParentEntities as p + }, + { + Text: +select p.Id, + p.Property +from ParentEntities as p +order by p.Property +offset @p rows fetch next @p1 rows only, + Parameters: { + @p: 0, + @p1: 10 + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt index 1fe53858..d6cdbf11 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -27,6 +27,18 @@ where: ParentEntityWhere, orderBy: [ParentEntityOrderBy!], ids: [ID!]): ParentConnection! + splitParentEntitiesConnection( + "Only return edges after the specified cursor." + after: String, + "Specifies the maximum number of edges to return, starting after the cursor specified by 'after', or the first number of edges if 'after' is not specified." + first: Int, + "Only return edges prior to the specified cursor." + before: String, + "Specifies the maximum number of edges to return, starting prior to the cursor specified by 'before', or the last number of edges if 'before' is not specified." + last: Int, + where: ParentEntityWhere, + orderBy: [ParentEntityOrderBy!], + ids: [ID!]): ParentConnection! childEntitiesConnection( "Only return edges after the specified cursor." after: String, diff --git a/src/Tests/IntegrationTests/IntegrationTests_connection_split_query.cs b/src/Tests/IntegrationTests/IntegrationTests_connection_split_query.cs new file mode 100644 index 00000000..37ade274 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests_connection_split_query.cs @@ -0,0 +1,49 @@ +public partial class IntegrationTests +{ + // The resolver's AsSplitQuery is removed, since no collection is selected + [Fact] + public async Task Connection_split_query_without_collection() + { + var query = + """ + { + splitParentEntitiesConnection(first: 10) { + totalCount + items { + property + } + } + } + """; + + var (entities, _, _) = BuildParentsWithChildren(); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, entities); + } + + // The resolver's AsSplitQuery is kept, since the children are selected + [Fact] + public async Task Connection_split_query_with_collection() + { + var query = + """ + { + splitParentEntitiesConnection(first: 10) { + totalCount + items { + property + children { + property + } + } + } + } + """; + + var (entities, _, _) = BuildParentsWithChildren(); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, entities); + } +} diff --git a/src/Tests/IntegrationTests/Query.cs b/src/Tests/IntegrationTests/Query.cs index 3cd8c324..a4de5f85 100644 --- a/src/Tests/IntegrationTests/Query.cs +++ b/src/Tests/IntegrationTests/Query.cs @@ -83,6 +83,14 @@ public Query(IEfGraphQLService efGraphQlService) resolve: _ => _.DbContext.ParentEntities.OrderBy(_ => _.Property)) .PageSize(10); + // Split for the children a request might select. With none selected the split does nothing. + efGraphQlService + .AddQueryConnectionField( + this, + name: "splitParentEntitiesConnection", + resolve: _ => _.DbContext.ParentEntities.AsSplitQuery().OrderBy(_ => _.Property)) + .PageSize(10); + efGraphQlService.AddQueryConnectionField( this, name: "childEntitiesConnection", From 5c95a067824fc239a70d6a829f6e308a42e1527f Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 28 Sep 2026 21:05:42 +1000 Subject: [PATCH 2/2] Order the children in the split query with collection test Child ids are random guids and the children were ordered by id, so the snapshot matched only when the database returned them in the verified order. --- ...ests.Connection_split_query_with_collection.verified.txt | 6 +++--- .../IntegrationTests_connection_split_query.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_with_collection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_with_collection.verified.txt index 20bbaa81..92b29a8d 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_with_collection.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Connection_split_query_with_collection.verified.txt @@ -27,10 +27,10 @@ property: Parent2, children: [ { - property: Ignore + property: Child2 }, { - property: Child2 + property: Ignore } ] } @@ -74,7 +74,7 @@ from (select p.Id, on p0.Id = c1.ParentId order by p0.Property, p0.Id, - c1.Id, + c1.Property, Parameters: { @p: 0, @p1: 10 diff --git a/src/Tests/IntegrationTests/IntegrationTests_connection_split_query.cs b/src/Tests/IntegrationTests/IntegrationTests_connection_split_query.cs index 37ade274..4d2e32e4 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_connection_split_query.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_connection_split_query.cs @@ -33,7 +33,7 @@ public async Task Connection_split_query_with_collection() totalCount items { property - children { + children(orderBy: {property: ascending}) { property } }