diff --git a/src/HotChocolate/Core/src/Types/Types/InputParser.cs b/src/HotChocolate/Core/src/Types/Types/InputParser.cs index 8facaebdbd3..cc12911432b 100644 --- a/src/HotChocolate/Core/src/Types/Types/InputParser.cs +++ b/src/HotChocolate/Core/src/Types/Types/InputParser.cs @@ -619,14 +619,6 @@ private static object DeserializeLeaf( } 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 0173dca652d..eaff0d0a5cf 100644 --- a/src/HotChocolate/Core/src/Types/Utilities/Serialization/InputObjectCompiler.cs +++ b/src/HotChocolate/Core/src/Types/Utilities/Serialization/InputObjectCompiler.cs @@ -204,6 +204,11 @@ private static Expression[] CompileAssignParameters( { value = CreateOptional(value, field.RuntimeType); } + else if (parameter.ParameterType.IsValueType + && System.Nullable.GetUnderlyingType(parameter.ParameterType) == null) + { + value = Expression.Coalesce(value, Expression.Default(parameter.ParameterType)); + } expressions[i] = Expression.Convert(value, parameter.ParameterType); } @@ -243,6 +248,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 c59121917a5..02cd1b9266f 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/InputParserTests.cs @@ -534,6 +534,114 @@ public void Force_NonNull_Struct_To_Be_Optional() Assert.IsType(runtimeValue).MatchSnapshot(); } + [Fact] + public void Force_NonNull_Struct_Constructor_Parameter_To_Be_Optional() + { + // arrange + var schema = SchemaBuilder.New() + .AddInputObjectType(d => + { + d.Field(t => t.Field2).Type(); + d.Field(t => t.Field3).Type(); + }) + .ModifyOptions(o => o.StrictValidation = false) + .Create(); + + var type = schema.Types.GetType("Test5Input"); + + var context = new Mock(); + context.Setup(t => t.Features).Returns(FeatureCollection.Empty); + + var parser = new InputParser(new DefaultTypeConverter()); + + using var missingFieldsData = JsonDocument.Parse( + """ + { + "field1": "abc" + } + """); + + using var explicitNullData = JsonDocument.Parse( + """ + { + "field1": "abc", + "field2": null, + "field3": null + } + """); + + // act + var missingFields = Assert.IsType( + parser.ParseInputValue( + missingFieldsData.RootElement, + type, + context.Object, + Path.Root)); + var explicitNull = Assert.IsType( + parser.ParseInputValue( + explicitNullData.RootElement, + type, + context.Object, + Path.Root)); + + // assert + Assert.Equal("abc", missingFields.Field1); + Assert.Equal(0, missingFields.Field2); + Assert.False(missingFields.Field3); + Assert.Equal("abc", explicitNull.Field1); + Assert.Equal(0, explicitNull.Field2); + Assert.False(explicitNull.Field3); + } + + [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; } @@ -605,4 +713,31 @@ public class Test4Input public int Field2 { get; set; } } + + public class Test5Input + { + public Test5Input(string field1, int field2, bool field3) + { + Field1 = field1; + Field2 = field2; + Field3 = field3; + } + + public string Field1 { get; } + + public int Field2 { get; } + + public bool Field3 { get; } + } + + 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 + } +}