From c03405a491bfa2eab0c7fa894bef3ce3f5c18c50 Mon Sep 17 00:00:00 2001 From: Thomas Pakh Date: Tue, 2 Jun 2026 16:00:48 -0700 Subject: [PATCH 1/3] Fix `[Parent(requires:)]` dropped for resolvers declared on interface types --- .../RequirementsTypeInterceptor.cs | 2 +- .../Descriptors/InterfaceFieldDescriptor.cs | 19 +++ .../ParentRequiresInterfaceProjectionTests.cs | 150 ++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs diff --git a/src/HotChocolate/Core/src/Types/Execution/Requirements/RequirementsTypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Execution/Requirements/RequirementsTypeInterceptor.cs index db40d0436d4..be36b0b7583 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Requirements/RequirementsTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Requirements/RequirementsTypeInterceptor.cs @@ -25,7 +25,7 @@ public override void OnBeforeCompleteName( "IL2072", Justification = "Runtime types come from the schema type model and are statically referenced.")] - public override void OnBeforeCompleteType( + public override void OnAfterCompleteType( ITypeCompletionContext completionContext, TypeSystemConfiguration configuration) { diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs index 8a4c4190cab..6e4d123bdb1 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs @@ -98,6 +98,25 @@ private void CompleteArguments(InterfaceFieldConfiguration definition) _parameterInfos, definition.GetParameterExpressionBuilders(), IsBatchResolver()); + + // Mirror ObjectFieldDescriptor so interface-declared [Parent(requires:)] resolvers still register requirements once inherited by the implementing object type. + foreach (var parameter in _parameterInfos) + { + if (!parameter.IsDefined(typeof(ParentAttribute))) + { + continue; + } + + var requirements = parameter.GetCustomAttribute()?.Requires; + if (!(requirements?.Length > 0)) + { + continue; + } + + Configuration.Flags |= CoreFieldFlags.WithRequirements; + Configuration.Features.Set(new FieldRequirementFeature(requirements, parameter.ParameterType)); + } + _argumentsInitialized = true; } } diff --git a/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs b/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs new file mode 100644 index 00000000000..3e5a196afc5 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs @@ -0,0 +1,150 @@ +using GreenDonut.Data; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Data; + +public class ParentRequiresInterfaceProjectionTests +{ + [Fact] + public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDeclaredOnInterface() + { + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryContext() + .AddQueryType() + .AddType() + .AddType() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .BuildRequestExecutorAsync(); + + // displayName is derived from Name, but the query selects only displayName; Name + // must reach the projection via [Parent(requires:)], not through the selection set. + var result = await executor.ExecuteAsync( + """ + { + brands { + displayName + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "brands": [ + { + "displayName": "Acme" + } + ] + } + } + """); + } + + [Fact] + public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDeclaredOnObjectType() + { + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryContext() + .AddQueryType() + .AddType() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .BuildRequestExecutorAsync(); + + // Same requirement, but declared directly on the object type (the path that already works). + var result = await executor.ExecuteAsync( + """ + { + brands { + displayName + } + } + """); + + result.MatchInlineSnapshot( + """ + { + "data": { + "brands": [ + { + "displayName": "Acme" + } + ] + } + } + """); + } + + public interface IBrand + { + int Id { get; } + } + + public class Brand : IBrand + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class BrandResolvers + { + public string GetDisplayName([Parent(requires: nameof(Brand.Name))] Brand brand) + => brand.Name; + } + + public class BrandInterfaceType : InterfaceType + { + protected override void Configure(IInterfaceTypeDescriptor descriptor) + { + descriptor + .Field("displayName") + .Type>() + .ResolveWith(r => r.GetDisplayName(default!)); + } + } + + public class BrandType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Implements(); + } + } + + public class Query + { + public IQueryable GetBrands(QueryContext query) + => s_brands.AsQueryable().With(query); + } + + public class BrandControlType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Field("displayName") + .Type>() + .ResolveWith(r => r.GetDisplayName(default!)); + } + } + + public class ControlQuery + { + public IQueryable GetBrands(QueryContext query) + => s_brands.AsQueryable().With(query); + } + + private static readonly Brand[] s_brands = + [ + new() + { + Id = 1, + Name = "Acme" + } + ]; +} From fc390ab2e16a84ed0fcbd45cd5d33e34234458c5 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 3 Jun 2026 06:54:27 +0000 Subject: [PATCH 2/3] Fixed one edge case --- .../Types/Types/ObjectType.Initialization.cs | 18 +++++ .../ParentRequiresInterfaceProjectionTests.cs | 66 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs b/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs index e14facf7028..05138d0fc31 100644 --- a/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs +++ b/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs @@ -1,4 +1,5 @@ using HotChocolate.Configuration; +using HotChocolate.Features; using HotChocolate.Internal; using HotChocolate.Resolvers; using HotChocolate.Types.Descriptors; @@ -133,15 +134,32 @@ protected virtual ObjectFieldCollection OnCompleteFields( if (processed.Add(field.Name) && interfaceFields.TryGetValue(field.Name, out var interfaceField)) { + var inheritedResolver = false; + if (!field.Resolvers.HasResolvers) { field.Resolvers = interfaceField.Resolvers; + inheritedResolver = true; } if (field.BatchResolver is null && interfaceField.BatchResolver is not null) { field.BatchResolver = interfaceField.BatchResolver; field.SetBatchResolverFlags(); + inheritedResolver = true; + } + + // The [Parent(requires:)] metadata is bound to the inherited resolver, so it has + // to travel with it. The CopyTo path below already does this for fields that are + // created from the interface field, but a field that is already declared on the + // object type reuses this merge path and would otherwise drop the requirement, + // leaving the required column out of the projection. + if (inheritedResolver + && (interfaceField.Flags & CoreFieldFlags.WithRequirements) == CoreFieldFlags.WithRequirements + && (field.Flags & CoreFieldFlags.WithRequirements) != CoreFieldFlags.WithRequirements) + { + field.Flags |= CoreFieldFlags.WithRequirements; + field.Features.Set(interfaceField.Features.GetRequired()); } } } diff --git a/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs b/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs index 3e5a196afc5..1e105ca7934 100644 --- a/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs @@ -10,6 +10,9 @@ public class ParentRequiresInterfaceProjectionTests [Fact] public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDeclaredOnInterface() { + // arrange + // displayName is derived from Name, but the query selects only displayName; Name + // must reach the projection via [Parent(requires:)], not through the selection set. var executor = await new ServiceCollection() .AddGraphQL() .AddQueryContext() @@ -19,8 +22,7 @@ public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDecla .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) .BuildRequestExecutorAsync(); - // displayName is derived from Name, but the query selects only displayName; Name - // must reach the projection via [Parent(requires:)], not through the selection set. + // act var result = await executor.ExecuteAsync( """ { @@ -30,6 +32,7 @@ public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDecla } """); + // assert result.MatchInlineSnapshot( """ { @@ -47,6 +50,8 @@ public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDecla [Fact] public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDeclaredOnObjectType() { + // arrange + // Same requirement, but declared directly on the object type (the path that already works). var executor = await new ServiceCollection() .AddGraphQL() .AddQueryContext() @@ -55,7 +60,7 @@ public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDecla .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) .BuildRequestExecutorAsync(); - // Same requirement, but declared directly on the object type (the path that already works). + // act var result = await executor.ExecuteAsync( """ { @@ -65,6 +70,47 @@ public async Task ParentRequires_Should_ProjectRequiredColumn_When_ResolverDecla } """); + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "brands": [ + { + "displayName": "Acme" + } + ] + } + } + """); + } + + [Fact] + public async Task ParentRequires_Should_ProjectRequiredColumn_When_ObjectTypeRedeclaresInterfaceField() + { + // arrange + // The object type declares displayName itself but without a resolver, so it inherits the + // interface resolver through the field-merge path instead of receiving a copied field. + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryContext() + .AddQueryType() + .AddType() + .AddType() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + brands { + displayName + } + } + """); + + // assert result.MatchInlineSnapshot( """ { @@ -116,6 +162,20 @@ protected override void Configure(IObjectTypeDescriptor descriptor) } } + public class BrandRedeclaredType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Implements(); + + // displayName is declared here without its own resolver; the interface resolver and its + // [Parent(requires:)] requirement must be merged onto this already-declared field. + descriptor + .Field("displayName") + .Type>(); + } + } + public class Query { public IQueryable GetBrands(QueryContext query) From fa0edfae489224723b778a58b91b1a30514b9dea Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 3 Jun 2026 07:39:37 +0000 Subject: [PATCH 3/3] Centralize parent registration in shared helpers and fix interface gap --- .../Configurations/FieldConfiguration.cs | 9 ++- .../Contracts/IInterfaceFieldDescriptor.cs | 33 +++++++++ .../Descriptors/InterfaceFieldDescriptor.cs | 33 +++++---- .../Descriptors/ObjectFieldDescriptor.cs | 37 +--------- .../Types/Helpers/FieldDescriptorUtilities.cs | 21 ++++++ .../Types/Types/ObjectType.Initialization.cs | 4 +- .../ParentRequiresInterfaceProjectionTests.cs | 67 +++++++++++++++++++ 7 files changed, 150 insertions(+), 54 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/FieldConfiguration.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/FieldConfiguration.cs index 8a981bf1949..87e1941bcd3 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/FieldConfiguration.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Configurations/FieldConfiguration.cs @@ -127,8 +127,15 @@ public IReadOnlyList GetDirectives() public void SetConnectionTotalCountFieldFlags() => Flags |= CoreFieldFlags.TotalCount; - public void SetFieldRequirements(string requirements, Type entityType) + public void SetFieldRequirements(string? requirements, Type? entityType) { + if (string.IsNullOrEmpty(requirements) || entityType is null) + { + Flags &= ~CoreFieldFlags.WithRequirements; + Features.Set(null); + return; + } + Flags |= CoreFieldFlags.WithRequirements; Features.Set(new FieldRequirementFeature(requirements, entityType)); } diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IInterfaceFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IInterfaceFieldDescriptor.cs index f1df22c1261..eb47b311fb9 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IInterfaceFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IInterfaceFieldDescriptor.cs @@ -330,4 +330,37 @@ IInterfaceFieldDescriptor Directive() IInterfaceFieldDescriptor Directive( string name, params ArgumentNode[] arguments); + + /// + /// Specifies the requirements for the parent object. + /// + /// + /// The requirements for the parent object. + /// + /// + /// Returns the descriptor to chain further configuration. + /// + IInterfaceFieldDescriptor ParentRequires(Expression> selector); + + /// + /// Specifies the requirements for the parent object. + /// + /// + /// The requirements for the parent object. + /// + /// + /// Returns the descriptor to chain further configuration. + /// + IInterfaceFieldDescriptor ParentRequires(string? requires); + + /// + /// Specifies the requirements for the parent object. + /// + /// + /// The requirements for the parent object. + /// + /// + /// Returns the descriptor to chain further configuration. + /// + IInterfaceFieldDescriptor ParentRequires(string? requires); } diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs index 6e4d123bdb1..8d1733b74d4 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/InterfaceFieldDescriptor.cs @@ -99,23 +99,7 @@ private void CompleteArguments(InterfaceFieldConfiguration definition) definition.GetParameterExpressionBuilders(), IsBatchResolver()); - // Mirror ObjectFieldDescriptor so interface-declared [Parent(requires:)] resolvers still register requirements once inherited by the implementing object type. - foreach (var parameter in _parameterInfos) - { - if (!parameter.IsDefined(typeof(ParentAttribute))) - { - continue; - } - - var requirements = parameter.GetCustomAttribute()?.Requires; - if (!(requirements?.Length > 0)) - { - continue; - } - - Configuration.Flags |= CoreFieldFlags.WithRequirements; - Configuration.Features.Set(new FieldRequirementFeature(requirements, parameter.ParameterType)); - } + FieldDescriptorUtilities.DiscoverParentRequirements(_parameterInfos, Configuration); _argumentsInitialized = true; } @@ -372,6 +356,21 @@ public IInterfaceFieldDescriptor Use(FieldMiddleware middleware) return this; } + public IInterfaceFieldDescriptor ParentRequires(Expression> selector) + => ParentRequires(ObjectFieldDescriptor.ExpressionSelectionSetFormatter.Format(selector)); + + public IInterfaceFieldDescriptor ParentRequires(string? requires) + { + Configuration.SetFieldRequirements(requires, typeof(TParent)); + return this; + } + + public IInterfaceFieldDescriptor ParentRequires(string? requires) + { + Configuration.SetFieldRequirements(requires, Configuration.SourceType); + return this; + } + public static InterfaceFieldDescriptor New( IDescriptorContext context, string fieldName) diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs index 7fe423cbd5e..82519939bd5 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectFieldDescriptor.cs @@ -242,22 +242,7 @@ private void CompleteArguments(ObjectFieldConfiguration definition) definition.GetParameterExpressionBuilders(), IsBatchResolver()); - foreach (var parameter in _parameterInfos) - { - if (!parameter.IsDefined(typeof(ParentAttribute))) - { - continue; - } - - var requirements = parameter.GetCustomAttribute()?.Requires; - if (!(requirements?.Length > 0)) - { - continue; - } - - Configuration.Flags |= CoreFieldFlags.WithRequirements; - Configuration.Features.Set(new FieldRequirementFeature(requirements, parameter.ParameterType)); - } + FieldDescriptorUtilities.DiscoverParentRequirements(_parameterInfos, Configuration); } _argumentsInitialized = true; @@ -603,29 +588,13 @@ public IObjectFieldDescriptor ParentRequires(Expression public IObjectFieldDescriptor ParentRequires(string? requires) { - if (!(requires?.Length > 0)) - { - Configuration.Flags &= ~CoreFieldFlags.WithRequirements; - Configuration.Features.Set(null); - return this; - } - - Configuration.Flags |= CoreFieldFlags.WithRequirements; - Configuration.Features.Set(new FieldRequirementFeature(requires, typeof(TParent))); + Configuration.SetFieldRequirements(requires, typeof(TParent)); return this; } public IObjectFieldDescriptor ParentRequires(string? requires) { - if (!(requires?.Length > 0)) - { - Configuration.Flags &= ~CoreFieldFlags.WithRequirements; - Configuration.Features.Set(null); - return this; - } - - Configuration.Flags |= CoreFieldFlags.WithRequirements; - Configuration.Features.Set(new FieldRequirementFeature(requires, Configuration.SourceType)); + Configuration.SetFieldRequirements(requires, Configuration.SourceType); return this; } diff --git a/src/HotChocolate/Core/src/Types/Types/Helpers/FieldDescriptorUtilities.cs b/src/HotChocolate/Core/src/Types/Types/Helpers/FieldDescriptorUtilities.cs index f6089c74004..03a80556b2a 100644 --- a/src/HotChocolate/Core/src/Types/Types/Helpers/FieldDescriptorUtilities.cs +++ b/src/HotChocolate/Core/src/Types/Types/Helpers/FieldDescriptorUtilities.cs @@ -138,4 +138,25 @@ public static void DiscoverArguments( } } } + + public static void DiscoverParentRequirements( + ParameterInfo[] parameters, + FieldConfiguration configuration) + { + foreach (var parameter in parameters) + { + if (!parameter.IsDefined(typeof(ParentAttribute))) + { + continue; + } + + var requirements = parameter.GetCustomAttribute()?.Requires; + if (!(requirements?.Length > 0)) + { + continue; + } + + configuration.SetFieldRequirements(requirements, parameter.ParameterType); + } + } } diff --git a/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs b/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs index 05138d0fc31..082357aee46 100644 --- a/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs +++ b/src/HotChocolate/Core/src/Types/Types/ObjectType.Initialization.cs @@ -158,8 +158,8 @@ protected virtual ObjectFieldCollection OnCompleteFields( && (interfaceField.Flags & CoreFieldFlags.WithRequirements) == CoreFieldFlags.WithRequirements && (field.Flags & CoreFieldFlags.WithRequirements) != CoreFieldFlags.WithRequirements) { - field.Flags |= CoreFieldFlags.WithRequirements; - field.Features.Set(interfaceField.Features.GetRequired()); + var requirements = interfaceField.Features.GetRequired(); + field.SetFieldRequirements(requirements.Requirements, requirements.EntityType); } } } diff --git a/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs b/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs index 1e105ca7934..6e9a31901a7 100644 --- a/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs +++ b/src/HotChocolate/Data/test/Data.Tests/ParentRequiresInterfaceProjectionTests.cs @@ -125,6 +125,46 @@ public async Task ParentRequires_Should_ProjectRequiredColumn_When_ObjectTypeRed """); } + [Fact] + public async Task ParentRequires_Should_ProjectRequiredColumn_When_DeclaredViaFluentApiOnInterface() + { + // arrange + // The requirement is declared through the fluent .ParentRequires(...) on the interface + // field rather than the [Parent(requires:)] attribute; Name must still reach the projection. + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryContext() + .AddQueryType() + .AddType() + .AddType() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + brands { + displayName + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "brands": [ + { + "displayName": "Acme" + } + ] + } + } + """); + } + public interface IBrand { int Id { get; } @@ -154,6 +194,33 @@ protected override void Configure(IInterfaceTypeDescriptor descriptor) } } + public class BrandFluentResolvers + { + public string GetDisplayName([Parent] Brand brand) + => brand.Name; + } + + public class BrandFluentInterfaceType : InterfaceType + { + protected override void Configure(IInterfaceTypeDescriptor descriptor) + { + // The requirement is supplied via the fluent ParentRequires API, not the attribute. + descriptor + .Field("displayName") + .Type>() + .ParentRequires(nameof(Brand.Name)) + .ResolveWith(r => r.GetDisplayName(default!)); + } + } + + public class BrandFluentType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Implements(); + } + } + public class BrandType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor)