From 12a4e3f52767a92c5cf79edf3e9d5c874a9cbae7 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 28 Oct 2026 21:08:28 +1100 Subject: [PATCH] Add splitSideBySideCollections option Opt in, through RegisterInContainer or the EfGraphQLService constructor, to AsSplitQuery for a request that selects collections side by side, which multiply each other's rows in a single query. Nested collections do not multiply, so do not trigger it. A resolver that called AsSplitQuery or AsSingleQuery keeps its choice. AsSplitQuery is in Microsoft.EntityFrameworkCore.Relational, which is now referenced. Bump to 36.2.0. --- docs/configuration.md | 25 +++++-- docs/mdsource/configuration.source.md | 15 +++++ src/Directory.Build.props | 2 +- src/Directory.Packages.props | 1 + .../EfGraphQLConventions.cs | 12 ++-- .../GraphApi/EfGraphQLService.cs | 6 +- .../GraphQL.EntityFramework.csproj | 1 + .../IncludeAppender.cs | 9 ++- src/GraphQL.EntityFramework/QuerySplitting.cs | 21 ++++++ .../SelectProjection/SideBySideCollections.cs | 47 ++++++++++++++ src/Tests/SideBySideCollectionsTests.cs | 65 +++++++++++++++++++ 11 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 src/GraphQL.EntityFramework/SelectProjection/SideBySideCollections.cs create mode 100644 src/Tests/SideBySideCollectionsTests.cs diff --git a/docs/configuration.md b/docs/configuration.md index e32b6b60..a28480cd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,9 +23,10 @@ public static void RegisterInContainer( IModel? model = null, ResolveFilters? resolveFilters = null, bool disableTracking = false, - bool includeSqlInExceptions = false) + bool includeSqlInExceptions = false, + bool splitSideBySideCollections = false) ``` -snippet source | anchor +snippet source | anchor ```cs EfGraphQLConventions.RegisterInContainer( @@ -125,6 +126,21 @@ It defaults to false. Those messages can surface to clients as GraphQL errors, a Enable it in development, or where GraphQL errors are not returned to untrusted callers. +#### SplitSideBySideCollections + +Setting `splitSideBySideCollections` to true calls `AsSplitQuery` on a query whose request selects collections side by side. + +A single query joins every collection it loads, so collections side by side multiply each other's rows: a request for a travel request's acting arrangements and its member's roles returns a row for every arrangement and role pair. A collection nested in another does not multiply, since the query returns a row per item of the inner one, so a request for a group's users and each user's groups stays a single query. + +The resolver can not know in advance which collections a request will select, so this decides per request, from the same selection the projection is built from. + + * A query the resolver already called `AsSplitQuery` or `AsSingleQuery` on is left as it is. + * Split queries are separate round trips, and each sees the data as it is when it runs. Run them in a transaction where the collections have to agree. + * It needs a relational provider. + +It defaults to false. + + ### Usage @@ -136,9 +152,10 @@ public static void RegisterInContainer( IModel? model = null, ResolveFilters? resolveFilters = null, bool disableTracking = false, - bool includeSqlInExceptions = false) + bool includeSqlInExceptions = false, + bool splitSideBySideCollections = false) ``` -snippet source | anchor +snippet source | anchor ```cs EfGraphQLConventions.RegisterInContainer( diff --git a/docs/mdsource/configuration.source.md b/docs/mdsource/configuration.source.md index 1a6895c9..159b2bb0 100644 --- a/docs/mdsource/configuration.source.md +++ b/docs/mdsource/configuration.source.md @@ -66,6 +66,21 @@ It defaults to false. Those messages can surface to clients as GraphQL errors, a Enable it in development, or where GraphQL errors are not returned to untrusted callers. +#### SplitSideBySideCollections + +Setting `splitSideBySideCollections` to true calls `AsSplitQuery` on a query whose request selects collections side by side. + +A single query joins every collection it loads, so collections side by side multiply each other's rows: a request for a travel request's acting arrangements and its member's roles returns a row for every arrangement and role pair. A collection nested in another does not multiply, since the query returns a row per item of the inner one, so a request for a group's users and each user's groups stays a single query. + +The resolver can not know in advance which collections a request will select, so this decides per request, from the same selection the projection is built from. + + * A query the resolver already called `AsSplitQuery` or `AsSingleQuery` on is left as it is. + * Split queries are separate round trips, and each sees the data as it is when it runs. Run them in a transaction where the collections have to agree. + * It needs a relational provider. + +It defaults to false. + + ### Usage snippet: RegisterInContainer diff --git a/src/Directory.Build.props b/src/Directory.Build.props index cdc66454..1ea825f7 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109 - 36.1.0 + 36.2.0 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 76ce4cc2..6809b505 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -22,6 +22,7 @@ + diff --git a/src/GraphQL.EntityFramework/EfGraphQLConventions.cs b/src/GraphQL.EntityFramework/EfGraphQLConventions.cs index 5435d258..586469db 100644 --- a/src/GraphQL.EntityFramework/EfGraphQLConventions.cs +++ b/src/GraphQL.EntityFramework/EfGraphQLConventions.cs @@ -11,6 +11,7 @@ public static class EfGraphQLConventions /// A function to obtain a list of filters to apply to the returned data. If null, then it will be extracted from the . /// Use for all operations. /// Include the generated sql in exception messages. Off by default, since those messages can reach clients and the sql carries table and column names and, depending on the provider, parameter values. + /// Use for a query whose request selects collections side by side, which multiply each other's rows in a single query. A query the resolver already called AsSplitQuery or AsSingleQuery on is left as it is. Needs a relational provider. #region RegisterInContainer @@ -20,7 +21,8 @@ public static void RegisterInContainer( IModel? model = null, ResolveFilters? resolveFilters = null, bool disableTracking = false, - bool includeSqlInExceptions = false) + bool includeSqlInExceptions = false, + bool splitSideBySideCollections = false) #endregion @@ -29,7 +31,7 @@ public static void RegisterInContainer( RegisterScalarsAndArgs(services); services.AddHttpContextAccessor(); services.AddTransient(); - services.AddSingleton(provider => Build(resolveDbContext, model, resolveFilters, provider, disableTracking, includeSqlInExceptions)); + services.AddSingleton(provider => Build(resolveDbContext, model, resolveFilters, provider, disableTracking, includeSqlInExceptions, splitSideBySideCollections)); services.AddSingleton>(provider => provider.GetRequiredService>()); // The generated where and orderBy input types resolve every registered service, so a // type mapped in any of the contexts is found @@ -42,7 +44,8 @@ static EfGraphQLService Build( ResolveFilters? filters, IServiceProvider provider, bool disableTracking, - bool includeSqlInExceptions) + bool includeSqlInExceptions, + bool splitSideBySideCollections) where TDbContext : DbContext { model ??= ResolveModel(provider); @@ -54,7 +57,8 @@ static EfGraphQLService Build( dbContextResolver, filters, disableTracking, - includeSqlInExceptions); + includeSqlInExceptions, + splitSideBySideCollections); } static TDbContext DbContextFromProvider(IServiceProvider provider, IServiceProvider? requestServices) diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs index 8497cff5..baca3d76 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs @@ -12,12 +12,14 @@ public partial class EfGraphQLService : /// Use for all operations. /// Include the generated sql in exception messages. Off by default, since those messages can reach clients and the sql carries table and column names and, depending on the provider, parameter values. + /// Use for a query whose request selects collections side by side, which multiply each other's rows in a single query. A query the resolver already called AsSplitQuery or AsSingleQuery on is left as it is. Needs a relational provider. public EfGraphQLService( IModel model, ResolveDbContext resolveDbContext, ResolveFilters? resolveFilters = null, bool disableTracking = false, - bool includeSqlInExceptions = false) + bool includeSqlInExceptions = false, + bool splitSideBySideCollections = false) { this.resolveFilters = resolveFilters; this.disableTracking = disableTracking; @@ -40,7 +42,7 @@ public EfGraphQLService( .OrderBy(derived => Depth(derived.ClrType)) .Select(derived => derived.ClrType) .ToList()); - includeAppender = new(Navigations, keyNames, foreignKeys, derivedTypes, BackingFieldReader.GetBackingFields(model)); + includeAppender = new(Navigations, keyNames, foreignKeys, derivedTypes, BackingFieldReader.GetBackingFields(model), splitSideBySideCollections); } static int Depth(Type type) diff --git a/src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj b/src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj index 528601b2..77eeaf3a 100644 --- a/src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj +++ b/src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj @@ -10,6 +10,7 @@ + diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index 35db6b5a..e428c646 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -3,7 +3,8 @@ IReadOnlyDictionary> keyNames, IReadOnlyDictionary> foreignKeys, IReadOnlyDictionary> derivedTypes, - IReadOnlyDictionary>? backingFields = null) + IReadOnlyDictionary>? backingFields = null, + bool splitSideBySideCollections = false) { /// /// Narrow to the fields the request asked for: a select projection @@ -44,6 +45,12 @@ public IQueryable ApplyProjection( { query = QuerySplitting.Remove(query); } + else if (splitSideBySideCollections && + SideBySideCollections.Count(projection) > 1 && + !QuerySplitting.IsChosen(query.Expression)) + { + query = query.AsSplitQuery(); + } if (!SelectExpressionBuilder.TryBuild(projection, keyNames, derivedTypes, out var expression, out var includePaths, out var argumentFields, backingFields)) { diff --git a/src/GraphQL.EntityFramework/QuerySplitting.cs b/src/GraphQL.EntityFramework/QuerySplitting.cs index ed2e5df7..cecb80ee 100644 --- a/src/GraphQL.EntityFramework/QuerySplitting.cs +++ b/src/GraphQL.EntityFramework/QuerySplitting.cs @@ -5,6 +5,27 @@ /// static class QuerySplitting { + /// + /// Whether the resolver chose a splitting behavior with AsSplitQuery or AsSingleQuery, which + /// is then left as it is. + /// + public static bool IsChosen(Expression expression) + { + var current = expression; + while (current is MethodCallExpression { Object: null, Arguments.Count: > 0 } call && + typeof(IQueryable).IsAssignableFrom(call.Arguments[0].Type)) + { + if (call.Method.Name is "AsSplitQuery" or "AsSingleQuery") + { + return true; + } + + current = call.Arguments[0]; + } + + return false; + } + public static IQueryable Remove(IQueryable queryable) { var expression = Remove(queryable.Expression); diff --git a/src/GraphQL.EntityFramework/SelectProjection/SideBySideCollections.cs b/src/GraphQL.EntityFramework/SelectProjection/SideBySideCollections.cs new file mode 100644 index 00000000..5c2a6857 --- /dev/null +++ b/src/GraphQL.EntityFramework/SelectProjection/SideBySideCollections.cs @@ -0,0 +1,47 @@ +/// +/// Counts the collections a projection loads side by side. A single query joins every collection +/// it loads, and collections side by side multiply each other's rows. A collection nested in +/// another does not, since the query returns a row per item of the inner one, so a chain of nested +/// collections counts as one. A collection holding several side by side counts as many as it +/// holds. A reference navigation is not a collection, but passes on the collections under it. +/// +static class SideBySideCollections +{ + public static int Count(FieldProjectionInfo projection) + { + var count = 0; + if (projection.Navigations is not null) + { + count += Count(projection.Navigations); + } + + if (projection.DerivedNavigations is not null) + { + foreach (var navigations in projection.DerivedNavigations.Values) + { + count += Count(navigations); + } + } + + return count; + } + + static int Count(Dictionary navigations) + { + var count = 0; + foreach (var navigation in navigations.Values) + { + var inner = Count(navigation.Projection); + if (navigation.IsCollection) + { + count += Math.Max(1, inner); + } + else + { + count += inner; + } + } + + return count; + } +} diff --git a/src/Tests/SideBySideCollectionsTests.cs b/src/Tests/SideBySideCollectionsTests.cs new file mode 100644 index 00000000..2da14c1b --- /dev/null +++ b/src/Tests/SideBySideCollectionsTests.cs @@ -0,0 +1,65 @@ +public class SideBySideCollectionsTests +{ + static FieldProjectionInfo Projection(params (string Name, bool IsCollection, FieldProjectionInfo Projection)[] navigations) => + new( + [], + null, + null, + navigations.ToDictionary( + _ => _.Name, + _ => new NavigationProjectionInfo(typeof(object), _.IsCollection, _.Projection))); + + static FieldProjectionInfo Empty => Projection(); + + [Fact] + public void None() => + Assert.Equal(0, SideBySideCollections.Count(Projection(("Parent", false, Empty)))); + + [Fact] + public void One() => + Assert.Equal(1, SideBySideCollections.Count(Projection(("Children", true, Empty)))); + + // A row per grandchild, so the chain is one + [Fact] + public void NestedChainCountsOnce() => + Assert.Equal( + 1, + SideBySideCollections.Count( + Projection(("Children", true, Projection(("Grandchildren", true, Empty)))))); + + [Fact] + public void SideBySide() => + Assert.Equal( + 2, + SideBySideCollections.Count( + Projection( + ("Children", true, Empty), + ("Owner", false, Projection(("Roles", true, Projection(("Departments", true, Empty)))))))); + + // Each child holds its grandchildren and its pets side by side + [Fact] + public void SideBySideInANestedCollection() => + Assert.Equal( + 2, + SideBySideCollections.Count( + Projection( + ("Children", true, Projection( + ("Grandchildren", true, Empty), + ("Pets", true, Empty)))))); + + [Fact] + public void DerivedNavigations() => + Assert.Equal( + 2, + SideBySideCollections.Count( + Projection(("Children", true, Empty)) with + { + DerivedNavigations = new() + { + [typeof(string)] = new() + { + ["Items"] = new(typeof(object), true, Empty) + } + } + })); +}