diff --git a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs index 15f889d8b88..7a9a4162b1f 100644 --- a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs +++ b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs @@ -298,22 +298,28 @@ private void CollectSelection( Selection selection, TypeNode parent) { - var namedType = selection.Field.Type.NamedType(); + var field = selection.Field; + var namedType = field.Type.NamedType(); - if (selection.Field.PureResolver is null - || selection.Field.ResolverMember?.ReflectedType != selection.Field.DeclaringType.RuntimeType) + // A field is projectable if its resolver is the underlying member (a pure resolver + // declared on the parent runtime type) or if it explicitly replaces that member + // (fluent ResolveWith / [BindMember]). + var isPureMemberResolver = field.PureResolver is not null + && field.ResolverMember?.ReflectedType == field.DeclaringType.RuntimeType; + var isMemberReplacement = field.Flags.HasFlag(CoreFieldFlags.MemberReplacement); + + if (!isPureMemberResolver && !isMemberReplacement) { return; } - if (selection.Field.Member is not PropertyInfo { CanRead: true, CanWrite: true } property) + if (field.Member is not PropertyInfo { CanRead: true, CanWrite: true } property) { return; } - var flags = selection.Field.Flags; - if ((flags & CoreFieldFlags.Connection) == CoreFieldFlags.Connection - || (flags & CoreFieldFlags.CollectionSegment) == CoreFieldFlags.CollectionSegment) + if (field.Flags.HasFlag(CoreFieldFlags.Connection) + || field.Flags.HasFlag(CoreFieldFlags.CollectionSegment)) { return; } diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/CoreFieldFlags.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/CoreFieldFlags.cs index ff132a166e7..c700eae18ec 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/CoreFieldFlags.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/CoreFieldFlags.cs @@ -46,5 +46,6 @@ internal enum CoreFieldFlags : long WithRequirements = 1 << 30, UsesProjections = 1L << 31, ImplicitField = 1L << 32, - BatchResolver = 1L << 33 + BatchResolver = 1L << 33, + MemberReplacement = 1L << 34 } diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs index b8cf1a41d19..eff69e5112d 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs @@ -435,6 +435,11 @@ private IObjectFieldDescriptor ResolveWithInternal( Configuration.Resolver = null; Configuration.ResultType = propertyOrMethod.GetReturnType(); + if (Configuration.Member is not null) + { + Configuration.Flags |= CoreFieldFlags.MemberReplacement; + } + if (propertyOrMethod is MethodInfo m) { _parameterInfos = Context.TypeInspector.GetParameters(m); diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs index fd5ad8b9e90..98913d75926 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using HotChocolate.Execution.Processing; using HotChocolate.Types; +using HotChocolate.Types.Descriptors.Configurations; namespace HotChocolate.Data.Projections.Expressions.Handlers; @@ -32,7 +33,14 @@ protected static bool CanProjectMember(Selection selection) return true; } - // When a member is explicitly bound we keep projecting it. + // Explicit member replacements (e.g. fluent ResolveWith on a shadowed property) + // must keep projecting the underlying member so custom resolvers + // can access the shadowed data on projected parents. + if (selection.Field.Flags.HasFlag(CoreFieldFlags.MemberReplacement)) + { + return true; + } + return resolverMember.IsDefined(typeof(BindMemberAttribute), inherit: true); } diff --git a/src/HotChocolate/Data/test/Data.Tests/ResolveWithProjectionTests.cs b/src/HotChocolate/Data/test/Data.Tests/ResolveWithProjectionTests.cs new file mode 100644 index 00000000000..52475a1284c --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/ResolveWithProjectionTests.cs @@ -0,0 +1,155 @@ +using HotChocolate.Execution; +using HotChocolate.Execution.Processing; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Data; + +public class ResolveWithProjectionTests +{ + [Fact] + public async Task ResolveWith_Should_Project_Member_When_UseProjection_Is_Applied_On_Parent() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQL() + .AddProjections() + .AddQueryType() + .AddType() + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + tenants { + workspaces { + id + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "tenants": [ + { + "workspaces": [ + { + "id": 2 + }, + { + "id": 4 + } + ] + } + ] + } + } + """); + } + + [Fact] + public async Task ResolveWith_Should_Project_Member_When_AsSelector_Is_Used_On_Parent() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddType() + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + tenants { + workspaces { + id + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "tenants": [ + { + "workspaces": [ + { + "id": 2 + }, + { + "id": 4 + } + ] + } + ] + } + } + """); + } + + public class Query + { + [UseProjection] + public IQueryable GetTenants() + => CreateTenants().AsQueryable(); + } + + public class AsSelectorQuery + { + public IQueryable GetTenants(ISelection selection) + => CreateTenants().AsQueryable().Select(selection.AsSelector()); + } + + private static Tenant[] CreateTenants() + => + [ + new Tenant + { + Id = 1, + Workspaces = + [ + new Workspace { Id = 1 }, + new Workspace { Id = 2 }, + new Workspace { Id = 3 }, + new Workspace { Id = 4 } + ] + } + ]; + + public class TenantType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Field(t => t.Workspaces) + .ResolveWith(r => r.GetWorkspaces(default!)); + } + } + + public class TenantResolvers + { + public IQueryable GetWorkspaces([Parent] Tenant tenant) + => tenant.Workspaces.Where(w => w.Id % 2 == 0).AsQueryable(); + } + + public class Tenant + { + public int Id { get; set; } + + public List Workspaces { get; set; } = []; + } + + public class Workspace + { + public int Id { get; set; } + } +}