diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index 4562c9a84eb458..a14cc263f1eb57 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -540,7 +540,7 @@ 'JsonNumberHandlingAttribute' cannot be placed on a property, field, or type that is handled by a custom converter. See usage(s) of converter '{0}' on type '{1}'. - When 'JsonNumberHandlingAttribute' is placed on a property or field, the property or field must be a number or a collection. See member '{0}' on type '{1}'. + When 'JsonNumberHandlingAttribute' is placed on a property or field, the property or field must be a number or a collection of numbers. See member '{0}' on type '{1}'. The converter '{0}' handles type '{1}' but is being asked to convert type '{2}'. Either create a separate converter for type '{2}' or change the converter's 'CanConvert' method to only return 'true' for a single type. diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/ObjectConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/ObjectConverter.cs index a4a1d348b52a35..a9feb74b5c6c1a 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/ObjectConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/ObjectConverter.cs @@ -5,6 +5,11 @@ namespace System.Text.Json.Serialization.Converters { internal sealed class ObjectConverter : JsonConverter { + public ObjectConverter() + { + IsInternalConverterForNumberType = true; + } + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using (JsonDocument document = JsonDocument.ParseValue(ref reader)) @@ -37,5 +42,13 @@ private JsonConverter GetRuntimeConverter(Type runtimeType, JsonSerializerOption return runtimeConverter; } + + internal override object ReadNumberWithCustomHandling(ref Utf8JsonReader reader, JsonNumberHandling handling) + { + using (JsonDocument document = JsonDocument.ParseValue(ref reader)) + { + return document.RootElement.Clone(); + } + } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs index 9ce34a858bfe1f..38bd9cb480f8b5 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs @@ -163,7 +163,7 @@ internal bool TryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSeriali // For performance, only perform validation on internal converters on debug builds. if (IsInternalConverter) { - if (IsInternalConverterForNumberType && state.Current.NumberHandling != null) + if (state.Current.NumberHandling != null) { value = ReadNumberWithCustomHandling(ref reader, state.Current.NumberHandling.Value); } @@ -179,7 +179,7 @@ internal bool TryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSeriali int originalPropertyDepth = reader.CurrentDepth; long originalPropertyBytesConsumed = reader.BytesConsumed; - if (IsInternalConverterForNumberType && state.Current.NumberHandling != null) + if (state.Current.NumberHandling != null) { value = ReadNumberWithCustomHandling(ref reader, state.Current.NumberHandling.Value); } @@ -356,7 +356,7 @@ internal bool TryWrite(Utf8JsonWriter writer, in T value, JsonSerializerOptions int originalPropertyDepth = writer.CurrentDepth; - if (IsInternalConverterForNumberType && state.Current.NumberHandling != null) + if (state.Current.NumberHandling != null && IsInternalConverterForNumberType) { WriteNumberWithCustomHandling(writer, value, state.Current.NumberHandling.Value); } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs index 10b92e26fac964..4d7d9f40f2c816 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs @@ -184,6 +184,8 @@ private void DetermineIgnoreCondition(JsonIgnoreCondition? ignoreCondition, bool private void DetermineNumberHandling(JsonNumberHandling? parentTypeNumberHandling) { + bool numberHandlingIsApplicable = ConverterBase.IsInternalConverterForNumberType || TypeIsCollectionOfNumbersWithInternalConverter(); + if (IsForClassInfo) { if (parentTypeNumberHandling != null && !ConverterBase.IsInternalConverter) @@ -191,45 +193,80 @@ private void DetermineNumberHandling(JsonNumberHandling? parentTypeNumberHandlin ThrowHelper.ThrowInvalidOperationException_NumberHandlingOnPropertyInvalid(this); } - // Priority 1: Get handling from the type (parent type in this case is the type itself). - NumberHandling = parentTypeNumberHandling; - - // Priority 2: Get handling from JsonSerializerOptions instance. - if (!NumberHandling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) + if (numberHandlingIsApplicable) { - NumberHandling = Options.NumberHandling; + // This logic is to honor JsonNumberHandlingAttribute placed on + // custom collections e.g. public class MyNumberList : List. + + // Priority 1: Get handling from the type (parent type in this case is the type itself). + NumberHandling = parentTypeNumberHandling; + + // Priority 2: Get handling from JsonSerializerOptions instance. + if (!NumberHandling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) + { + NumberHandling = Options.NumberHandling; + } } } else { - JsonNumberHandling? handling = null; + Debug.Assert(MemberInfo != null); + + JsonNumberHandlingAttribute? attribute = GetAttribute(MemberInfo); + if (attribute != null && !numberHandlingIsApplicable) + { + ThrowHelper.ThrowInvalidOperationException_NumberHandlingOnPropertyInvalid(this); + } - // Priority 1: Get handling from attribute on property or field. - if (MemberInfo != null) + if (numberHandlingIsApplicable) { - JsonNumberHandlingAttribute? attribute = GetAttribute(MemberInfo); + // Priority 1: Get handling from attribute on property or field. + JsonNumberHandling? handling = attribute?.Handling; + + // Priority 2: Get handling from attribute on parent class type. + handling ??= parentTypeNumberHandling; - if (attribute != null && - !ConverterBase.IsInternalConverterForNumberType && - ((ClassType.Enumerable | ClassType.Dictionary) & ClassType) == 0) + // Priority 3: Get handling from JsonSerializerOptions instance. + if (!handling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) { - ThrowHelper.ThrowInvalidOperationException_NumberHandlingOnPropertyInvalid(this); + handling = Options.NumberHandling; } - handling = attribute?.Handling; + NumberHandling = handling; } + } + } - // Priority 2: Get handling from attribute on parent class type. - handling ??= parentTypeNumberHandling; - - // Priority 3: Get handling from JsonSerializerOptions instance. - if (!handling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) - { - handling = Options.NumberHandling; - } + private bool TypeIsCollectionOfNumbersWithInternalConverter() + { + if (!ConverterBase.IsInternalConverter || + ((ClassType.Enumerable | ClassType.Dictionary) & ClassType) == 0) + { + return false; + } - NumberHandling = handling; + Type? elementType = ConverterBase.ElementType; + Debug.Assert(elementType != null); + + elementType = Nullable.GetUnderlyingType(elementType) ?? elementType; + + if (elementType == typeof(byte) || + elementType == typeof(decimal) || + elementType == typeof(double) || + elementType == typeof(short) || + elementType == typeof(int) || + elementType == typeof(long) || + elementType == typeof(sbyte) || + elementType == typeof(float) || + elementType == typeof(ushort) || + elementType == typeof(uint) || + elementType == typeof(ulong) || + elementType == JsonClassInfo.ObjectType) + { + return true; } + + return false; } public static TAttribute? GetAttribute(MemberInfo memberInfo) where TAttribute : Attribute diff --git a/src/libraries/System.Text.Json/tests/Serialization/NumberHandlingTests.cs b/src/libraries/System.Text.Json/tests/Serialization/NumberHandlingTests.cs index 60acb54094d6f9..f306e3a3287484 100644 --- a/src/libraries/System.Text.Json/tests/Serialization/NumberHandlingTests.cs +++ b/src/libraries/System.Text.Json/tests/Serialization/NumberHandlingTests.cs @@ -126,7 +126,7 @@ private static void PerformAsRootTypeSerialization(T number, string jsonWithN } [Fact] - public static void Number_AsBoxedRootType() + public static void Number_AsBoxed_RootType() { string numberAsString = @"""2"""; @@ -146,6 +146,216 @@ public static void Number_AsBoxedRootType() Assert.Equal(2, (float?)JsonSerializer.Deserialize(numberAsString, typeof(float?), s_optionReadAndWriteFromStr)); } + [Fact] + public static void Number_AsBoxed_Property() + { + int @int = 1; + float? nullableFloat = 2; + + string expected = @"{""MyInt"":""1"",""MyNullableFloat"":""2""}"; + + var obj = new Class_With_BoxedNumbers + { + MyInt = @int, + MyNullableFloat = nullableFloat + }; + + string serialized = JsonSerializer.Serialize(obj); + JsonTestHelper.AssertJsonEqual(expected, serialized); + + obj = JsonSerializer.Deserialize(serialized); + + JsonElement el = Assert.IsType(obj.MyInt); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal("1", el.GetString()); + + el = Assert.IsType(obj.MyNullableFloat); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal("2", el.GetString()); + } + + public class Class_With_BoxedNumbers + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public object MyInt { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public object MyNullableFloat { get; set; } + } + + [Fact] + public static void Number_AsBoxed_CollectionRootType_Element() + { + int @int = 1; + float? nullableFloat = 2; + + string expected = @"[""1""]"; + + var obj = new List { @int }; + string serialized = JsonSerializer.Serialize(obj, s_optionReadAndWriteFromStr); + Assert.Equal(expected, serialized); + + obj = JsonSerializer.Deserialize>(serialized, s_optionReadAndWriteFromStr); + + JsonElement el = Assert.IsType(obj[0]); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal("1", el.GetString()); + + expected = @"[""2""]"; + + IList obj2 = new object[] { nullableFloat }; + serialized = JsonSerializer.Serialize(obj2, s_optionReadAndWriteFromStr); + Assert.Equal(expected, serialized); + + obj2 = JsonSerializer.Deserialize(serialized, s_optionReadAndWriteFromStr); + + el = Assert.IsType(obj2[0]); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal("2", el.GetString()); + } + + [Fact] + public static void Number_AsBoxed_CollectionProperty_Element() + { + int @int = 2; + float? nullableFloat = 2; + + string expected = @"{""MyInts"":[""2""],""MyNullableFloats"":[""2""]}"; + + var obj = new Class_With_ListsOfBoxedNumbers + { + MyInts = new List { @int }, + MyNullableFloats = new object[] { nullableFloat } + }; + + string serialized = JsonSerializer.Serialize(obj); + JsonTestHelper.AssertJsonEqual(expected, serialized); + + obj = JsonSerializer.Deserialize(serialized); + + JsonElement el = Assert.IsType(obj.MyInts[0]); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal("2", el.GetString()); + + el = Assert.IsType(obj.MyNullableFloats[0]); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal("2", el.GetString()); + } + + public class Class_With_ListsOfBoxedNumbers + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public List MyInts { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public IList MyNullableFloats { get; set; } + } + + [Fact] + public static void NonNumber_AsBoxed_Property() + { + DateTime dateTime = DateTime.Now; + Guid? nullableGuid = Guid.NewGuid(); + + string expected = @$"{{""MyDateTime"":{JsonSerializer.Serialize(dateTime)},""MyNullableGuid"":{JsonSerializer.Serialize(nullableGuid)}}}"; + + var obj = new Class_With_BoxedNonNumbers + { + MyDateTime = dateTime, + MyNullableGuid = nullableGuid + }; + + string serialized = JsonSerializer.Serialize(obj); + JsonTestHelper.AssertJsonEqual(expected, serialized); + + obj = JsonSerializer.Deserialize(serialized); + + JsonElement el = Assert.IsType(obj.MyDateTime); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal(dateTime, el.GetDateTime()); + + el = Assert.IsType(obj.MyNullableGuid); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal(nullableGuid.Value, el.GetGuid()); + } + + public class Class_With_BoxedNonNumbers + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public object MyDateTime { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public object MyNullableGuid { get; set; } + } + + [Fact] + public static void NonNumber_AsBoxed_CollectionRootType_Element() + { + DateTime dateTime = DateTime.Now; + Guid? nullableGuid = Guid.NewGuid(); + + string expected = @$"[{JsonSerializer.Serialize(dateTime)}]"; + + var obj = new List { dateTime }; + string serialized = JsonSerializer.Serialize(obj, s_optionReadAndWriteFromStr); + Assert.Equal(expected, serialized); + + obj = JsonSerializer.Deserialize>(serialized, s_optionReadAndWriteFromStr); + + JsonElement el = Assert.IsType(obj[0]); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal(dateTime, el.GetDateTime()); + + expected = @$"[{JsonSerializer.Serialize(nullableGuid)}]"; + + IList obj2 = new object[] { nullableGuid }; + serialized = JsonSerializer.Serialize(obj2, s_optionReadAndWriteFromStr); + Assert.Equal(expected, serialized); + + obj2 = JsonSerializer.Deserialize(serialized, s_optionReadAndWriteFromStr); + + el = Assert.IsType(obj2[0]); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal(nullableGuid.Value, el.GetGuid()); + } + + [Fact] + public static void NonNumber_AsBoxed_CollectionProperty_Element() + { + DateTime dateTime = DateTime.Now; + Guid? nullableGuid = Guid.NewGuid(); + + string expected = @$"{{""MyDateTimes"":[{JsonSerializer.Serialize(dateTime)}],""MyNullableGuids"":[{JsonSerializer.Serialize(nullableGuid)}]}}"; + + var obj = new Class_With_ListsOfBoxedNonNumbers + { + MyDateTimes = new List { dateTime }, + MyNullableGuids = new object[] { nullableGuid } + }; + + string serialized = JsonSerializer.Serialize(obj); + JsonTestHelper.AssertJsonEqual(expected, serialized); + + obj = JsonSerializer.Deserialize(serialized); + + JsonElement el = Assert.IsType(obj.MyDateTimes[0]); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal(dateTime, el.GetDateTime()); + + el = Assert.IsType(obj.MyNullableGuids[0]); + Assert.Equal(JsonValueKind.String, el.ValueKind); + Assert.Equal(nullableGuid, el.GetGuid()); + } + + public class Class_With_ListsOfBoxedNonNumbers + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public List MyDateTimes { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public IList MyNullableGuids { get; set; } + } + [Fact] public static void Number_AsCollectionElement_RoundTrip() { @@ -1102,26 +1312,46 @@ public class ClassWithSimpleCollectionProperty } [Fact] - public static void NestedCollectionElementTypeHandling_Overrides_ParentPropertyHandling() + public static void NestedCollectionElementTypeHandling_Overrides_GlobalOption() { // Strict policy on the collection element type overrides read-as-string on the collection property string json = @"{""MyList"":[{""Float"":""1""}]}"; - Assert.Throws(() => JsonSerializer.Deserialize(json)); + Assert.Throws(() => JsonSerializer.Deserialize(json, s_optionReadAndWriteFromStr)); // Strict policy on the collection element type overrides write-as-string on the collection property var obj = new ClassWithComplexListProperty { MyList = new List { new ClassWith_StrictAttribute { Float = 1 } } }; - Assert.Equal(@"{""MyList"":[{""Float"":1}]}", JsonSerializer.Serialize(obj)); + Assert.Equal(@"{""MyList"":[{""Float"":1}]}", JsonSerializer.Serialize(obj, s_optionReadAndWriteFromStr)); } public class ClassWithComplexListProperty + { + public List MyList { get; set; } + } + + [Fact] + public static void NumberHandlingAttribute_NotAllowedOn_CollectionOfNonNumbers() + { + Assert.Throws(() => JsonSerializer.Deserialize("")); + Assert.Throws(() => JsonSerializer.Serialize(new ClassWith_AttributeOnComplexListProperty())); + Assert.Throws(() => JsonSerializer.Deserialize("")); + Assert.Throws(() => JsonSerializer.Serialize(new ClassWith_AttributeOnComplexDictionaryProperty())); + } + + public class ClassWith_AttributeOnComplexListProperty { [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] public List MyList { get; set; } } + public class ClassWith_AttributeOnComplexDictionaryProperty + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public Dictionary MyDictionary { get; set; } + } + [Fact] public static void MemberAttributeAppliesToDictionary_SimpleElements() { @@ -1136,23 +1366,22 @@ public class ClassWithSimpleDictionaryProperty } [Fact] - public static void NestedDictionaryElementTypeHandling_Overrides_ParentPropertyHandling() + public static void NestedDictionaryElementTypeHandling_Overrides_GlobalOption() { // Strict policy on the dictionary element type overrides read-as-string on the collection property. string json = @"{""MyDictionary"":{""Key"":{""Float"":""1""}}}"; - Assert.Throws(() => JsonSerializer.Deserialize(json)); + Assert.Throws(() => JsonSerializer.Deserialize(json, s_optionReadFromStr)); // Strict policy on the collection element type overrides write-as-string on the collection property var obj = new ClassWithComplexDictionaryProperty { MyDictionary = new Dictionary { ["Key"] = new ClassWith_StrictAttribute { Float = 1 } } }; - Assert.Equal(@"{""MyDictionary"":{""Key"":{""Float"":1}}}", JsonSerializer.Serialize(obj)); + Assert.Equal(@"{""MyDictionary"":{""Key"":{""Float"":1}}}", JsonSerializer.Serialize(obj, s_optionReadFromStr)); } public class ClassWithComplexDictionaryProperty { - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public Dictionary MyDictionary { get; set; } }