diff --git a/src/HotChocolate/Core/src/Types/Types/Relay/Attributes/IDAttribute.cs b/src/HotChocolate/Core/src/Types/Types/Relay/Attributes/IDAttribute.cs index 3b3ac1f43b5..153be064c82 100644 --- a/src/HotChocolate/Core/src/Types/Types/Relay/Attributes/IDAttribute.cs +++ b/src/HotChocolate/Core/src/Types/Types/Relay/Attributes/IDAttribute.cs @@ -170,31 +170,8 @@ public class IDAttribute : DescriptorAttribute /// public IDAttribute() { - TypeName = typeof(T).Name; } - /// - /// With the property you can override the type name - /// of the ID. This is useful to rewrite a parameter of a mutation or query, to a specific - /// id. - /// - /// - /// - /// A field can be rewritten to an ID by adding [ID] to the resolver. - /// - /// - /// public class UserQuery - /// { - /// public User GetUserById([ID("User")] int id) => //.... - /// } - /// - /// - /// The argument is rewritten to ID and expect an ID of type User. - /// Assuming `User.id` has the value 1. The following string is base64 encoded - /// - /// - public string? TypeName { get; } - /// protected internal override void TryConfigure( IDescriptorContext context, @@ -204,13 +181,13 @@ protected internal override void TryConfigure( switch (descriptor) { case IInputFieldDescriptor d when element is PropertyInfo: - d.ID(TypeName); + d.ID(); break; case IArgumentDescriptor d when element is ParameterInfo: - d.ID(TypeName); + d.ID(); break; case IObjectFieldDescriptor d when element is MemberInfo: - d.ID(TypeName); + d.ID(); break; case IInterfaceFieldDescriptor d when element is MemberInfo: d.ID(); diff --git a/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/NodeIdNameDefinitionUnion.cs b/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/NodeIdNameDefinitionUnion.cs new file mode 100644 index 00000000000..10da17849c4 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/NodeIdNameDefinitionUnion.cs @@ -0,0 +1,15 @@ +#nullable enable +namespace HotChocolate.Types.Relay; + +/// +/// A discriminated union, containing either a literal or a type that defines +/// the name of the node identifier. +/// +internal record NodeIdNameDefinitionUnion(string? Literal, Type? Type) +{ + public static NodeIdNameDefinitionUnion? Create(string? literal) => + literal == null ? null : new NodeIdNameDefinitionUnion(literal, null); + + public static NodeIdNameDefinitionUnion Create() => + new NodeIdNameDefinitionUnion(null, typeof(T)); +} diff --git a/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/RelayIdFieldExtensions.cs b/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/RelayIdFieldExtensions.cs index 63d13e5dfdd..a679851db2c 100644 --- a/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/RelayIdFieldExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/RelayIdFieldExtensions.cs @@ -76,8 +76,7 @@ public static IInputFieldDescriptor ID( return descriptor; } - /// - /// the descriptor + /// /// /// the type from which the type name is derived /// @@ -85,7 +84,7 @@ public static IInputFieldDescriptor ID(this IInputFieldDescriptor descriptor) { ArgumentNullException.ThrowIfNull(descriptor); - RelayIdFieldHelpers.ApplyIdToField(descriptor, typeof(T).Name); + RelayIdFieldHelpers.ApplyIdToField(descriptor); return descriptor; } @@ -106,8 +105,7 @@ public static IArgumentDescriptor ID( return descriptor; } - /// - /// the descriptor + /// /// /// the type from which the type name is derived /// @@ -115,7 +113,7 @@ public static IArgumentDescriptor ID(this IArgumentDescriptor descriptor) { ArgumentNullException.ThrowIfNull(descriptor); - RelayIdFieldHelpers.ApplyIdToField(descriptor, typeof(T).Name); + RelayIdFieldHelpers.ApplyIdToField(descriptor); return descriptor; } @@ -136,16 +134,15 @@ public static IObjectFieldDescriptor ID( return descriptor; } - /// - /// the descriptor + /// /// /// the type from which the type name is derived /// public static IObjectFieldDescriptor ID(this IObjectFieldDescriptor descriptor) { ArgumentNullException.ThrowIfNull(descriptor); - - RelayIdFieldHelpers.ApplyIdToField(descriptor, typeof(T).Name); + + RelayIdFieldHelpers.ApplyIdToField(descriptor); return descriptor; } diff --git a/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/RelayIdFieldHelpers.cs b/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/RelayIdFieldHelpers.cs index a23d670e26a..02ddd893102 100644 --- a/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/RelayIdFieldHelpers.cs +++ b/src/HotChocolate/Core/src/Types/Types/Relay/Extensions/RelayIdFieldHelpers.cs @@ -25,21 +25,16 @@ internal static class RelayIdFieldHelpers /// public static void ApplyIdToField( IDescriptor descriptor, - string? typeName = null) - { - ArgumentNullException.ThrowIfNull(descriptor); - - var extend = descriptor.Extend(); - - // rewrite type - extend.OnBeforeCreate(RewriteConfiguration); + string? typeName = null) => + ApplyIdToFieldCore(descriptor, NodeIdNameDefinitionUnion.Create(typeName)); - // add serializer if globalID support is enabled. - if (extend.Context.Features.Get()?.IsEnabled == true) - { - extend.OnBeforeCompletion((c, d) => AddSerializerToInputField(c, d, typeName)); - } - } + /// + /// + /// the type from which the type name is derived + /// + public static void ApplyIdToField( + IDescriptor descriptor) => + ApplyIdToFieldCore(descriptor, NodeIdNameDefinitionUnion.Create()); /// /// Applies the .ID() to an argument @@ -50,24 +45,16 @@ public static void ApplyIdToField( /// public static void ApplyIdToField( IDescriptor descriptor, - string? typeName = null) - { - ArgumentNullException.ThrowIfNull(descriptor); - - // rewrite type - descriptor.Extend().OnBeforeCreate(RewriteConfiguration); + string? typeName = null) => + ApplyIdToFieldCore(descriptor, NodeIdNameDefinitionUnion.Create(typeName)); - if (descriptor is IDescriptor objectFieldDescriptor) - { - var extend = objectFieldDescriptor.Extend(); - - // add serializer if globalID support is enabled. - if (extend.Context.Features.Get()?.IsEnabled == true) - { - ApplyIdToField(extend.Configuration, typeName); - } - } - } + /// + /// + /// the type from which the type name is derived + /// + public static void ApplyIdToField( + IDescriptor descriptor) => + ApplyIdToFieldCore(descriptor, NodeIdNameDefinitionUnion.Create()); /// /// Applies the .ID() to an argument @@ -78,7 +65,8 @@ public static void ApplyIdToField( /// internal static void ApplyIdToField( ObjectFieldConfiguration configuration, - string? typeName = null) + NodeIdNameDefinitionUnion? nameDefinition = null, + TypeReference? dependsOn = null) { var placeholder = new ResultFormatterConfiguration( (_, r) => r, @@ -91,12 +79,75 @@ internal static void ApplyIdToField( ctx, (ObjectFieldConfiguration)def, placeholder, - typeName), + nameDefinition), configuration, - ApplyConfigurationOn.BeforeCompletion); + ApplyConfigurationOn.BeforeCompletion, + typeReference: dependsOn); configuration.Tasks.Add(configurationTask); } + internal static void ApplyIdToFieldCore( + IDescriptor descriptor, + NodeIdNameDefinitionUnion? nameDefinition) + { + ArgumentNullException.ThrowIfNull(descriptor); + + // rewrite type + descriptor.Extend().OnBeforeCreate(RewriteConfiguration); + + if (descriptor is IDescriptor objectFieldDescriptor) + { + var extend = objectFieldDescriptor.Extend(); + + // add serializer if globalID support is enabled. + if (extend.Context.Features.Get()?.IsEnabled == true) + { + if (nameDefinition?.Type != null) + { + var dependsOn = extend.Context.TypeInspector.GetTypeRef(nameDefinition.Type); + ApplyIdToField(extend.Configuration, nameDefinition, dependsOn); + } + else + { + ApplyIdToField(extend.Configuration, nameDefinition); + } + } + } + } + + public static void ApplyIdToFieldCore( + IDescriptor descriptor, + NodeIdNameDefinitionUnion? nameDefinition) + { + ArgumentNullException.ThrowIfNull(descriptor); + + var extend = descriptor.Extend(); + + // rewrite type + extend.OnBeforeCreate(RewriteConfiguration); + + // add serializer if globalID support is enabled. + if (extend.Context.Features.Get()?.IsEnabled == true) + { + if (nameDefinition?.Type == null) + { + extend.OnBeforeCompletion((c, d) => + AddSerializerToInputField(c, d, nameDefinition)); + } + else + { + var dependsOn = extend.Context.TypeInspector.GetTypeRef(nameDefinition.Type); + + var configurationTask = new OnCompleteTypeSystemConfigurationTask( + (ctx, def) => AddSerializerToInputField(ctx, (ArgumentConfiguration)def, nameDefinition), + extend.Configuration, + ApplyConfigurationOn.BeforeCompletion, + typeReference: dependsOn); + + extend.Configuration.Tasks.Add(configurationTask); + } + } + } private static void RewriteConfiguration( IDescriptorContext context, @@ -139,7 +190,7 @@ private static IExtendedType RewriteType(ITypeInspector typeInspector, ITypeInfo internal static void AddSerializerToInputField( ITypeCompletionContext completionContext, ArgumentConfiguration configuration, - string? typeName) + NodeIdNameDefinitionUnion? nameDefinition) { var typeInspector = completionContext.TypeInspector; IExtendedType? resultType; @@ -167,6 +218,8 @@ internal static void AddSerializerToInputField( completionContext.Type); } + var typeName = GetIdTypeName(completionContext, nameDefinition, typeInspector); + var validateType = typeName is not null; typeName ??= completionContext.Type.Name; SetSerializerInfos(completionContext.DescriptorContext, typeName, resultType); @@ -178,7 +231,7 @@ private static void AddSerializerToObjectField( ITypeCompletionContext completionContext, ObjectFieldConfiguration configuration, ResultFormatterConfiguration placeholder, - string? typeName) + NodeIdNameDefinitionUnion? nameDefinition) { var typeInspector = completionContext.TypeInspector; IExtendedType? resultType; @@ -201,6 +254,8 @@ private static void AddSerializerToObjectField( var serializerAccessor = completionContext.DescriptorContext.NodeIdSerializerAccessor; var index = configuration.FormatterConfigurations.IndexOf(placeholder); + var typeName = GetIdTypeName(completionContext, nameDefinition, typeInspector); + typeName ??= completionContext.Type.Name; SetSerializerInfos(completionContext.DescriptorContext, typeName, resultType); @@ -281,4 +336,19 @@ internal static void SetSerializerInfos(IDescriptorContext context, string typeN var feature = context.Features.GetOrSet(); feature.NodeIdTypes.TryAdd(typeName, runtimeTypeInfo.NamedType); } + + private static string? GetIdTypeName(ITypeCompletionContext completionContext, + NodeIdNameDefinitionUnion? nameDefinition, + ITypeInspector typeInspector) + { + var typeName = nameDefinition?.Literal; + if (nameDefinition?.Type is { } t) + { + var referencedType = typeInspector.GetType(t); + var foo = completionContext.GetType(TypeReference.Create(referencedType)); + typeName = foo.NamedType().Name; + } + + return typeName; + } } diff --git a/src/HotChocolate/Core/src/Types/Types/Relay/NodeResolverTypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Types/Relay/NodeResolverTypeInterceptor.cs index e6a7b0310da..8c2c47da9c1 100644 --- a/src/HotChocolate/Core/src/Types/Types/Relay/NodeResolverTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Relay/NodeResolverTypeInterceptor.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Reflection.Metadata; using HotChocolate.Configuration; using HotChocolate.Features; using HotChocolate.Language; @@ -157,7 +158,7 @@ public override void OnAfterMergeTypeExtensions() RelayIdFieldHelpers.AddSerializerToInputField( CompletionContext, argument, - fieldTypeDef.Name); + NodeIdNameDefinitionUnion.Create(fieldTypeDef.Name)); // As with the id argument, we also want to make sure that the ID field of // the field result type is a non-null ID type. diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Relay/IdDescriptorTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Relay/IdDescriptorTests.cs index 8d7f1ece92a..28a763e6d95 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Relay/IdDescriptorTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Relay/IdDescriptorTests.cs @@ -93,6 +93,130 @@ public void Id_Type_Is_Correctly_Inferred() .MatchSnapshot(); } + [Fact] + public async Task Id_Honors_CustomTypeNaming_OutputFields() + { + // arrange + var services = new ServiceCollection() + .AddGraphQL() + .AddMutationType() + .AddMutationConventions() + .ModifyOptions(o => o.StrictValidation = false) + .AddGlobalObjectIdentification(); + + // act + var result = await services.ExecuteRequestAsync( + """ + mutation { + out: doSomethingElse { + renamedUser { + userId + explicitUserId + fooId + fluentFooId + singleTypeFluentFooId + userIdMethod + explicitUserIdMethod + fooIdMethod + fluentFooIdMethod + singleTypeFluentFooIdMethod + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + $$""" + { + "data": { + "out": { + "renamedUser": { + "userId": "{{Convert.ToBase64String("RenamedUser:1"u8)}}", + "explicitUserId": "{{Convert.ToBase64String("RenamedUser:1"u8)}}", + "fooId": "{{Convert.ToBase64String("FooFoo:1"u8)}}", + "fluentFooId": "{{Convert.ToBase64String("FooFooFluent:1"u8)}}", + "singleTypeFluentFooId": "{{Convert.ToBase64String("FooFooFluentSingle:1"u8)}}", + "userIdMethod": "{{Convert.ToBase64String("RenamedUser:1"u8)}}", + "explicitUserIdMethod": "{{Convert.ToBase64String("RenamedUser:1"u8)}}", + "fooIdMethod": "{{Convert.ToBase64String("FooFoo:1"u8)}}", + "fluentFooIdMethod": "{{Convert.ToBase64String("FooFooFluent:1"u8)}}", + "singleTypeFluentFooIdMethod": "{{Convert.ToBase64String("FooFooFluentSingle:1"u8)}}" + } + } + } + } + """ + ); + } + [Fact] + public async Task Id_Honors_CustomTypeNaming_ValidInputs() + { + // arrange + var services = new ServiceCollection() + .AddGraphQL() + .AddMutationType() + .AddMutationConventions() + .ModifyOptions(o => o.StrictValidation = false) + .AddGlobalObjectIdentification(); + + var userId = Convert.ToBase64String("RenamedUser:100"u8); + var fooId = Convert.ToBase64String("FooFoo:300"u8); + var fluentFooId = Convert.ToBase64String("FooFooFluent:500"u8); + var singleTypeFluentFooId = Convert.ToBase64String("FooFooFluentSingle:600"u8); + + // act + var result = + await services.ExecuteRequestAsync($$""" + mutation { + validAnyIdInput1: acceptsAnyId(input: { id:"{{userId}}"}) { int } + validAnyIdInput2: acceptsAnyId(input: { id:"{{fooId}}"}) { int } + validAnyIdInput3: acceptsAnyId(input: { id:"{{fluentFooId}}"}) { int } + validAnyIdInput4: acceptsAnyId(input: { id:"{{singleTypeFluentFooId}}"}) { int } + + validUserIdInput: acceptsUserId(input: { id:"{{userId}}"}) { int } + validFooIdInput: acceptsFooId(input: { id:"{{fooId}}"}) { int } + validFluentFooIdInput: acceptsFluentFooId(input: { id:"{{fluentFooId}}"}) { int } + validSingleTypeFluentFooIdInput: acceptsSingleTypeFluentFooId(input: { id:"{{singleTypeFluentFooId}}"}) { int } + } + """); + + // assert + result.MatchSnapshot(); + } + + [Fact] + public async Task Id_Honors_CustomTypeNaming_Throws_On_InvalidInputs() + { + // arrange + var services = new ServiceCollection() + .AddGraphQL() + .AddMutationType() + .AddMutationConventions() + .ModifyOptions(o => o.StrictValidation = false) + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .AddErrorFilter(x => new Error { Message = x.Message }) + .AddGlobalObjectIdentification(); + + var userId = Convert.ToBase64String("RenamedUser:100"u8); + var fooId = Convert.ToBase64String("FooFoo:300"u8); + var fluentFooId = Convert.ToBase64String("FooFooFluent:500"u8); + var singleTypeFluentFooId = Convert.ToBase64String("FooFooFluentSingle:600"u8); + + // act + var result = await services.ExecuteRequestAsync($$""" + mutation { + validUserIdInput: acceptsUserId(input: { id:"{{fooId}}"}) { int } + validFooIdInput: acceptsFooId(input: { id:"{{fluentFooId}}"}) { int } + validFluentFooIdInput: acceptsFluentFooId(input: { id:"{{singleTypeFluentFooId}}"}) { int } + validSingleTypeFluentFooIdInput: acceptsSingleTypeFluentFooId(input: { id:"{{userId}}"}) { int } + } + """); + + // assert + result.MatchSnapshot(postFix: "InvalidArgs"); + } + private static byte[] Combine(ReadOnlySpan s1, ReadOnlySpan s2) { var buffer = new byte[s1.Length + s2.Length]; @@ -195,5 +319,75 @@ public interface IFooPayload string AnotherId { get; set; } } - private class Another; + public class Another + { + public string Id { get; set; } + } + + public class MutationWithRenamedIds + { + [GraphQLName("doSomethingElse")] + public IdContainer DoSomething() + { + return new IdContainer(); + } + + public int? AcceptsAnyId([ID] int? id = 0) => id; + public int? AcceptsUserId([ID] int? id = 0) => id; + public int? AcceptsFooId([ID] int? id = 0) => id; + public int? AcceptsFluentFooId([ID] int? id = 0) => id; + public int? AcceptsSingleTypeFluentFooId([ID] int? id = 0) => id; + } + + [GraphQLName("RenamedUser")] + public class IdContainer + { + [ID] + public int UserId { get; set; } = 1; + + [ID] + public int ExplicitUserId { get; set; } = 1; + + [ID] + public int FooId { get; set; } = 1; + + [ID] + public int FluentFooId { get; set; } = 1; + + [ID] + public int SingleTypeFluentFooId { get; set; } = 1; + + [ID] + public int UserIdMethod() => 1; + + [ID] + public int ExplicitUserIdMethod() => 1; + + [ID] + public int FooIdMethod() => 1; + + [ID] + public int FluentFooIdMethod() => 1; + + [ID] + public int SingleTypeFluentFooIdMethod() => 1; + } + + [GraphQLName("FooFoo")] + public class RenamedFoo; + + public class FluentRenamedFoo; + + public class FluentRenamedFooType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) => + descriptor.Name("FooFooFluent"); + } + + public class SingleTypeFluentRenamedFooType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) => + descriptor.Name("FooFooFluentSingle") + .BindFieldsExplicitly(); + } } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Relay/__snapshots__/IdDescriptorTests.Id_Honors_CustomTypeNaming_Throws_On_InvalidInputs_InvalidArgs.snap b/src/HotChocolate/Core/test/Types.Tests/Types/Relay/__snapshots__/IdDescriptorTests.Id_Honors_CustomTypeNaming_Throws_On_InvalidInputs_InvalidArgs.snap new file mode 100644 index 00000000000..dd746ccc21a --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Relay/__snapshots__/IdDescriptorTests.Id_Honors_CustomTypeNaming_Throws_On_InvalidInputs_InvalidArgs.snap @@ -0,0 +1,53 @@ +{ + "errors": [ + { + "message": "The node id type name `FooFoo` does not match the expected type name `RenamedUser`.", + "locations": [ + { + "line": 2, + "column": 5 + } + ], + "path": [ + "validUserIdInput" + ] + }, + { + "message": "The node id type name `FooFooFluent` does not match the expected type name `FooFoo`.", + "locations": [ + { + "line": 3, + "column": 5 + } + ], + "path": [ + "validFooIdInput" + ] + }, + { + "message": "The node id type name `FooFooFluentSingle` does not match the expected type name `FooFooFluent`.", + "locations": [ + { + "line": 4, + "column": 5 + } + ], + "path": [ + "validFluentFooIdInput" + ] + }, + { + "message": "The node id type name `RenamedUser` does not match the expected type name `FooFooFluentSingle`.", + "locations": [ + { + "line": 5, + "column": 5 + } + ], + "path": [ + "validSingleTypeFluentFooIdInput" + ] + } + ], + "data": null +} diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Relay/__snapshots__/IdDescriptorTests.Id_Honors_CustomTypeNaming_ValidInputs.snap b/src/HotChocolate/Core/test/Types.Tests/Types/Relay/__snapshots__/IdDescriptorTests.Id_Honors_CustomTypeNaming_ValidInputs.snap new file mode 100644 index 00000000000..860067cd9f2 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Relay/__snapshots__/IdDescriptorTests.Id_Honors_CustomTypeNaming_ValidInputs.snap @@ -0,0 +1,28 @@ +{ + "data": { + "validAnyIdInput1": { + "int": 100 + }, + "validAnyIdInput2": { + "int": 300 + }, + "validAnyIdInput3": { + "int": 500 + }, + "validAnyIdInput4": { + "int": 600 + }, + "validUserIdInput": { + "int": 100 + }, + "validFooIdInput": { + "int": 300 + }, + "validFluentFooIdInput": { + "int": 500 + }, + "validSingleTypeFluentFooIdInput": { + "int": 600 + } + } +}