Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ public static void RegisterInContainer<TDbContext>(
IModel? model = null,
ResolveFilters<TDbContext>? resolveFilters = null,
bool disableTracking = false,
bool includeSqlInExceptions = false)
bool includeSqlInExceptions = false,
bool splitSideBySideCollections = false)
```
<sup><a href='/src/GraphQL.EntityFramework/EfGraphQLConventions.cs#L15-L25' title='Snippet source file'>snippet source</a> | <a href='#snippet-RegisterInContainer' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/GraphQL.EntityFramework/EfGraphQLConventions.cs#L16-L27' title='Snippet source file'>snippet source</a> | <a href='#snippet-RegisterInContainer' title='Start of snippet'>anchor</a></sup>
<a id='snippet-RegisterInContainer-1'></a>
```cs
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
Expand Down Expand Up @@ -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

<!-- snippet: RegisterInContainer -->
Expand All @@ -136,9 +152,10 @@ public static void RegisterInContainer<TDbContext>(
IModel? model = null,
ResolveFilters<TDbContext>? resolveFilters = null,
bool disableTracking = false,
bool includeSqlInExceptions = false)
bool includeSqlInExceptions = false,
bool splitSideBySideCollections = false)
```
<sup><a href='/src/GraphQL.EntityFramework/EfGraphQLConventions.cs#L15-L25' title='Snippet source file'>snippet source</a> | <a href='#snippet-RegisterInContainer' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/GraphQL.EntityFramework/EfGraphQLConventions.cs#L16-L27' title='Snippet source file'>snippet source</a> | <a href='#snippet-RegisterInContainer' title='Start of snippet'>anchor</a></sup>
<a id='snippet-RegisterInContainer-1'></a>
```cs
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
Expand Down
15 changes: 15 additions & 0 deletions docs/mdsource/configuration.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CS1591;NU5104;CS1573;CS9107;NU1608;NU1109</NoWarn>
<Version>36.1.0</Version>
<Version>36.2.0</Version>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<PackageTags>EntityFrameworkCore, EntityFramework, GraphQL</PackageTags>
Expand Down
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.1.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.14" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageVersion Include="Polyfill" Version="11.4.5" />
Expand Down
12 changes: 8 additions & 4 deletions src/GraphQL.EntityFramework/EfGraphQLConventions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public static class EfGraphQLConventions
/// <param name="resolveFilters">A function to obtain a list of filters to apply to the returned data. If null, then it will be extracted from the <see cref="IServiceProvider"/>.</param>
/// <param name="disableTracking">Use <see cref="EntityFrameworkQueryableExtensions.AsNoTracking{TEntity}"/> for all <see cref="IQueryable{T}"/> operations.</param>
/// <param name="includeSqlInExceptions">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.</param>
/// <param name="splitSideBySideCollections">Use <see cref="RelationalQueryableExtensions.AsSplitQuery{TEntity}"/> 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.</param>

#region RegisterInContainer

Expand All @@ -20,7 +21,8 @@ public static void RegisterInContainer<TDbContext>(
IModel? model = null,
ResolveFilters<TDbContext>? resolveFilters = null,
bool disableTracking = false,
bool includeSqlInExceptions = false)
bool includeSqlInExceptions = false,
bool splitSideBySideCollections = false)

#endregion

Expand All @@ -29,7 +31,7 @@ public static void RegisterInContainer<TDbContext>(
RegisterScalarsAndArgs(services);
services.AddHttpContextAccessor();
services.AddTransient<HttpContextCapture>();
services.AddSingleton(provider => Build(resolveDbContext, model, resolveFilters, provider, disableTracking, includeSqlInExceptions));
services.AddSingleton(provider => Build(resolveDbContext, model, resolveFilters, provider, disableTracking, includeSqlInExceptions, splitSideBySideCollections));
services.AddSingleton<IEfGraphQLService<TDbContext>>(provider => provider.GetRequiredService<EfGraphQLService<TDbContext>>());
// The generated where and orderBy input types resolve every registered service, so a
// type mapped in any of the contexts is found
Expand All @@ -42,7 +44,8 @@ static EfGraphQLService<TDbContext> Build<TDbContext>(
ResolveFilters<TDbContext>? filters,
IServiceProvider provider,
bool disableTracking,
bool includeSqlInExceptions)
bool includeSqlInExceptions,
bool splitSideBySideCollections)
where TDbContext : DbContext
{
model ??= ResolveModel<TDbContext>(provider);
Expand All @@ -54,7 +57,8 @@ static EfGraphQLService<TDbContext> Build<TDbContext>(
dbContextResolver,
filters,
disableTracking,
includeSqlInExceptions);
includeSqlInExceptions,
splitSideBySideCollections);
}

