diff --git a/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs b/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs index b9e39867294..68f4429be6e 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/Handlers/DefaultTypeDiscoveryHandler.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using HotChocolate.Internal; +using HotChocolate.Types.Helpers; using HotChocolate.Types; using HotChocolate.Types.Descriptors; +using System.Diagnostics; namespace HotChocolate.Configuration; @@ -16,6 +18,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 +107,173 @@ public override bool TryInferType( return true; } + private static bool TryCreateKeyValuePairTypeRef( + TypeReference typeReference, + 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<,>)) + { + 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( + extendedType, + TypeKind.Object); + + schemaTypeRefs = + [ + TypeReference.Create( + typeName, + typeReference, + _ => 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( + extendedType, + TypeKind.InputObject); + + schemaTypeRefs = + [ + TypeReference.Create( + typeName, + typeReference, + _ => 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 ObjectType CreateKeyValuePairObjectType( + 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 InputObjectType CreateKeyValuePairInputObjectType( + 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 is 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..1b52f22c49f 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,54 @@ 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; } + } + + public class NullableDictionaryInputQuery + { + public string GetFoo(NullableDictionaryInput input) => "ok"; + } + + 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! +}