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
47 changes: 47 additions & 0 deletions src/GraphQL.EntityFramework/IncludeAppender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ public IQueryable<TItem> ApplyProjection<TDbContext, TItem>(
return AddIncludesFromProjection(query, projection);
}

if (!LoadsCollection(projection))
{
query = QuerySplitting.Remove(query);
}

foreach (var includePath in includePaths)
{
query = query.Include(includePath);
Expand All @@ -49,6 +54,48 @@ public IQueryable<TItem> ApplyProjection<TDbContext, TItem>(
return query.Select(expression);
}

/// <summary>
/// Whether the projection loads a collection at any depth, the only case where query splitting
/// changes anything.
/// </summary>
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<string, NavigationProjectionInfo> navigations)
{
foreach (var navigation in navigations.Values)
{
if (navigation.IsCollection ||
LoadsCollection(navigation.Projection))
{
return true;
}
}

return false;
}

IQueryable<TItem> AddIncludesFromProjection<TItem>(
IQueryable<TItem> query,
FieldProjectionInfo projection)
Expand Down
43 changes: 43 additions & 0 deletions src/GraphQL.EntityFramework/QuerySplitting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/// <summary>
/// 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.
/// </summary>
static class QuerySplitting
{
public static IQueryable<T> Remove<T>(IQueryable<T> queryable)
{
var expression = Remove(queryable.Expression);
if (expression == queryable.Expression)
{
return queryable;
}

return queryable.Provider.CreateQuery<T>(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)]);
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
8 changes: 8 additions & 0 deletions src/Tests/IntegrationTests/Query.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ public Query(IEfGraphQLService<IntegrationDbContext> 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<ParentGraphType, ParentEntity>(
this,
name: "splitParentEntitiesConnection",
resolve: _ => _.DbContext.ParentEntities.AsSplitQuery().OrderBy(_ => _.Property))
.PageSize(10);

efGraphQlService.AddQueryConnectionField<ChildGraphType, ChildEntity>(
this,
name: "childEntitiesConnection",
Expand Down
Loading