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
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>34.1.7</Version>
<Version>34.1.8</Version>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<PackageTags>EntityFrameworkCore, EntityFramework, GraphQL</PackageTags>
Expand Down
112 changes: 105 additions & 7 deletions src/GraphQL.EntityFramework/IncludeAppender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ internal IQueryable<TItem> AddIncludesWithFiltersAndDetectNavigations<TItem>(
IReadOnlyDictionary<Type, IReadOnlySet<string>>? allFilterFields = null)
where TItem : class
{
// Include cannot be applied to queries that have already been projected (e.g., after Select).
// Check if the query is the result of a projection - the element type won't match the root entity.
if (IsProjectedQuery(query))
{
return query;
}

// Add includes from GraphQL query
query = AddIncludes(query, context);

Expand All @@ -47,6 +54,79 @@ internal IQueryable<TItem> AddIncludesWithFiltersAndDetectNavigations<TItem>(
return query;
}

/// <summary>
/// Checks if the query is the result of a projection (Select).
/// When a query has been projected via Select, Include cannot be applied.
/// We detect this by examining if the expression tree contains a Select call.
/// </summary>
static bool IsProjectedQuery<TItem>(IQueryable<TItem> query)
where TItem : class =>
ContainsSelectMethod(query.Expression);

static bool ContainsSelectMethod(Expression expression)
{
while (true)
{
switch (expression)
{
case MethodCallExpression methodCall:
// Check if this is a Select call
if (methodCall.Method.Name == "Select")
{
return true;
}

// Check all arguments recursively
foreach (var arg in methodCall.Arguments)
{
if (ContainsSelectMethod(arg))
{
return true;
}
}

// Check the object (for instance method calls)
return methodCall.Object != null && ContainsSelectMethod(methodCall.Object);

case UnaryExpression unary:
expression = unary.Operand;
continue;

case LambdaExpression lambda:
expression = lambda.Body;
continue;

case MemberExpression member:
return member.Expression != null && ContainsSelectMethod(member.Expression);

case BinaryExpression binary:
return ContainsSelectMethod(binary.Left) || ContainsSelectMethod(binary.Right);

case ConditionalExpression conditional:
return ContainsSelectMethod(conditional.Test) || ContainsSelectMethod(conditional.IfTrue) || ContainsSelectMethod(conditional.IfFalse);

case InvocationExpression invocation:
if (ContainsSelectMethod(invocation.Expression))
{
return true;
}

foreach (var arg in invocation.Arguments)
{
if (ContainsSelectMethod(arg))
{
return true;
}
}

return false;

default:
return false;
}
}
}

IQueryable<TItem> AddFilterNavigationIncludes<TItem>(
IQueryable<TItem> query,
IReadOnlyDictionary<Type, IReadOnlySet<string>> allFilterFields,
Expand Down Expand Up @@ -297,15 +377,18 @@ FieldProjectionInfo MergeFilterFieldsIntoProjection(
}

// Recursively process existing navigations
if (projection.Navigations != null) foreach (var (navName, navProjection) in projection.Navigations)
if (projection.Navigations != null)
{
if (!mergedNavigations.ContainsKey(navName))
foreach (var (navName, navProjection) in projection.Navigations)
{
var updated = MergeFilterFieldsIntoProjection(navProjection.Projection, allFilterFields, navProjection.EntityType);
mergedNavigations[navName] = navProjection with
if (!mergedNavigations.ContainsKey(navName))
{
Projection = updated
};
var updated = MergeFilterFieldsIntoProjection(navProjection.Projection, allFilterFields, navProjection.EntityType);
mergedNavigations[navName] = navProjection with
{
Projection = updated
};
}
}
}

Expand Down Expand Up @@ -690,7 +773,22 @@ IQueryable<T> AddIncludes<T>(IQueryable<T> query, IResolveFieldContext context,
where T : class
{
var paths = GetPaths(context, navigationProperties);
return paths.Aggregate(query, (current, path) => current.Include(path));
foreach (var path in paths)
{
try
{
query = query.Include(path);
}
catch (InvalidOperationException)
{
// Include cannot be applied to this query (e.g., it has already been projected).
// Skip adding Include and let the query execute without it.
// The filter will need to fetch the data separately if needed.
return query;
}
}

return query;
}

List<string> GetPaths(IResolveFieldContext context, IReadOnlyDictionary<string, Navigation> navigationProperty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,16 @@ static bool TryBuildNavigationBindings(
var properties = GetEntityMetadata(entityType).Properties;

// Add key properties
if (projection.KeyNames != null) foreach (var keyName in projection.KeyNames)
if (projection.KeyNames != null)
{
if (properties.TryGetValue(keyName, out var metadata) &&
metadata.CanWrite &&
addedProperties.Add(keyName))
foreach (var keyName in projection.KeyNames)
{
bindings.Add(Expression.Bind(metadata.Property, Expression.Property(sourceExpression, metadata.Property)));
if (properties.TryGetValue(keyName, out var metadata) &&
metadata.CanWrite &&
addedProperties.Add(keyName))
{
bindings.Add(Expression.Bind(metadata.Property, Expression.Property(sourceExpression, metadata.Property)));
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
target:
{
"data": {
"queryFieldWithInclude": [
{
"id": "Guid_1"
}
]
}
},
sql: {
Text:
select i0.Id
from IncludeNonQueryableBs as i
inner join
IncludeNonQueryableAs as i0
on i.IncludeNonQueryableAId = i0.Id
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
target:
{
"data": {
"queryFieldWithInclude": [
{
"id": "Guid_1"
}
]
}
},
sql: {
Text:
select i0.Id,
case when i1.Id is null then cast (1 as bit) else cast (0 as bit) end,
i1.Id
from IncludeNonQueryableBs as i
inner join
IncludeNonQueryableAs as i0
on i.IncludeNonQueryableAId = i0.Id
left outer join
IncludeNonQueryableBs as i1
on i0.Id = i1.IncludeNonQueryableAId
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
target:
{
"data": {
"childEntities": [
{
"id": "Guid_1",
"property": "Child1"
}
]
}
},
sql: {
Text:
select c.Id,
c.ParentId,
c.Property,
case when p.Id is null then cast (1 as bit) else cast (0 as bit) end,
p.Property
from ChildEntities as c
left outer join
ParentEntities as p
on c.ParentId = p.Id
order by c.Property
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
public partial class IntegrationTests
{
/// <summary>
/// Tests that queries with Select projections in the resolver work correctly with filters.
/// This scenario occurs when a resolver uses .Select() to project data from a view or
/// navigation property. In this case, Include cannot be added to the query because
/// EF Core throws "Include has been used on non entity queryable" when Include is
/// applied after Select.
/// </summary>
[Fact]
public async Task Query_with_select_projection_and_filter()
{
var query =
"""
{
queryFieldWithInclude {
id
}
}
""";

var entityA = new IncludeNonQueryableA();
var entityB = new IncludeNonQueryableB
{
IncludeNonQueryableA = entityA,
IncludeNonQueryableAId = entityA.Id
};
entityA.IncludeNonQueryableB = entityB;
entityA.IncludeNonQueryableBId = entityB.Id;

// Add a filter to IncludeNonQueryableA - this entity is returned via .Select() projection
// The filter should work without attempting to add Include to the projected query
var filters = new Filters<IntegrationDbContext>();
filters.For<IncludeNonQueryableA>().Add(
projection: _ => _.Id,
filter: (_, _, _, id) => id != Guid.Empty);

await using var database = await sqlInstance.Build();
await RunQuery(database, query, null, filters, false, [entityA, entityB]);
}

/// <summary>
/// Tests that queries with Select projections work when the filter accesses a navigation property.
/// This is the scenario from MinistersApi where GroundTransportOrderedView.Select(_ => _.GroundTransport)
/// is used, and the filter accesses GroundTransport.TravelRequest.HighestStatusAchieved.
/// </summary>
[Fact]
public async Task Query_with_select_projection_and_filter_accessing_navigation()
{
var query =
"""
{
queryFieldWithInclude {
id
}
}
""";

var entityA = new IncludeNonQueryableA();
var entityB = new IncludeNonQueryableB
{
IncludeNonQueryableA = entityA,
IncludeNonQueryableAId = entityA.Id
};
entityA.IncludeNonQueryableB = entityB;
entityA.IncludeNonQueryableBId = entityB.Id;

// Add a filter that accesses a navigation property
// This would normally trigger Include for the navigation, but since the query
// already has Select applied, Include cannot be added
var filters = new Filters<IntegrationDbContext>();
filters.For<IncludeNonQueryableA>().Add(
projection: _ => new
{
_.Id,
ParentId = _.IncludeNonQueryableB.Id
},
filter: (_, _, _, data) => data.Id != Guid.Empty);

await using var database = await sqlInstance.Build();
await RunQuery(database, query, null, filters, false, [entityA, entityB]);
}

/// <summary>
/// Tests that regular queries (without Select projection in resolver) still work
/// correctly with filters that require navigation properties.
/// </summary>
[Fact]
public async Task Query_without_select_projection_with_navigation_filter()
{
var query =
"""
{
childEntities {
id
property
}
}
""";

var parent = new ParentEntity
{
Property = "Parent1"
};
var child = new ChildEntity
{
Property = "Child1",
Parent = parent
};
parent.Children.Add(child);

// Add a filter that accesses the parent navigation
var filters = new Filters<IntegrationDbContext>();
filters.For<ChildEntity>().Add(
projection: _ => new
{
_.Id,
ParentProperty = _.Parent != null ? _.Parent.Property : null
},
filter: (_, _, _, data) => data.ParentProperty != null);

await using var database = await sqlInstance.Build();
await RunQuery(database, query, null, filters, false, [parent, child]);
}
}