From eeeca19e5321301a0b4a0053ad6b4c06599029dc Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 25 Feb 2026 21:45:09 +0000 Subject: [PATCH 1/2] Fix #5072 projection of unbound extension resolver fields --- .../QueryableProjectionFieldHandler.cs | 2 +- .../QueryableProjectionHandlerBase.cs | 30 +++++++++ .../QueryableProjectionListHandler.cs | 2 +- .../QueryableProjectionScalarHandler.cs | 2 +- .../Data.Tests/Issue5072VerificationTests.cs | 65 +++++++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 src/HotChocolate/Data/test/Data.Tests/Issue5072VerificationTests.cs diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionFieldHandler.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionFieldHandler.cs index 52265dee39d..97b4ba117bb 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionFieldHandler.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionFieldHandler.cs @@ -10,7 +10,7 @@ public class QueryableProjectionFieldHandler : QueryableProjectionHandlerBase { public override bool CanHandle(Selection selection) - => selection.Field.Member is not null && !selection.IsLeaf; + => !selection.IsLeaf && CanProjectMember(selection); public override bool TryHandleEnter( QueryableProjectionContext context, 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 44937250621..fd5ad8b9e90 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionHandlerBase.cs @@ -1,11 +1,41 @@ using System.Diagnostics.CodeAnalysis; using HotChocolate.Execution.Processing; +using HotChocolate.Types; namespace HotChocolate.Data.Projections.Expressions.Handlers; public abstract class QueryableProjectionHandlerBase : ProjectionFieldHandler { + protected static bool CanProjectMember(Selection selection) + { + if (selection.Field.Member is null) + { + return false; + } + + // Explicit opt-in should always project regardless of resolver source. + if (selection.Field.IsAlwaysProjected()) + { + return true; + } + + var resolverMember = selection.Field.ResolverMember; + + if (resolverMember is null) + { + return true; + } + + if (resolverMember.ReflectedType == selection.Field.DeclaringType.RuntimeType) + { + return true; + } + + // When a member is explicitly bound we keep projecting it. + return resolverMember.IsDefined(typeof(BindMemberAttribute), inherit: true); + } + public override bool TryHandleEnter( QueryableProjectionContext context, Selection selection, diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionListHandler.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionListHandler.cs index 9466a674a2c..389adbfd75c 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionListHandler.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionListHandler.cs @@ -10,7 +10,7 @@ public class QueryableProjectionListHandler : QueryableProjectionHandlerBase { public override bool CanHandle(Selection selection) => - selection.Field.Member is { } + CanProjectMember(selection) && (selection.IsList || selection.IsMemberIsList()); public override QueryableProjectionContext OnBeforeEnter( diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionScalarHandler.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionScalarHandler.cs index e16fef75060..2a44365dfcc 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionScalarHandler.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionScalarHandler.cs @@ -9,7 +9,7 @@ public class QueryableProjectionScalarHandler : QueryableProjectionHandlerBase { public override bool CanHandle(Selection selection) - => selection.Field.Member is not null && selection.IsLeaf; + => selection.IsLeaf && CanProjectMember(selection); public override bool TryHandleEnter( QueryableProjectionContext context, diff --git a/src/HotChocolate/Data/test/Data.Tests/Issue5072VerificationTests.cs b/src/HotChocolate/Data/test/Data.Tests/Issue5072VerificationTests.cs new file mode 100644 index 00000000000..7c7d903ebc4 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/Issue5072VerificationTests.cs @@ -0,0 +1,65 @@ +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Data; + +public class Issue5072VerificationTests +{ + [Fact] + public async Task Extended_Field_Resolver_Does_Not_Project_Original_Field() + { + var executor = await new ServiceCollection() + .AddGraphQL() + .AddProjections() + .AddQueryType() + .AddTypeExtension() + .BuildRequestExecutorAsync(); + + var result = await executor.ExecuteAsync( + """ + { + users { + id + profile { + id + } + } + } + """); + + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + } + + public class Issue5072Query + { + [UseProjection] + public IQueryable GetUsers() + => new[] { new Issue5072User { Id = 1 } }.AsQueryable(); + } + + [ExtendObjectType(typeof(Issue5072User))] + public class Issue5072UserExtensions + { + public Issue5072Profile Profile([Parent] Issue5072User user) + => new() { Id = user.Id * 10 }; + } + + public class Issue5072User + { + public int Id { get; set; } + + public Issue5072Profile Profile + { + get => throw new InvalidOperationException( + "The original Profile member should not be projected for extended resolver fields."); + set { } + } + } + + public class Issue5072Profile + { + public int Id { get; set; } + } +} From 1cea2126663cccea7cdce8ae44aa866135ac2cfa Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 25 Feb 2026 23:53:37 +0100 Subject: [PATCH 2/2] Add support for requirements --- .../ProjectionProviderDescriptorExtensions.cs | 1 + ...ueryableRequirementsProjectionOptimizer.cs | 259 ++++++++++++++++++ .../Data.Tests/Issue5072VerificationTests.cs | 45 +++ 3 files changed, 305 insertions(+) create mode 100644 src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs diff --git a/src/HotChocolate/Data/src/Data/Projections/Convention/Extensions/ProjectionProviderDescriptorExtensions.cs b/src/HotChocolate/Data/src/Data/Projections/Convention/Extensions/ProjectionProviderDescriptorExtensions.cs index 03dca1a4cf9..744432ed3b9 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Convention/Extensions/ProjectionProviderDescriptorExtensions.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Convention/Extensions/ProjectionProviderDescriptorExtensions.cs @@ -26,6 +26,7 @@ public static IProjectionProviderDescriptor RegisterQueryableHandler( descriptor.RegisterFieldInterceptor(QueryableSingleOrDefaultInterceptor.Create); descriptor.RegisterOptimizer(IsProjectedProjectionOptimizer.Create); + descriptor.RegisterOptimizer(QueryableRequirementsProjectionOptimizer.Create); descriptor.RegisterOptimizer(QueryablePagingProjectionOptimizer.Create); descriptor.RegisterOptimizer(QueryableFilterProjectionOptimizer.Create); descriptor.RegisterOptimizer(QueryableSortProjectionOptimizer.Create); diff --git a/src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs b/src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs new file mode 100644 index 00000000000..b65fd84ee1e --- /dev/null +++ b/src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs @@ -0,0 +1,259 @@ +using System.Reflection; +using HotChocolate.Execution.Processing; +using HotChocolate.Execution.Requirements; +using HotChocolate.Language; +using HotChocolate.Types; +using HotChocolate.Types.Descriptors.Configurations; + +namespace HotChocolate.Data.Projections.Optimizers; + +public sealed class QueryableRequirementsProjectionOptimizer : IProjectionOptimizer +{ + private const string AliasPrefix = "__projection_requirements_"; + + public bool CanHandle(Selection field) + => (field.Field.Flags & CoreFieldFlags.WithRequirements) == CoreFieldFlags.WithRequirements; + + public Selection RewriteSelection( + SelectionSetOptimizerContext context, + Selection selection) + { + if (!context.Schema.Features.TryGet(out FieldRequirementsMetadata? metadata)) + { + return selection; + } + + var requirements = CollectRequirements(context, metadata); + + foreach (var requirement in requirements) + { + if (!TryGetField(context.TypeContext, requirement.Property, out var field) + || field.Arguments.Count > 0) + { + continue; + } + + var responseName = AliasPrefix + field.Name; + var fieldNode = CreateFieldNode(field, requirement, responseName); + UpsertInternalSelection(context, responseName, field, fieldNode); + } + + return selection; + } + + private static IReadOnlyList CollectRequirements( + SelectionSetOptimizerContext context, + FieldRequirementsMetadata metadata) + { + var root = new TypeNode(context.TypeContext.RuntimeType); + + foreach (var selection in context.Selections) + { + if (selection.IsInternal) + { + continue; + } + + if ((selection.Field.Flags & CoreFieldFlags.WithRequirements) != CoreFieldFlags.WithRequirements) + { + continue; + } + + if (metadata.GetRequirements(selection.Field) is not { } requirements) + { + continue; + } + + foreach (var node in requirements.Nodes) + { + root.TryAddNode(node.Clone()); + } + } + + return root.Nodes; + } + + private static void UpsertInternalSelection( + SelectionSetOptimizerContext context, + string responseName, + ObjectField field, + FieldNode fieldNode) + { + var resolverPipeline = context.CompileResolverPipeline(field, fieldNode); + + if (context.TryGetSelection(responseName, out var existingSelection)) + { + if (existingSelection.IsInternal && existingSelection.Field == field) + { + context.ReplaceSelection( + new Selection( + existingSelection.Id, + responseName, + field, + [new FieldSelectionNode(fieldNode, 0)], + [], + isInternal: true, + resolverPipeline: resolverPipeline)); + } + + return; + } + + context.AddSelection( + new Selection( + context.NewSelectionId(), + responseName, + field, + [new FieldSelectionNode(fieldNode, 0)], + [], + isInternal: true, + resolverPipeline: resolverPipeline)); + } + + private static FieldNode CreateFieldNode( + ObjectField field, + PropertyNode requirement, + string responseName) + { + var selectionSet = CreateSelectionSet(requirement.Nodes, field.Type.NamedType()); + + return new FieldNode( + null, + new NameNode(field.Name), + new NameNode(responseName), + [], + [], + selectionSet); + } + + private static SelectionSetNode? CreateSelectionSet( + IReadOnlyList requirements, + ITypeDefinition namedType) + { + if (requirements.Count == 0) + { + return null; + } + + var mergedNode = new TypeNode(requirements[0].Type); + + foreach (var requirement in requirements) + { + foreach (var node in requirement.Nodes) + { + mergedNode.TryAddNode(node.Clone()); + } + } + + var selections = new List(); + + foreach (var requirement in mergedNode.Nodes) + { + if (!TryGetField(namedType, requirement.Property, out var field)) + { + continue; + } + + selections.Add( + new FieldNode( + null, + new NameNode(field.Name), + null, + [], + [], + CreateSelectionSet(requirement.Nodes, field.Type.NamedType()))); + } + + return selections.Count == 0 ? null : new SelectionSetNode(selections); + } + + private static bool TryGetField( + ObjectType type, + PropertyInfo property, + out ObjectField field) + { + foreach (var candidate in type.Fields) + { + if (IsMatchingField(candidate, property)) + { + field = candidate; + return true; + } + } + + field = default!; + return false; + } + + private static bool TryGetField( + ITypeDefinition namedType, + PropertyInfo property, + out IOutputFieldDefinition field) + { + switch (namedType) + { + case ObjectType objectType: + foreach (var candidate in objectType.Fields) + { + if (IsMatchingField(candidate, property)) + { + field = candidate; + return true; + } + } + break; + + case InterfaceType interfaceType: + foreach (var candidate in interfaceType.Fields) + { + if (NameMatches(candidate.Name, property)) + { + field = candidate; + return true; + } + } + break; + } + + field = default!; + return false; + } + + private static bool IsMatchingField(ObjectField field, PropertyInfo property) + { + if (field.Member is PropertyInfo member) + { + return AreSameProperty(member, property); + } + + return NameMatches(field.Name, property); + } + + private static bool NameMatches(string fieldName, PropertyInfo property) + => fieldName.Equals(property.Name, StringComparison.Ordinal) + || fieldName.Equals(ToCamelCase(property.Name), StringComparison.Ordinal); + + private static bool AreSameProperty(PropertyInfo left, PropertyInfo right) + => ReferenceEquals(left, right) + || left.Name.Equals(right.Name, StringComparison.Ordinal) + && left.DeclaringType == right.DeclaringType + || left.MetadataToken == right.MetadataToken + && left.Module.Equals(right.Module); + + private static string ToCamelCase(string value) + { + if (string.IsNullOrEmpty(value) || !char.IsUpper(value[0])) + { + return value; + } + + if (value.Length == 1) + { + return char.ToLowerInvariant(value[0]).ToString(); + } + + return char.ToLowerInvariant(value[0]) + value[1..]; + } + + public static QueryableRequirementsProjectionOptimizer Create(ProjectionProviderContext context) => new(); +} diff --git a/src/HotChocolate/Data/test/Data.Tests/Issue5072VerificationTests.cs b/src/HotChocolate/Data/test/Data.Tests/Issue5072VerificationTests.cs index 7c7d903ebc4..301b64ddb47 100644 --- a/src/HotChocolate/Data/test/Data.Tests/Issue5072VerificationTests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/Issue5072VerificationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Execution; using HotChocolate.Types; using Microsoft.Extensions.DependencyInjection; @@ -32,6 +33,43 @@ public async Task Extended_Field_Resolver_Does_Not_Project_Original_Field() Assert.Empty(operationResult.Errors ?? []); } + [Fact] + public async Task Extended_Field_Resolver_Uses_Parent_Requirements_For_Projection() + { + var executor = await new ServiceCollection() + .AddGraphQL() + .AddProjections() + .AddQueryType() + .AddTypeExtension() + .BuildRequestExecutorAsync(); + + var result = await executor.ExecuteAsync( + """ + { + users { + profile { + id + } + } + } + """); + + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + + using var document = JsonDocument.Parse(result.ToJson()); + + var id = document + .RootElement + .GetProperty("data") + .GetProperty("users")[0] + .GetProperty("profile") + .GetProperty("id") + .GetInt32(); + + Assert.Equal(10, id); + } + public class Issue5072Query { [UseProjection] @@ -46,6 +84,13 @@ public Issue5072Profile Profile([Parent] Issue5072User user) => new() { Id = user.Id * 10 }; } + [ExtendObjectType(typeof(Issue5072User))] + public class Issue5072UserExtensionsWithRequirements + { + public Issue5072Profile Profile([Parent("Id")] Issue5072User user) + => new() { Id = user.Id * 10 }; + } + public class Issue5072User { public int Id { get; set; }