From 01fc2ad04bda8855327217104b8c2e103f484b7c Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 19 May 2026 14:51:37 +0200 Subject: [PATCH 1/3] Add MemberReplacement flag to track ResolveWith replacements for projection --- .../Configurations/CoreFieldFlags.cs | 3 +- .../Descriptors/ObjectFieldDescriptor.cs | 5 + .../QueryableProjectionHandlerBase.cs | 9 +- .../Data/test/Data.Tests/Issue9741Tests.cs | 101 ++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs 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..8dbc3b801b6 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,13 @@ protected static bool CanProjectMember(Selection selection) return true; } - // When a member is explicitly bound we keep projecting it. + // Explicit member replacements must keep projecting the underlying member + // so custom resolvers can access the shadowed data on projected parents. + if ((selection.Field.Flags & CoreFieldFlags.MemberReplacement) == CoreFieldFlags.MemberReplacement) + { + return true; + } + return resolverMember.IsDefined(typeof(BindMemberAttribute), inherit: true); } diff --git a/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs b/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs new file mode 100644 index 00000000000..4d7cd1c59a9 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs @@ -0,0 +1,101 @@ +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Data; + +public class Issue9741Tests +{ + [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 + } + ] + } + ] + } + } + """); + } + + public class Query + { + [UseProjection] + public IQueryable GetTenants() + => new[] + { + new Tenant + { + Id = 1, + Workspaces = + [ + new Workspace { Id = 1 }, + new Workspace { Id = 2 }, + new Workspace { Id = 3 }, + new Workspace { Id = 4 } + ] + } + }.AsQueryable(); + } + + 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; } + } +} From 7f1f1747891d74d1b420fcf038f2c5be20a23304 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 19 May 2026 15:13:22 +0200 Subject: [PATCH 2/3] Fix same issue in AsSelector --- .../SelectionExpressionBuilder.cs | 16 +++- .../Data/test/Data.Tests/Issue9741Tests.cs | 82 +++++++++++++++---- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs index 15f889d8b88..b892860fcd8 100644 --- a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs +++ b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs @@ -300,10 +300,20 @@ private void CollectSelection( { var namedType = selection.Field.Type.NamedType(); - if (selection.Field.PureResolver is null - || selection.Field.ResolverMember?.ReflectedType != selection.Field.DeclaringType.RuntimeType) + var isMemberReplacement = + (selection.Field.Flags & CoreFieldFlags.MemberReplacement) == CoreFieldFlags.MemberReplacement; + + // Member replacements (e.g. fluent ResolveWith on a shadowed property) must keep + // projecting the underlying member so the resolver can read it from the projected + // parent. The pure-resolver gate does not apply here because such resolvers are + // typically non-pure (IQueryable, async, services). + if (!isMemberReplacement) { - return; + if (selection.Field.PureResolver is null + || selection.Field.ResolverMember?.ReflectedType != selection.Field.DeclaringType.RuntimeType) + { + return; + } } if (selection.Field.Member is not PropertyInfo { CanRead: true, CanWrite: true } property) diff --git a/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs b/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs index 4d7cd1c59a9..95ab8e25c51 100644 --- a/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs @@ -1,4 +1,5 @@ using HotChocolate.Execution; +using HotChocolate.Execution.Processing; using HotChocolate.Types; using Microsoft.Extensions.DependencyInjection; @@ -51,26 +52,79 @@ public async Task ResolveWith_Should_Project_Member_When_UseProjection_Is_Applie """); } + [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() - => new[] - { - new Tenant - { - Id = 1, - Workspaces = - [ - new Workspace { Id = 1 }, - new Workspace { Id = 2 }, - new Workspace { Id = 3 }, - new Workspace { Id = 4 } - ] - } - }.AsQueryable(); + => 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) From c03e89fff8689b78df75eb44160981f18688e2a3 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 19 May 2026 15:23:30 +0200 Subject: [PATCH 3/3] Cleanup --- .../SelectionExpressionBuilder.cs | 30 ++++++++----------- .../QueryableProjectionHandlerBase.cs | 7 +++-- ...Tests.cs => ResolveWithProjectionTests.cs} | 2 +- 3 files changed, 18 insertions(+), 21 deletions(-) rename src/HotChocolate/Data/test/Data.Tests/{Issue9741Tests.cs => ResolveWithProjectionTests.cs} (98%) diff --git a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs index b892860fcd8..7a9a4162b1f 100644 --- a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs +++ b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs @@ -298,32 +298,28 @@ private void CollectSelection( Selection selection, TypeNode parent) { - var namedType = selection.Field.Type.NamedType(); + var field = selection.Field; + var namedType = field.Type.NamedType(); - var isMemberReplacement = - (selection.Field.Flags & CoreFieldFlags.MemberReplacement) == CoreFieldFlags.MemberReplacement; + // 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); - // Member replacements (e.g. fluent ResolveWith on a shadowed property) must keep - // projecting the underlying member so the resolver can read it from the projected - // parent. The pure-resolver gate does not apply here because such resolvers are - // typically non-pure (IQueryable, async, services). - if (!isMemberReplacement) + if (!isPureMemberResolver && !isMemberReplacement) { - if (selection.Field.PureResolver is null - || selection.Field.ResolverMember?.ReflectedType != selection.Field.DeclaringType.RuntimeType) - { - return; - } + 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/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs index 8dbc3b801b6..98913d75926 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs @@ -33,9 +33,10 @@ protected static bool CanProjectMember(Selection selection) return true; } - // Explicit member replacements must keep projecting the underlying member - // so custom resolvers can access the shadowed data on projected parents. - if ((selection.Field.Flags & CoreFieldFlags.MemberReplacement) == CoreFieldFlags.MemberReplacement) + // 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; } diff --git a/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs b/src/HotChocolate/Data/test/Data.Tests/ResolveWithProjectionTests.cs similarity index 98% rename from src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs rename to src/HotChocolate/Data/test/Data.Tests/ResolveWithProjectionTests.cs index 95ab8e25c51..52475a1284c 100644 --- a/src/HotChocolate/Data/test/Data.Tests/Issue9741Tests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/ResolveWithProjectionTests.cs @@ -5,7 +5,7 @@ namespace HotChocolate.Data; -public class Issue9741Tests +public class ResolveWithProjectionTests { [Fact] public async Task ResolveWith_Should_Project_Member_When_UseProjection_Is_Applied_On_Parent()