From 2f7ada038fbbc4cb33b97caf4e3c244ec2f0b66a Mon Sep 17 00:00:00 2001 From: Steve Harter Date: Thu, 15 Aug 2019 11:07:57 -0500 Subject: [PATCH] Support custom converters that treat non-null input as null (#40287) --- .../JsonClassInfo.AddProperty.cs | 21 ++- .../JsonPropertyInfoNotNullable.cs | 3 - ...sonPropertyInfoNotNullableContravariant.cs | 3 - .../CustomConverterTests.NullValueType.cs | 163 ++++++++++++++++++ 4 files changed, 180 insertions(+), 10 deletions(-) diff --git a/src/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.AddProperty.cs b/src/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.AddProperty.cs index d1094002bac2..539af2428921 100644 --- a/src/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.AddProperty.cs +++ b/src/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.AddProperty.cs @@ -112,10 +112,23 @@ internal static JsonPropertyInfo CreateProperty( Type propertyInfoClassType; if (runtimePropertyType.IsGenericType && runtimePropertyType.GetGenericTypeDefinition() == typeof(Nullable<>)) { - // For Nullable, use the underlying type. - Type underlyingPropertyType = Nullable.GetUnderlyingType(runtimePropertyType); - propertyInfoClassType = typeof(JsonPropertyInfoNullable<,>).MakeGenericType(parentClassType, underlyingPropertyType); - converter = options.DetermineConverterForProperty(parentClassType, underlyingPropertyType, propertyInfo); + // First try to find a converter for the Nullable, then if not found use the underlying type. + // This supports custom converters that want to (de)serialize as null when the value is not null. + converter = options.DetermineConverterForProperty(parentClassType, runtimePropertyType, propertyInfo); + if (converter != null) + { + propertyInfoClassType = typeof(JsonPropertyInfoNotNullable<,,,>).MakeGenericType( + parentClassType, + declaredPropertyType, + runtimePropertyType, + runtimePropertyType); + } + else + { + Type typeToConvert = Nullable.GetUnderlyingType(runtimePropertyType); + converter = options.DetermineConverterForProperty(parentClassType, typeToConvert, propertyInfo); + propertyInfoClassType = typeof(JsonPropertyInfoNullable<,>).MakeGenericType(parentClassType, typeToConvert); + } } else { diff --git a/src/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoNotNullable.cs b/src/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoNotNullable.cs index 079c0a864d6b..59a2fd0e05f8 100644 --- a/src/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoNotNullable.cs +++ b/src/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoNotNullable.cs @@ -30,9 +30,6 @@ protected override void OnRead(JsonTokenType tokenType, ref ReadStack state, ref } else { - // Null values were already handled. - Debug.Assert(value != null); - Set(state.Current.ReturnValue, value); } } diff --git a/src/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoNotNullableContravariant.cs b/src/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoNotNullableContravariant.cs index c71ba603a961..d939a00f7bf8 100644 --- a/src/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoNotNullableContravariant.cs +++ b/src/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoNotNullableContravariant.cs @@ -29,9 +29,6 @@ protected override void OnRead(JsonTokenType tokenType, ref ReadStack state, ref } else { - // Null values were already handled. - Debug.Assert(value != null); - Set(state.Current.ReturnValue, (TDeclaredProperty)value); } diff --git a/src/System.Text.Json/tests/Serialization/CustomConverterTests.NullValueType.cs b/src/System.Text.Json/tests/Serialization/CustomConverterTests.NullValueType.cs index 864f87a88543..50ecaaef31ee 100644 --- a/src/System.Text.Json/tests/Serialization/CustomConverterTests.NullValueType.cs +++ b/src/System.Text.Json/tests/Serialization/CustomConverterTests.NullValueType.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; +using System.Globalization; using Xunit; namespace System.Text.Json.Serialization.Tests @@ -59,5 +61,166 @@ public static void ValueTypeConverterForNullWithArray() Assert.Equal(1, arr[1]); Assert.Equal(0, arr[2]); } + + /// + /// Allow a conversion of empty string to a null DateTimeOffset?. + /// + public class JsonNullableDateTimeOffsetConverter : JsonConverter + { + public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + string value = reader.GetString(); + if (value == string.Empty) + { + return default; + } + + return DateTimeOffset.ParseExact(value, "yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + { + if (!value.HasValue) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.Value.ToString("yyyy/MM/dd HH:mm:ss")); + } + } + } + + private class ClassWithNullableAndJsonConverterAttribute + { + [JsonConverter(typeof(JsonNullableDateTimeOffsetConverter))] + public DateTimeOffset? NullableValue { get; set; } + } + + [Fact] + public static void ValueConverterForNullableWithJsonConverterAttribute() + { + ClassWithNullableAndJsonConverterAttribute obj; + + const string BaselineJson = @"{""NullableValue"":""1989/01/01 11:22:33""}"; + obj = JsonSerializer.Deserialize(BaselineJson); + Assert.NotNull(obj.NullableValue); + + const string Json = @"{""NullableValue"":""""}"; + obj = JsonSerializer.Deserialize(Json); + Assert.Null(obj.NullableValue); + + string json = JsonSerializer.Serialize(obj); + Assert.Contains(@"""NullableValue"":null", json); + } + + private class ClassWithNullableAndWithoutJsonConverterAttribute + { + public DateTimeOffset? NullableValue { get; set; } + public List NullableValues { get; set; } + } + + [Fact] + public static void ValueConverterForNullableWithoutJsonConverterAttribute() + { + const string Json = @"{""NullableValue"":"""", ""NullableValues"":[""""]}"; + ClassWithNullableAndWithoutJsonConverterAttribute obj; + + // The json is not valid with the default converter. + Assert.Throws(() => JsonSerializer.Deserialize(Json)); + + JsonSerializerOptions options = new JsonSerializerOptions(); + options.Converters.Add(new JsonNullableDateTimeOffsetConverter()); + + obj = JsonSerializer.Deserialize(Json, options); + Assert.Null(obj.NullableValue); + Assert.Null(obj.NullableValues[0]); + + string json = JsonSerializer.Serialize(obj); + Assert.Contains(@"""NullableValue"":null", json); + Assert.Contains(@"""NullableValues"":[null]", json); + } + + [JsonConverter(typeof(ClassThatCanBeNullDependingOnContentConverter))] + private class ClassThatCanBeNullDependingOnContent + { + public int MyInt { get; set; } + } + + /// + /// Allow a conversion of ClassThatCanBeNullDependingOnContent to null when its MyInt property is 0. + /// + private class ClassThatCanBeNullDependingOnContentConverter : JsonConverter + { + public override ClassThatCanBeNullDependingOnContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + // Assume a single property. + + reader.Read(); + Assert.Equal(JsonTokenType.PropertyName, reader.TokenType); + + reader.Read(); + int myInt = reader.GetInt16(); + + reader.Read(); + Assert.Equal(JsonTokenType.EndObject, reader.TokenType); + + if (myInt == 0) + { + return null; + } + + return new ClassThatCanBeNullDependingOnContent + { + MyInt = myInt + }; + } + + public override void Write(Utf8JsonWriter writer, ClassThatCanBeNullDependingOnContent value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value.MyInt == 0) + { + writer.WriteNull("MyInt"); + } + else + { + writer.WriteNumber("MyInt", value.MyInt); + } + + writer.WriteEndObject(); + } + } + + [Fact] + public static void ConverterForClassThatCanBeNullDependingOnContent() + { + ClassThatCanBeNullDependingOnContent obj; + + obj = JsonSerializer.Deserialize(@"{""MyInt"":5}"); + Assert.Equal(5, obj.MyInt); + + string json; + json = JsonSerializer.Serialize(obj); + Assert.Contains(@"""MyInt"":5", json); + + obj.MyInt = 0; + json = JsonSerializer.Serialize(obj); + Assert.Contains(@"""MyInt"":null", json); + + obj = JsonSerializer.Deserialize(@"{""MyInt"":0}"); + Assert.Null(obj); + } } }