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..92b29a8d --- /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: Child2 + }, + { + property: Ignore + } + ] + } + ] + } + } + }, + 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.Property, + 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..4d2e32e4 --- /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(orderBy: {property: ascending}) { + 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",