From 698ccc83b2c31f9a594909b8b38daeed883457f3 Mon Sep 17 00:00:00 2001 From: NOlbert <45466413+N-Olbert@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:42:28 +0200 Subject: [PATCH 1/2] Coalesce value types within InputObjectCompiler --- .../Core/src/Types/Types/InputParser.cs | 8 -- .../Serialization/InputObjectCompiler.cs | 5 + .../Types.Tests/Types/InputParserTests.cs | 106 ++++++++++++------ ...oRuntimeTypeDefaultValueIsInitialized.snap | 32 ++++++ 4 files changed, 106 insertions(+), 45 deletions(-) create mode 100644 src/HotChocolate/Core/test/Types.Tests/Types/__snapshots__/InputParserTests.Integration_CodeFirst_InputObjectNoDefaultValue_NoRuntimeTypeDefaultValueIsInitialized.snap diff --git a/src/HotChocolate/Core/src/Types/Types/InputParser.cs b/src/HotChocolate/Core/src/Types/Types/InputParser.cs index 3067f926142..411ef59ccb6 100644 --- a/src/HotChocolate/Core/src/Types/Types/InputParser.cs +++ b/src/HotChocolate/Core/src/Types/Types/InputParser.cs @@ -614,14 +614,6 @@ private object DeserializeObject(object resultValue, InputObjectType type, Path } object? value = null; - - // if the type is nullable but the runtime type is a non-nullable value - // we will create a default instance and assign that instead. - if (field.RuntimeType.IsValueType) - { - value = Activator.CreateInstance(field.RuntimeType); - } - return field.IsOptional ? new Optional(value, false) : value; diff --git a/src/HotChocolate/Core/src/Types/Utilities/Serialization/InputObjectCompiler.cs b/src/HotChocolate/Core/src/Types/Utilities/Serialization/InputObjectCompiler.cs index 1cccf1289a1..6678e928338 100644 --- a/src/HotChocolate/Core/src/Types/Utilities/Serialization/InputObjectCompiler.cs +++ b/src/HotChocolate/Core/src/Types/Utilities/Serialization/InputObjectCompiler.cs @@ -243,6 +243,11 @@ private static void CompileSetProperties( { value = CreateOptional(value, field.RuntimeType); } + else if (field.Property.PropertyType.IsValueType + && System.Nullable.GetUnderlyingType(field.Property.PropertyType) == null) + { + value = Expression.Coalesce(value, Expression.Default(field.Property.PropertyType)); + } value = Expression.Convert(value, field.Property.PropertyType); Expression setPropertyValue = Expression.Call(instance, setter, value); diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs index e0523c411cd..37704fc0b1d 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs @@ -19,11 +19,7 @@ public void Deserialize_InputObject_AllIsSet() var type = schema.Types.GetType("TestInput"); - var fieldData = new Dictionary - { - { "field1", "abc" }, - { "field2", 123 } - }; + var fieldData = new Dictionary { { "field1", "abc" }, { "field2", 123 } }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -67,11 +63,7 @@ public void Deserialize_InputObject_AllIsSet_ConstructorInit() var type = schema.Types.GetType("Test2Input"); - var fieldData = new Dictionary - { - { "field1", "abc" }, - { "field2", 123 } - }; + var fieldData = new Dictionary { { "field1", "abc" }, { "field2", 123 } }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -115,10 +107,7 @@ public void Deserialize_InputObject_AllIsSet_MissingRequired() var type = schema.Types.GetType("Test2Input"); - var fieldData = new Dictionary - { - { "field2", 123 } - }; + var fieldData = new Dictionary { { "field2", 123 } }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -161,11 +150,7 @@ public void Deserialize_InputObject_AllIsSet_OneInvalidField() var type = schema.Types.GetType("TestInput"); - var fieldData = new Dictionary - { - { "field2", 123 }, - { "field3", 123 } - }; + var fieldData = new Dictionary { { "field2", 123 }, { "field3", 123 } }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -213,12 +198,7 @@ public void Deserialize_InputObject_AllIsSet_TwoInvalidFields() var type = schema.Types.GetType("TestInput"); - var fieldData = new Dictionary - { - { "field2", 123 }, - { "field3", 123 }, - { "field4", 123 } - }; + var fieldData = new Dictionary { { "field2", 123 }, { "field3", 123 }, { "field4", 123 } }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -275,10 +255,7 @@ public void Parse_InputObject_AllIsSet_IgnoreAdditionalInputFields() var converter = new DefaultTypeConverter(); - var options = new InputParserOptions - { - IgnoreAdditionalInputFields = true - }; + var options = new InputParserOptions { IgnoreAdditionalInputFields = true }; // act var parser = new InputParser(converter, options); @@ -480,10 +457,7 @@ public void Force_NonNull_Struct_To_Be_Optional() var type = schema.Types.GetType("Test4Input"); - var fieldData = new Dictionary - { - { "field1", "abc" } - }; + var fieldData = new Dictionary { { "field1", "abc" } }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -493,6 +467,55 @@ public void Force_NonNull_Struct_To_Be_Optional() Assert.IsType(runtimeValue).MatchSnapshot(); } + [Fact] + public async Task Integration_CodeFirst_InputObjectNoDefaultValue_NoRuntimeTypeDefaultValueIsInitialized() + { + // arrange + var resolverArgumentsAccessor = new ResolverArgumentsAccessor(); + var executor = await new ServiceCollection() + .AddSingleton(resolverArgumentsAccessor) + .AddGraphQL() + .AddQueryType(x => x.Field("foo") + .Argument("args", a => a.Type>()) + .Type() + .ResolveWith(r => r.ResolveWith(default!))) + .BuildRequestExecutorAsync(); + + // act + var query = + OperationRequest.FromSourceText( + """ + { + a: foo(args: { string: "allSet" int: 1 bool: true }) + b: foo(args: { string: "noneSet" }) + c: foo(args: { string: "intExplicitlyNull" int: null }) + d: foo(args: { string: "boolExplicitlyNull" bool: null }) + e: foo(args: { string: "intSetBoolNull" int: 1 bool: null }) + f: foo(args: { string: "boolSetIntNull" int: null bool: true }) + } + """); + await executor.ExecuteAsync(query, CancellationToken.None); + + // assert + resolverArgumentsAccessor.Arguments.MatchSnapshot(); + } + + private class ResolverArgumentsAccessor + { + private readonly object _lock = new(); + internal SortedDictionary?> Arguments { get; } = new(); + + internal string? ResolveWith(IDictionary args) + { + lock (_lock) + { + Arguments[args["string"]!.ToString()!] = args; + } + + return "OK"; + } + } + public class TestInput { public string? Field1 { get; set; } @@ -519,8 +542,7 @@ public Test3Input(string field1) Field1 = field1; } - [DefaultValue("DefaultAbc")] - public string Field1 { get; } + [DefaultValue("DefaultAbc")] public string Field1 { get; } public int? Field2 { get; set; } } @@ -537,8 +559,7 @@ public class Query4 public class Test4 { - [DefaultValue("DefaultAbc")] - public string? Field1 { get; set; } + [DefaultValue("DefaultAbc")] public string? Field1 { get; set; } public int? Field2 { get; set; } } @@ -564,4 +585,15 @@ public class Test4Input public int Field2 { get; set; } } + + public class MyInputType : InputObjectType + { + protected override void Configure(IInputObjectTypeDescriptor descriptor) + { + descriptor.Name("MyInput"); + descriptor.Field("string").Type(); + descriptor.Field("int").Type(); + descriptor.Field("bool").Type(); + } + } } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/__snapshots__/InputParserTests.Integration_CodeFirst_InputObjectNoDefaultValue_NoRuntimeTypeDefaultValueIsInitialized.snap b/src/HotChocolate/Core/test/Types.Tests/Types/__snapshots__/InputParserTests.Integration_CodeFirst_InputObjectNoDefaultValue_NoRuntimeTypeDefaultValueIsInitialized.snap new file mode 100644 index 00000000000..8d706adf995 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/__snapshots__/InputParserTests.Integration_CodeFirst_InputObjectNoDefaultValue_NoRuntimeTypeDefaultValueIsInitialized.snap @@ -0,0 +1,32 @@ +{ + "allSet": { + "string": "allSet", + "int": 1, + "bool": true + }, + "boolExplicitlyNull": { + "string": "boolExplicitlyNull", + "int": null, + "bool": null + }, + "boolSetIntNull": { + "string": "boolSetIntNull", + "int": null, + "bool": true + }, + "intExplicitlyNull": { + "string": "intExplicitlyNull", + "int": null, + "bool": null + }, + "intSetBoolNull": { + "string": "intSetBoolNull", + "int": 1, + "bool": null + }, + "noneSet": { + "string": "noneSet", + "int": null, + "bool": null + } +} From fd9a453f332853719a614a98a99fd97fe915ff47 Mon Sep 17 00:00:00 2001 From: NOlbert <45466413+N-Olbert@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:24:27 +0200 Subject: [PATCH 2/2] Undo formatting changes --- .../Types.Tests/Types/InputParserTests.cs | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs index 37704fc0b1d..21660f013f6 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs @@ -19,7 +19,11 @@ public void Deserialize_InputObject_AllIsSet() var type = schema.Types.GetType("TestInput"); - var fieldData = new Dictionary { { "field1", "abc" }, { "field2", 123 } }; + var fieldData = new Dictionary + { + { "field1", "abc" }, + { "field2", 123 } + }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -63,7 +67,11 @@ public void Deserialize_InputObject_AllIsSet_ConstructorInit() var type = schema.Types.GetType("Test2Input"); - var fieldData = new Dictionary { { "field1", "abc" }, { "field2", 123 } }; + var fieldData = new Dictionary + { + { "field1", "abc" }, + { "field2", 123 } + }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -107,7 +115,10 @@ public void Deserialize_InputObject_AllIsSet_MissingRequired() var type = schema.Types.GetType("Test2Input"); - var fieldData = new Dictionary { { "field2", 123 } }; + var fieldData = new Dictionary + { + { "field2", 123 } + }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -150,7 +161,11 @@ public void Deserialize_InputObject_AllIsSet_OneInvalidField() var type = schema.Types.GetType("TestInput"); - var fieldData = new Dictionary { { "field2", 123 }, { "field3", 123 } }; + var fieldData = new Dictionary + { + { "field2", 123 }, + { "field3", 123 } + }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -198,7 +213,12 @@ public void Deserialize_InputObject_AllIsSet_TwoInvalidFields() var type = schema.Types.GetType("TestInput"); - var fieldData = new Dictionary { { "field2", 123 }, { "field3", 123 }, { "field4", 123 } }; + var fieldData = new Dictionary + { + { "field2", 123 }, + { "field3", 123 }, + { "field4", 123 } + }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -255,7 +275,10 @@ public void Parse_InputObject_AllIsSet_IgnoreAdditionalInputFields() var converter = new DefaultTypeConverter(); - var options = new InputParserOptions { IgnoreAdditionalInputFields = true }; + var options = new InputParserOptions + { + IgnoreAdditionalInputFields = true + }; // act var parser = new InputParser(converter, options); @@ -457,7 +480,10 @@ public void Force_NonNull_Struct_To_Be_Optional() var type = schema.Types.GetType("Test4Input"); - var fieldData = new Dictionary { { "field1", "abc" } }; + var fieldData = new Dictionary + { + { "field1", "abc" } + }; // act var parser = new InputParser(new DefaultTypeConverter()); @@ -542,7 +568,8 @@ public Test3Input(string field1) Field1 = field1; } - [DefaultValue("DefaultAbc")] public string Field1 { get; } + [DefaultValue("DefaultAbc")] + public string Field1 { get; } public int? Field2 { get; set; } } @@ -559,7 +586,8 @@ public class Query4 public class Test4 { - [DefaultValue("DefaultAbc")] public string? Field1 { get; set; } + [DefaultValue("DefaultAbc")] + public string? Field1 { get; set; } public int? Field2 { get; set; } }