From 54d9deb6a03fdb8a611182c688de22dbeb955fc2 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 3 Feb 2026 14:00:49 +1100 Subject: [PATCH 1/3] Handle Include on projected queries in IncludeAppender Added logic to detect when a query has been projected (e.g., via Select) and prevent applying Include, as EF Core does not support Include on non-entity queries. Updated AddIncludes to catch InvalidOperationException and skip Include if it cannot be applied. Added integration tests to verify correct behavior with and without Select projections and navigation property filters. --- .../IncludeAppender.cs | 99 +++++++++++++- ..._select_projection_and_filter.verified.txt | 20 +++ ...d_filter_accessing_navigation.verified.txt | 25 ++++ ...ection_with_navigation_filter.verified.txt | 26 ++++ ...ationTests_query_with_select_projection.cs | 125 ++++++++++++++++++ 5 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/Tests/IntegrationTests/IntegrationTests.Query_with_select_projection_and_filter.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests.Query_with_select_projection_and_filter_accessing_navigation.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests.Query_without_select_projection_with_navigation_filter.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index c69a4a61..8b7286a8 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -33,6 +33,13 @@ internal IQueryable AddIncludesWithFiltersAndDetectNavigations( IReadOnlyDictionary>? 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); @@ -47,6 +54,81 @@ internal IQueryable AddIncludesWithFiltersAndDetectNavigations( return query; } + /// + /// 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. + /// + static bool IsProjectedQuery(IQueryable query) + where TItem : class => + ContainsSelectMethod(query.Expression); + + static bool ContainsSelectMethod(Expression expression) + { + 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) + if (methodCall.Object != null && ContainsSelectMethod(methodCall.Object)) + { + return true; + } + + return false; + + case UnaryExpression unary: + return ContainsSelectMethod(unary.Operand); + + case LambdaExpression lambda: + return ContainsSelectMethod(lambda.Body); + + 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 AddFilterNavigationIncludes( IQueryable query, IReadOnlyDictionary> allFilterFields, @@ -690,7 +772,22 @@ IQueryable AddIncludes(IQueryable 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 GetPaths(IResolveFieldContext context, IReadOnlyDictionary navigationProperty) diff --git a/src/Tests/IntegrationTests/IntegrationTests.Query_with_select_projection_and_filter.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Query_with_select_projection_and_filter.verified.txt new file mode 100644 index 00000000..b4fd78ac --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Query_with_select_projection_and_filter.verified.txt @@ -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 + } +} diff --git a/src/Tests/IntegrationTests/IntegrationTests.Query_with_select_projection_and_filter_accessing_navigation.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Query_with_select_projection_and_filter_accessing_navigation.verified.txt new file mode 100644 index 00000000..1ed3ad24 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Query_with_select_projection_and_filter_accessing_navigation.verified.txt @@ -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 + } +} diff --git a/src/Tests/IntegrationTests/IntegrationTests.Query_without_select_projection_with_navigation_filter.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Query_without_select_projection_with_navigation_filter.verified.txt new file mode 100644 index 00000000..89373c06 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Query_without_select_projection_with_navigation_filter.verified.txt @@ -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 + } +} diff --git a/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs b/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs new file mode 100644 index 00000000..727b1793 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs @@ -0,0 +1,125 @@ +public partial class IntegrationTests +{ + /// + /// 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. + /// + [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(); + filters.For().Add( + projection: _ => _.Id, + filter: (_, _, _, id) => id != Guid.Empty); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, filters, false, [entityA, entityB]); + } + + /// + /// 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. + /// + [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(); + filters.For().Add( + projection: _ => new + { + _.Id, + ParentId = _.IncludeNonQueryableB != null ? _.IncludeNonQueryableB.Id : (Guid?)null + }, + filter: (_, _, _, data) => data.Id != Guid.Empty); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, filters, false, [entityA, entityB]); + } + + /// + /// Tests that regular queries (without Select projection in resolver) still work + /// correctly with filters that require navigation properties. + /// + [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(); + filters.For().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]); + } +} From 9a47c2d38fb1e5d83527e074f7918b6afd00ffb1 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 3 Feb 2026 14:06:11 +1100 Subject: [PATCH 2/3] . --- .../IncludeAppender.cs | 101 +++++++++--------- .../SelectExpressionBuilder.cs | 13 ++- ...ationTests_query_with_select_projection.cs | 2 +- 3 files changed, 60 insertions(+), 56 deletions(-) diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index 8b7286a8..e809f0bd 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -65,67 +65,65 @@ static bool IsProjectedQuery(IQueryable query) static bool ContainsSelectMethod(Expression expression) { - switch (expression) + while (true) { - 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)) + switch (expression) + { + case MethodCallExpression methodCall: + // Check if this is a Select call + if (methodCall.Method.Name == "Select") { return true; } - } - // Check the object (for instance method calls) - if (methodCall.Object != null && ContainsSelectMethod(methodCall.Object)) - { - return true; - } - - return false; + // Check all arguments recursively + foreach (var arg in methodCall.Arguments) + { + if (ContainsSelectMethod(arg)) + { + return true; + } + } - case UnaryExpression unary: - return ContainsSelectMethod(unary.Operand); + // Check the object (for instance method calls) + return methodCall.Object != null && ContainsSelectMethod(methodCall.Object); - case LambdaExpression lambda: - return ContainsSelectMethod(lambda.Body); + case UnaryExpression unary: + expression = unary.Operand; + continue; - case MemberExpression member: - return member.Expression != null && ContainsSelectMethod(member.Expression); + case LambdaExpression lambda: + expression = lambda.Body; + continue; - case BinaryExpression binary: - return ContainsSelectMethod(binary.Left) || ContainsSelectMethod(binary.Right); + case MemberExpression member: + return member.Expression != null && ContainsSelectMethod(member.Expression); - case ConditionalExpression conditional: - return ContainsSelectMethod(conditional.Test) || - ContainsSelectMethod(conditional.IfTrue) || - ContainsSelectMethod(conditional.IfFalse); + case BinaryExpression binary: + return ContainsSelectMethod(binary.Left) || ContainsSelectMethod(binary.Right); - case InvocationExpression invocation: - if (ContainsSelectMethod(invocation.Expression)) - { - return true; - } + case ConditionalExpression conditional: + return ContainsSelectMethod(conditional.Test) || ContainsSelectMethod(conditional.IfTrue) || ContainsSelectMethod(conditional.IfFalse); - foreach (var arg in invocation.Arguments) - { - if (ContainsSelectMethod(arg)) + case InvocationExpression invocation: + if (ContainsSelectMethod(invocation.Expression)) { return true; } - } - return false; + foreach (var arg in invocation.Arguments) + { + if (ContainsSelectMethod(arg)) + { + return true; + } + } + + return false; - default: - return false; + default: + return false; + } } } @@ -379,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 + }; + } } } diff --git a/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs b/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs index 24656a05..5f4bbab9 100644 --- a/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs +++ b/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs @@ -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))); + } } } diff --git a/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs b/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs index 727b1793..4b3285fa 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_query_with_select_projection.cs @@ -73,7 +73,7 @@ public async Task Query_with_select_projection_and_filter_accessing_navigation() projection: _ => new { _.Id, - ParentId = _.IncludeNonQueryableB != null ? _.IncludeNonQueryableB.Id : (Guid?)null + ParentId = _.IncludeNonQueryableB.Id }, filter: (_, _, _, data) => data.Id != Guid.Empty); From 3de73d5e6cc285deb517d1e769fe3d24696d419c Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 3 Feb 2026 14:06:22 +1100 Subject: [PATCH 3/3] Update Directory.Build.props --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index dfbcc17c..d3f0d96b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109 - 34.1.7 + 34.1.8 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL