From 38378553aacdb96ec1ba539afe4cae71bd2a28f5 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 24 Feb 2026 23:38:59 +0000 Subject: [PATCH 1/3] Fix nullable dictionary value nullability in KeyValuePair types --- .../Handlers/DefaultTypeDiscoveryHandler.cs | 168 ++++++++++++++++++ .../src/Types/Internal/ExtendedType.Helper.cs | 13 ++ .../Types/Internal/ExtendedType.Members.cs | 30 ++++ .../Types/InputObjectTypeDictionaryTests.cs | 49 +++++ 4 files changed, 260 insertions(+) diff --git a/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs b/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs index b9e39867294..dde29bdf3af 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using HotChocolate.Internal; +using HotChocolate.Types.Helpers; using HotChocolate.Types; using HotChocolate.Types.Descriptors; @@ -16,6 +17,11 @@ public override bool TryInferType( TypeDiscoveryInfo typeInfo, [NotNullWhen(true)] out TypeReference[]? schemaTypeRefs) { + if (TryCreateKeyValuePairTypeRef(typeReference, typeInfo, out schemaTypeRefs)) + { + return true; + } + TypeReference? schemaType; if (typeInfo.IsStatic) @@ -100,6 +106,168 @@ public override bool TryInferType( return true; } + private static bool TryCreateKeyValuePairTypeRef( + TypeReference typeReference, + TypeDiscoveryInfo typeInfo, + [NotNullWhen(true)] out TypeReference[]? schemaTypeRefs) + { + if (typeReference is not ExtendedTypeReference { Type: { } extendedType }) + { + schemaTypeRefs = null; + return false; + } + + if (!extendedType.IsGeneric + || extendedType.Definition != typeof(KeyValuePair<,>) + || extendedType.TypeArguments.Count != 2) + { + schemaTypeRefs = null; + return false; + } + + if (typeInfo.Context is TypeContext.Output or TypeContext.None) + { + var typeName = CreateKeyValuePairTypeName( + extendedType, + TypeKind.Object); + + schemaTypeRefs = + [ + TypeReference.Create( + typeName, + typeReference, + _ => CreateOutputType(extendedType, typeName), + typeReference.Context, + typeReference.Scope) + ]; + return true; + } + + if (typeInfo.Context is TypeContext.Input) + { + var typeName = CreateKeyValuePairTypeName( + extendedType, + TypeKind.InputObject); + + schemaTypeRefs = + [ + TypeReference.Create( + typeName, + typeReference, + _ => CreateInputType(extendedType, typeName), + typeReference.Context, + typeReference.Scope) + ]; + return true; + } + + schemaTypeRefs = null; + return false; + } + + private static TypeSystemObject CreateOutputType( + IExtendedType keyValuePairType, + string typeName) + { + var runtimeType = keyValuePairType.Type; + var keyType = keyValuePairType.TypeArguments[0]; + var valueType = keyValuePairType.TypeArguments[1]; + var keyProperty = runtimeType.GetProperty("Key")!; + var valueProperty = runtimeType.GetProperty("Value")!; + + return new ObjectType( + descriptor => + { + descriptor.Name(typeName); + + descriptor.Field(keyProperty) + .Name("key") + .Extend() + .OnBeforeCreate( + (_, field) => field.SetMoreSpecificType(keyType, TypeContext.Output)); + + descriptor.Field(valueProperty) + .Name("value") + .Extend() + .OnBeforeCreate( + (_, field) => field.SetMoreSpecificType(valueType, TypeContext.Output)); + + descriptor.Extend() + .OnBeforeCreate( + (_, type) => + { + type.RuntimeType = runtimeType; + type.FieldBindingType = typeof(object); + }); + }); + } + + private static TypeSystemObject CreateInputType( + IExtendedType keyValuePairType, + string typeName) + { + var runtimeType = keyValuePairType.Type; + var keyType = keyValuePairType.TypeArguments[0]; + var valueType = keyValuePairType.TypeArguments[1]; + var keyProperty = runtimeType.GetProperty("Key")!; + var valueProperty = runtimeType.GetProperty("Value")!; + var keyGetter = keyProperty.GetMethod!; + var valueGetter = valueProperty.GetMethod!; + + return new InputObjectType( + descriptor => + { + descriptor.Name(typeName); + + descriptor.Field("key") + .Extend() + .OnBeforeCreate( + (_, field) => field.SetMoreSpecificType(keyType, TypeContext.Input)); + + descriptor.Field("value") + .Extend() + .OnBeforeCreate( + (_, field) => field.SetMoreSpecificType(valueType, TypeContext.Input)); + + descriptor.Extend() + .OnBeforeCreate( + (_, type) => + { + type.RuntimeType = runtimeType; + type.CreateInstance = + values => Activator.CreateInstance(runtimeType, values[0], values[1])!; + type.GetFieldData = + (obj, values) => + { + values[0] = keyGetter.Invoke(obj, []); + values[1] = valueGetter.Invoke(obj, []); + }; + }); + }); + } + + private static string CreateKeyValuePairTypeName(IExtendedType type, TypeKind kind) + { + var keyType = type.TypeArguments[0]; + var valueType = type.TypeArguments[1]; + var keyName = keyType.Type.Name; + var valueName = valueType.Type.Name; + + if (keyType.IsNullable) + { + keyName = $"Nullable{keyName}"; + } + + if (valueType.IsNullable) + { + valueName = $"Nullable{valueName}"; + } + + return kind == TypeKind.InputObject + ? $"KeyValuePairOf{keyName}And{valueName}Input" + : $"KeyValuePairOf{keyName}And{valueName}"; + } + public override bool TryInferKind( TypeReference typeReference, TypeDiscoveryInfo typeInfo, diff --git a/src/HotChocolate/Core/src/Types/Internal/ExtendedType.Helper.cs b/src/HotChocolate/Core/src/Types/Internal/ExtendedType.Helper.cs index 99f0691c3ec..a1174d5edbe 100644 --- a/src/HotChocolate/Core/src/Types/Internal/ExtendedType.Helper.cs +++ b/src/HotChocolate/Core/src/Types/Internal/ExtendedType.Helper.cs @@ -181,6 +181,7 @@ private static ExtendedType ChangeNullability( { if (cache.TryGetType(id, out var cached)) { + position += CountComponents(cached); return cached; } @@ -248,6 +249,18 @@ private static ExtendedType ChangeNullability( return type; } + private static int CountComponents(IExtendedType type) + { + var count = 1; + + foreach (var typeArgument in type.TypeArguments) + { + count += CountComponents(typeArgument); + } + + return count; + } + internal static ExtendedTypeId CreateIdentifier(IExtendedType type) { var position = 0; diff --git a/src/HotChocolate/Core/src/Types/Internal/ExtendedType.Members.cs b/src/HotChocolate/Core/src/Types/Internal/ExtendedType.Members.cs index 2cfd24182e9..385833941d0 100644 --- a/src/HotChocolate/Core/src/Types/Internal/ExtendedType.Members.cs +++ b/src/HotChocolate/Core/src/Types/Internal/ExtendedType.Members.cs @@ -96,6 +96,16 @@ private static ExtendedType Rewrite( elementType = extendedArguments[0]; } } + else if (extendedType.TypeArguments.Count == 2 + && itemType.IsGenericType + && itemType.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) + { + elementType = CreateDictionaryItemType( + itemType, + extendedArguments[0], + extendedArguments[1], + cache); + } elementType ??= FromType(itemType, cache); } @@ -120,6 +130,26 @@ private static ExtendedType Rewrite( : cache.GetType(rewritten.Id); } + private static ExtendedType CreateDictionaryItemType( + Type itemType, + IExtendedType keyType, + IExtendedType valueType, + TypeCache cache) + { + var keyNullability = Tools.CollectNullability(keyType); + var valueNullability = Tools.CollectNullability(valueType); + var nullability = new bool?[1 + keyNullability.Length + valueNullability.Length]; + + nullability[0] = false; + keyNullability.CopyTo(nullability, 1); + valueNullability.CopyTo(nullability, 1 + keyNullability.Length); + + return (ExtendedType)Tools.ChangeNullability( + FromType(itemType, cache), + nullability, + cache); + } + private static ExtendedType CreateExtendedType( bool? context, ReadOnlySpan flags, diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/InputObjectTypeDictionaryTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/InputObjectTypeDictionaryTests.cs index 3a98b9a09a4..e09d5c3cf64 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/InputObjectTypeDictionaryTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/InputObjectTypeDictionaryTests.cs @@ -42,6 +42,40 @@ public async Task Dictionary_Is_Correctly_Deserialized() result.ToJson().MatchSnapshot(); } + [Fact] + public void Dictionary_Input_With_Nullable_Value_Is_Correctly_Detected() + { + // arrange + // act + var schema = SchemaBuilder.New() + .AddQueryType() + .Create(); + + // assert + var fooInputType = schema.Types.GetType("NullableDictionaryInput"); + var keyValuePairType = schema.Types.GetType( + fooInputType.Fields["contextData"].Type.TypeName()); + + Assert.False(keyValuePairType.Fields["value"].Type.IsNonNullType()); + } + + [Fact] + public void Dictionary_Output_With_Nullable_Value_Is_Correctly_Detected() + { + // arrange + // act + var schema = SchemaBuilder.New() + .AddQueryType() + .Create(); + + // assert + var queryType = schema.Types.GetType("NullableDictionaryOutputQuery"); + var keyValuePairType = schema.Types.GetType( + queryType.Fields["contextData"].Type.TypeName()); + + Assert.False(keyValuePairType.Fields["value"].Type.IsNonNullType()); + } + public class Query { public string GetFoo(FooInput input) @@ -62,4 +96,19 @@ public class FooInput public IReadOnlyDictionary? ContextData3 { get; set; } } + + public class NullableDictionaryInput + { + public Dictionary? ContextData { get; set; } + } + + public class NullableDictionaryInputQuery + { + public string GetFoo(NullableDictionaryInput input) => "ok"; + } + + public class NullableDictionaryOutputQuery + { + public Dictionary? GetContextData() => null; + } } From bf9c44a0c1df1fe8287edcc34ab935f2d5d29fd7 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 25 Feb 2026 11:50:22 +0100 Subject: [PATCH 2/3] polish --- .../Handlers/DefaultTypeDiscoveryHandler.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs b/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs index dde29bdf3af..68f4429be6e 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs @@ -4,6 +4,7 @@ using HotChocolate.Types.Helpers; using HotChocolate.Types; using HotChocolate.Types.Descriptors; +using System.Diagnostics; namespace HotChocolate.Configuration; @@ -111,20 +112,22 @@ private static bool TryCreateKeyValuePairTypeRef( TypeDiscoveryInfo typeInfo, [NotNullWhen(true)] out TypeReference[]? schemaTypeRefs) { + // Only extended type references can represent dictionaries. if (typeReference is not ExtendedTypeReference { Type: { } extendedType }) { schemaTypeRefs = null; return false; } + // We only handle generic KeyValuePair types here. if (!extendedType.IsGeneric - || extendedType.Definition != typeof(KeyValuePair<,>) - || extendedType.TypeArguments.Count != 2) + || extendedType.Definition != typeof(KeyValuePair<,>)) { schemaTypeRefs = null; return false; } + // For output types we create an object type to represent the key-value pair. if (typeInfo.Context is TypeContext.Output or TypeContext.None) { var typeName = CreateKeyValuePairTypeName( @@ -136,13 +139,14 @@ private static bool TryCreateKeyValuePairTypeRef( TypeReference.Create( typeName, typeReference, - _ => CreateOutputType(extendedType, typeName), + _ => CreateKeyValuePairObjectType(extendedType, typeName), typeReference.Context, typeReference.Scope) ]; return true; } + // For input types we create an input object type instead. if (typeInfo.Context is TypeContext.Input) { var typeName = CreateKeyValuePairTypeName( @@ -154,18 +158,20 @@ private static bool TryCreateKeyValuePairTypeRef( TypeReference.Create( typeName, typeReference, - _ => CreateInputType(extendedType, typeName), + _ => CreateKeyValuePairInputObjectType(extendedType, typeName), typeReference.Context, typeReference.Scope) ]; return true; } + // We should never get here as all context options are exhausted above. + Debug.Fail("Unexpected TypeContext value."); schemaTypeRefs = null; return false; } - private static TypeSystemObject CreateOutputType( + private static ObjectType CreateKeyValuePairObjectType( IExtendedType keyValuePairType, string typeName) { @@ -202,7 +208,7 @@ private static TypeSystemObject CreateOutputType( }); } - private static TypeSystemObject CreateInputType( + private static InputObjectType CreateKeyValuePairInputObjectType( IExtendedType keyValuePairType, string typeName) { @@ -263,7 +269,7 @@ private static string CreateKeyValuePairTypeName(IExtendedType type, TypeKind ki valueName = $"Nullable{valueName}"; } - return kind == TypeKind.InputObject + return kind is TypeKind.InputObject ? $"KeyValuePairOf{keyName}And{valueName}Input" : $"KeyValuePairOf{keyName}And{valueName}"; } From c4c5ef384eaccca7221292ed355d0f6978166794 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 25 Feb 2026 12:16:39 +0100 Subject: [PATCH 3/3] Added more tests --- .../Types/InputObjectTypeDictionaryTests.cs | 35 +++++++++++++++++++ ...yValuePair_Overrides_Inferred_Type.graphql | 12 +++++++ 2 files changed, 47 insertions(+) create mode 100644 src/HotChocolate/Core/test/Types.Tests/Types/__snapshots__/InputObjectTypeDictionaryTests.Explicit_ObjectType_For_KeyValuePair_Overrides_Inferred_Type.graphql diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/InputObjectTypeDictionaryTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/InputObjectTypeDictionaryTests.cs index e09d5c3cf64..1b52f22c49f 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/InputObjectTypeDictionaryTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/InputObjectTypeDictionaryTests.cs @@ -97,6 +97,26 @@ public class FooInput public IReadOnlyDictionary? ContextData3 { get; set; } } + [Fact] + public void Explicit_ObjectType_For_KeyValuePair_Overrides_Inferred_Type() + { + // arrange & act + var schema = SchemaBuilder.New() + .AddQueryType() + .AddType() + .Create(); + + // assert — the custom type name and field names must appear in the schema, + // proving the explicit ObjectType definition is used instead of the + // auto-inferred KeyValuePairOfStringAndString. + schema.MatchSnapshot(); + + var customType = schema.Types.GetType("StringPair"); + Assert.Equal("first", customType.Fields["first"].Name); + Assert.Equal("second", customType.Fields["second"].Name); + Assert.Equal(2, customType.Fields.Count(f => !f.IsIntrospectionField)); + } + public class NullableDictionaryInput { public Dictionary? ContextData { get; set; } @@ -111,4 +131,19 @@ public class NullableDictionaryOutputQuery { public Dictionary? GetContextData() => null; } + + public class CustomKeyValuePairType : ObjectType> + { + protected override void Configure(IObjectTypeDescriptor> descriptor) + { + descriptor.Name("StringPair"); + descriptor.Field(x => x.Key).Name("first"); + descriptor.Field(x => x.Value).Name("second"); + } + } + + public class CustomKvpOutputQuery + { + public Dictionary? GetItems() => null; + } } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/__snapshots__/InputObjectTypeDictionaryTests.Explicit_ObjectType_For_KeyValuePair_Overrides_Inferred_Type.graphql b/src/HotChocolate/Core/test/Types.Tests/Types/__snapshots__/InputObjectTypeDictionaryTests.Explicit_ObjectType_For_KeyValuePair_Overrides_Inferred_Type.graphql new file mode 100644 index 00000000000..599c7adf813 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/__snapshots__/InputObjectTypeDictionaryTests.Explicit_ObjectType_For_KeyValuePair_Overrides_Inferred_Type.graphql @@ -0,0 +1,12 @@ +schema { + query: CustomKvpOutputQuery +} + +type CustomKvpOutputQuery { + items: [StringPair!] +} + +type StringPair { + first: String! + second: String! +}