static TDbContext DbContextFromProvider<TDbContext>(IServiceProvider provider, IServiceProvider? requestServices)
Expand Down
6 changes: 4 additions & 2 deletions src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ public partial class EfGraphQLService<TDbContext> :

/// <param name="disableTracking">Use <see cref="EntityFrameworkQueryableExtensions.AsNoTracking{TEntity}"/> for all <see cref="IQueryable{T}"/> operations.</param>
/// <param name="includeSqlInExceptions">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.</param>
/// <param name="splitSideBySideCollections">Use <see cref="RelationalQueryableExtensions.AsSplitQuery{TEntity}"/> 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.</param>
public EfGraphQLService(
IModel model,
ResolveDbContext<TDbContext> resolveDbContext,
ResolveFilters<TDbContext>? resolveFilters = null,
bool disableTracking = false,
bool includeSqlInExceptions = false)
bool includeSqlInExceptions = false,
bool splitSideBySideCollections = false)
{
this.resolveFilters = resolveFilters;
this.disableTracking = disableTracking;
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/GraphQL.EntityFramework/GraphQL.EntityFramework.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageReference Include="GraphQL" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Polyfill" PrivateAssets="all" />
<PackageReference Include="ProjectDefaults" PrivateAssets="all" />
<PackageReference Include="Sbom" PrivateAssets="all" />
Expand Down
9 changes: 8 additions & 1 deletion src/GraphQL.EntityFramework/IncludeAppender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
IReadOnlyDictionary<Type, List<string>> keyNames,
IReadOnlyDictionary<Type, IReadOnlySet<string>> foreignKeys,
IReadOnlyDictionary<Type, IReadOnlyList<Type>> derivedTypes,
IReadOnlyDictionary<Type, IReadOnlyDictionary<string, FieldInfo>>? backingFields = null)
IReadOnlyDictionary<Type, IReadOnlyDictionary<string, FieldInfo>>? backingFields = null,
bool splitSideBySideCollections = false)
{
/// <summary>
/// Narrow <paramref name="query"/> to the fields the request asked for: a select projection
Expand Down Expand Up @@ -44,6 +45,12 @@ public IQueryable<TItem> ApplyProjection<TDbContext, TItem>(
{
query = QuerySplitting.Remove(query);
}
else if (splitSideBySideCollections &&
SideBySideCollections.Count(projection) > 1 &&
!QuerySplitting.IsChosen(query.Expression))
{
query = query.AsSplitQuery();
}

if (!SelectExpressionBuilder.TryBuild<TItem>(projection, keyNames, derivedTypes, out var expression, out var includePaths, out var argumentFields, backingFields))
{
Expand Down
21 changes: 21 additions & 0 deletions src/GraphQL.EntityFramework/QuerySplitting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@
/// </summary>
static class QuerySplitting
{
/// <summary>
/// Whether the resolver chose a splitting behavior with AsSplitQuery or AsSingleQuery, which
/// is then left as it is.
/// </summary>
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<T> Remove<T>(IQueryable<T> queryable)
{
var expression = Remove(queryable.Expression);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/// <summary>
/// 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.
/// </summary>
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<string, NavigationProjectionInfo> 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;
}
}
65 changes: 65 additions & 0 deletions src/Tests/SideBySideCollectionsTests.cs
Original file line number Diff line number Diff line change
@@ -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)
}
}
}));
}
Loading