From 52d759f915605e5c78de96f4225fee3419a87373 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:26:54 +0000 Subject: [PATCH 01/13] Initial plan From ce2929d24b8dfb87cedd08471c9d1430c6a8743c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:04:11 +0000 Subject: [PATCH 02/13] Honor number handling on JSON unions Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Converters/Union/JsonUnionConverter.cs | 27 ++++-- .../Metadata/JsonPropertyInfo.cs | 3 +- .../Serialization/Metadata/JsonTypeInfo.cs | 38 ++++++++- .../tests/Common/UnionTests.cs | 83 +++++++++++++++++++ .../Serialization/UnionTests.cs | 10 +++ 5 files changed, 154 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs index 6bab2cc9bd1801..09128301e56db5 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs @@ -66,7 +66,7 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, } else { - if (!TryResolveCaseType(ref reader, typeToConvert, typeInfo, out caseType)) + if (!TryResolveCaseType(ref reader, typeToConvert, typeInfo, state.Current.NumberHandling, out caseType)) { value = default; return false; @@ -74,6 +74,7 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; + state.Current.NumberHandling ??= caseTypeInfo.PropertyInfoForTypeInfo.EffectiveNumberHandling; } JsonConverter caseConverter = caseTypeInfo.Converter; @@ -87,7 +88,12 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, return true; } - private static bool TryResolveCaseType(ref Utf8JsonReader reader, Type typeToConvert, JsonTypeInfo typeInfo, out Type caseType) + private static bool TryResolveCaseType( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonTypeInfo typeInfo, + JsonNumberHandling? numberHandling, + out Type caseType) { caseType = null!; JsonTypeClassifier? classifier = typeInfo.TypeClassifier; @@ -117,13 +123,23 @@ private static bool TryResolveCaseType(ref Utf8JsonReader reader, Type typeToCon JsonTokenType tokenType = reader.TokenType; JsonValueType valueType = GetJsonValueType(tokenType); - if ((typeInfo.UnionAmbiguousValueTypes & valueType) != 0) + Type? resolvedCaseType = null; + bool isAmbiguous; + if (numberHandling == typeInfo.PropertyInfoForTypeInfo.EffectiveNumberHandling) + { + isAmbiguous = (typeInfo.UnionAmbiguousValueTypes & valueType) != 0; + typeInfo.UnionValueTypeMap?.TryGetValue(valueType, out resolvedCaseType); + } + else + { + resolvedCaseType = typeInfo.ResolveUnionCaseType(valueType, numberHandling, out isAmbiguous); + } + + if (isAmbiguous) { ThrowHelper.ThrowJsonException_UnionAmbiguousJsonValueType(typeToConvert, valueType); } - Type? resolvedCaseType = null; - typeInfo.UnionValueTypeMap?.TryGetValue(valueType, out resolvedCaseType); caseType = resolvedCaseType!; } @@ -185,6 +201,7 @@ internal override bool OnTryWrite(Utf8JsonWriter writer, TUnion value, JsonSeria JsonTypeInfo caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; + state.Current.NumberHandling ??= caseTypeInfo.PropertyInfoForTypeInfo.EffectiveNumberHandling; return caseTypeInfo.Converter.TryWriteAsObject(writer, caseValue, options, ref state); } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs index 7d8752f81757a5..a5ed643d57ab89 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs @@ -688,7 +688,8 @@ Get is not null && private bool NumberHandingIsApplicable() { - if (EffectiveConverter.IsInternalConverterForNumberType) + if (EffectiveConverter.IsInternalConverterForNumberType || + EffectiveConverter.ConverterStrategy is ConverterStrategy.Union) { return true; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs index 96819b198b68c9..3f904a94aad434 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs @@ -1163,6 +1163,7 @@ private static void BuildUnionValueTypeMap(IList unionCases, { var map = new Dictionary(); JsonValueType ambiguousValueTypes = JsonValueType.None; + JsonNumberHandling? unionNumberHandling = target.PropertyInfoForTypeInfo.EffectiveNumberHandling; foreach (JsonUnionCaseInfo info in unionCases) { @@ -1175,7 +1176,7 @@ private static void BuildUnionValueTypeMap(IList unionCases, } JsonNumberHandling effectiveNumberHandling = - caseTypeInfo.NumberHandling ?? options.NumberHandling; + unionNumberHandling ?? caseTypeInfo.NumberHandling ?? options.NumberHandling; JsonValueType valueTypes = converter.GetSupportedJsonValueTypes(effectiveNumberHandling); AddUnionValueTypes(valueTypes, caseType, map, ref ambiguousValueTypes); @@ -1217,6 +1218,41 @@ static void AddUnionValueTypes( } } + internal Type? ResolveUnionCaseType(JsonValueType valueType, JsonNumberHandling? numberHandling, out bool isAmbiguous) + { + Type? resolvedCaseType = null; + + foreach (JsonUnionCaseInfo info in UnionCases) + { + Type caseType = info.CaseType; + JsonTypeInfo caseTypeInfo = Options.GetTypeInfoInternal(caseType); + JsonConverter converter = caseTypeInfo.Converter; + if (converter.ConverterStrategy is ConverterStrategy.Union) + { + continue; + } + + JsonNumberHandling effectiveNumberHandling = + numberHandling ?? caseTypeInfo.NumberHandling ?? Options.NumberHandling; + + if ((converter.GetSupportedJsonValueTypes(effectiveNumberHandling) & valueType) == 0) + { + continue; + } + + if (resolvedCaseType is not null) + { + isAmbiguous = true; + return null; + } + + resolvedCaseType = caseType; + } + + isAmbiguous = false; + return resolvedCaseType; + } + /// /// Gets any ancestor polymorphic types that declare /// a type discriminator for the current type. Consulted diff --git a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index c5dfd273bb938e..c1dc9bd827e060 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -413,6 +413,89 @@ public void UnionWithMixedAmbiguity_GetTypeInfoSucceeds() Assert.NotNull(typeInfo); } + [JsonNumberHandling(JsonNumberHandling.Strict)] + public union StrictIntOrString(int, string); + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public union StringEncodedInt(int); + + public union PlainIntUnion(int); + + public sealed class UnionNumberHandlingContainer + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public PlainIntUnion Value { get; set; } + } + + public sealed class StrictUnionNumberHandlingContainer + { + [JsonNumberHandling(JsonNumberHandling.Strict)] + public MixedUnion Value { get; set; } = new(0); + } + + [Fact] + public async Task UnionNumberHandling_TypeAttributeOverridesOptions() + { + JsonSerializerOptions options = Serializer.CreateOptions( + static options => options.NumberHandling = JsonNumberHandling.AllowReadingFromString); + + StrictIntOrString? value = await Serializer.DeserializeWrapper("\"hello\"", options); + + Assert.Equal("hello", GetUnionValue(value!)); + } + + [Fact] + public async Task UnionNumberHandling_TypeAttributeSupportsReadingAndWritingNumbersAsStrings() + { + StringEncodedInt? value = await Serializer.DeserializeWrapper("\"42\""); + Assert.Equal(42, GetUnionValue(value!)); + + string json = await Serializer.SerializeWrapper(new StringEncodedInt(42)); + Assert.Equal("\"42\"", json); + } + + [Fact] + public async Task UnionNumberHandling_TypeInfoOverrideSupportsReadingAndWritingNumbersAsStrings() + { + JsonSerializerOptions options = Serializer.GetDefaultOptionsWithMetadataModifier(static typeInfo => + { + if (typeInfo.Type == typeof(PlainIntUnion)) + { + typeInfo.NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString; + } + }); + + PlainIntUnion? value = await Serializer.DeserializeWrapper("\"42\"", options); + Assert.Equal(42, GetUnionValue(value!)); + + string json = await Serializer.SerializeWrapper(new PlainIntUnion(42), options); + Assert.Equal("\"42\"", json); + } + + [Fact] + public async Task UnionNumberHandling_PropertyAttributeSupportsReadingAndWritingNumbersAsStrings() + { + UnionNumberHandlingContainer? value = + await Serializer.DeserializeWrapper("""{"Value":"42"}"""); + Assert.Equal(42, GetUnionValue(value!.Value)); + + string json = await Serializer.SerializeWrapper( + new UnionNumberHandlingContainer { Value = new PlainIntUnion(42) }); + JsonTestHelper.AssertJsonEqual("""{"Value":"42"}""", json); + } + + [Fact] + public async Task UnionNumberHandling_StrictPropertyAttributeOverridesOptions() + { + JsonSerializerOptions options = Serializer.CreateOptions( + static options => options.NumberHandling = JsonNumberHandling.AllowReadingFromString); + + StrictUnionNumberHandlingContainer? value = + await Serializer.DeserializeWrapper("""{"Value":"hello"}""", options); + + Assert.Equal("hello", GetUnionValue(value!.Value)); + } + public class Animal { } public class Dog : Animal { } public class Lab : Dog { } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs index a175b77446f6d0..4a7ccc39d1c602 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs @@ -33,6 +33,11 @@ public UnionTests_Metadata() [JsonSerializable(typeof(UnionWithCustomConverterCase))] [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] + [JsonSerializable(typeof(StrictIntOrString))] + [JsonSerializable(typeof(StringEncodedInt))] + [JsonSerializable(typeof(PlainIntUnion))] + [JsonSerializable(typeof(UnionNumberHandlingContainer))] + [JsonSerializable(typeof(StrictUnionNumberHandlingContainer))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] @@ -97,6 +102,11 @@ public UnionTests_Default() [JsonSerializable(typeof(UnionWithCustomConverterCase))] [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] + [JsonSerializable(typeof(StrictIntOrString))] + [JsonSerializable(typeof(StringEncodedInt))] + [JsonSerializable(typeof(PlainIntUnion))] + [JsonSerializable(typeof(UnionNumberHandlingContainer))] + [JsonSerializable(typeof(StrictUnionNumberHandlingContainer))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] From e42947274542db5dd50ce47cf4b333aaa102a613 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:16:17 +0000 Subject: [PATCH 03/13] Preserve union case number handling precedence Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Serialization/Converters/Union/JsonUnionConverter.cs | 6 +++--- .../Text/Json/Serialization/Metadata/JsonPropertyInfo.cs | 8 ++++++-- .../Text/Json/Serialization/Metadata/JsonTypeInfo.cs | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs index 09128301e56db5..f303ac6e1acf78 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs @@ -74,7 +74,7 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; - state.Current.NumberHandling ??= caseTypeInfo.PropertyInfoForTypeInfo.EffectiveNumberHandling; + state.Current.NumberHandling ??= caseTypeInfo.NumberHandling ?? options.NumberHandling; } JsonConverter caseConverter = caseTypeInfo.Converter; @@ -125,7 +125,7 @@ private static bool TryResolveCaseType( Type? resolvedCaseType = null; bool isAmbiguous; - if (numberHandling == typeInfo.PropertyInfoForTypeInfo.EffectiveNumberHandling) + if (numberHandling == typeInfo.NumberHandling) { isAmbiguous = (typeInfo.UnionAmbiguousValueTypes & valueType) != 0; typeInfo.UnionValueTypeMap?.TryGetValue(valueType, out resolvedCaseType); @@ -201,7 +201,7 @@ internal override bool OnTryWrite(Utf8JsonWriter writer, TUnion value, JsonSeria JsonTypeInfo caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; - state.Current.NumberHandling ??= caseTypeInfo.PropertyInfoForTypeInfo.EffectiveNumberHandling; + state.Current.NumberHandling ??= caseTypeInfo.NumberHandling ?? options.NumberHandling; return caseTypeInfo.Converter.TryWriteAsObject(writer, caseValue, options, ref state); } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs index a5ed643d57ab89..e3138c01e3c2f9 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs @@ -576,7 +576,9 @@ private void DetermineNumberHandlingForTypeInfo() EffectiveNumberHandling = declaringTypeNumberHandling; // Priority 2: Get handling from JsonSerializerOptions instance. - if (!EffectiveNumberHandling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) + if (!EffectiveNumberHandling.HasValue && + Options.NumberHandling != JsonNumberHandling.Strict && + EffectiveConverter.ConverterStrategy is not ConverterStrategy.Union) { EffectiveNumberHandling = Options.NumberHandling; } @@ -597,7 +599,9 @@ private void DetermineNumberHandlingForProperty() JsonNumberHandling? handling = NumberHandling ?? DeclaringTypeInfo.NumberHandling ?? _jsonTypeInfo.NumberHandling; // Priority 2: Get handling from JsonSerializerOptions instance. - if (!handling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) + if (!handling.HasValue && + Options.NumberHandling != JsonNumberHandling.Strict && + EffectiveConverter.ConverterStrategy is not ConverterStrategy.Union) { handling = Options.NumberHandling; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs index 3f904a94aad434..df393679db3782 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs @@ -1163,7 +1163,7 @@ private static void BuildUnionValueTypeMap(IList unionCases, { var map = new Dictionary(); JsonValueType ambiguousValueTypes = JsonValueType.None; - JsonNumberHandling? unionNumberHandling = target.PropertyInfoForTypeInfo.EffectiveNumberHandling; + JsonNumberHandling? unionNumberHandling = target.NumberHandling; foreach (JsonUnionCaseInfo info in unionCases) { From af64b38d4d036d6a843c0fffa6dfbd2860cb4fdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:34:50 +0000 Subject: [PATCH 04/13] Narrow union number handling fix Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Converters/Union/JsonUnionConverter.cs | 29 +++------ .../Serialization/Metadata/JsonTypeInfo.cs | 38 +----------- .../tests/Common/UnionTests.cs | 61 ++++++++++++------- .../Serialization/UnionTests.cs | 6 +- 4 files changed, 49 insertions(+), 85 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs index f303ac6e1acf78..815efa605a2ba7 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs @@ -66,7 +66,7 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, } else { - if (!TryResolveCaseType(ref reader, typeToConvert, typeInfo, state.Current.NumberHandling, out caseType)) + if (!TryResolveCaseType(ref reader, typeToConvert, typeInfo, out caseType)) { value = default; return false; @@ -74,7 +74,7 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; - state.Current.NumberHandling ??= caseTypeInfo.NumberHandling ?? options.NumberHandling; + state.Current.NumberHandling ??= caseTypeInfo.NumberHandling; } JsonConverter caseConverter = caseTypeInfo.Converter; @@ -88,12 +88,7 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, return true; } - private static bool TryResolveCaseType( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonTypeInfo typeInfo, - JsonNumberHandling? numberHandling, - out Type caseType) + private static bool TryResolveCaseType(ref Utf8JsonReader reader, Type typeToConvert, JsonTypeInfo typeInfo, out Type caseType) { caseType = null!; JsonTypeClassifier? classifier = typeInfo.TypeClassifier; @@ -123,23 +118,13 @@ private static bool TryResolveCaseType( JsonTokenType tokenType = reader.TokenType; JsonValueType valueType = GetJsonValueType(tokenType); - Type? resolvedCaseType = null; - bool isAmbiguous; - if (numberHandling == typeInfo.NumberHandling) - { - isAmbiguous = (typeInfo.UnionAmbiguousValueTypes & valueType) != 0; - typeInfo.UnionValueTypeMap?.TryGetValue(valueType, out resolvedCaseType); - } - else - { - resolvedCaseType = typeInfo.ResolveUnionCaseType(valueType, numberHandling, out isAmbiguous); - } - - if (isAmbiguous) + if ((typeInfo.UnionAmbiguousValueTypes & valueType) != 0) { ThrowHelper.ThrowJsonException_UnionAmbiguousJsonValueType(typeToConvert, valueType); } + Type? resolvedCaseType = null; + typeInfo.UnionValueTypeMap?.TryGetValue(valueType, out resolvedCaseType); caseType = resolvedCaseType!; } @@ -201,7 +186,7 @@ internal override bool OnTryWrite(Utf8JsonWriter writer, TUnion value, JsonSeria JsonTypeInfo caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; - state.Current.NumberHandling ??= caseTypeInfo.NumberHandling ?? options.NumberHandling; + state.Current.NumberHandling ??= caseTypeInfo.NumberHandling; return caseTypeInfo.Converter.TryWriteAsObject(writer, caseValue, options, ref state); } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs index df393679db3782..96819b198b68c9 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs @@ -1163,7 +1163,6 @@ private static void BuildUnionValueTypeMap(IList unionCases, { var map = new Dictionary(); JsonValueType ambiguousValueTypes = JsonValueType.None; - JsonNumberHandling? unionNumberHandling = target.NumberHandling; foreach (JsonUnionCaseInfo info in unionCases) { @@ -1176,7 +1175,7 @@ private static void BuildUnionValueTypeMap(IList unionCases, } JsonNumberHandling effectiveNumberHandling = - unionNumberHandling ?? caseTypeInfo.NumberHandling ?? options.NumberHandling; + caseTypeInfo.NumberHandling ?? options.NumberHandling; JsonValueType valueTypes = converter.GetSupportedJsonValueTypes(effectiveNumberHandling); AddUnionValueTypes(valueTypes, caseType, map, ref ambiguousValueTypes); @@ -1218,41 +1217,6 @@ static void AddUnionValueTypes( } } - internal Type? ResolveUnionCaseType(JsonValueType valueType, JsonNumberHandling? numberHandling, out bool isAmbiguous) - { - Type? resolvedCaseType = null; - - foreach (JsonUnionCaseInfo info in UnionCases) - { - Type caseType = info.CaseType; - JsonTypeInfo caseTypeInfo = Options.GetTypeInfoInternal(caseType); - JsonConverter converter = caseTypeInfo.Converter; - if (converter.ConverterStrategy is ConverterStrategy.Union) - { - continue; - } - - JsonNumberHandling effectiveNumberHandling = - numberHandling ?? caseTypeInfo.NumberHandling ?? Options.NumberHandling; - - if ((converter.GetSupportedJsonValueTypes(effectiveNumberHandling) & valueType) == 0) - { - continue; - } - - if (resolvedCaseType is not null) - { - isAmbiguous = true; - return null; - } - - resolvedCaseType = caseType; - } - - isAmbiguous = false; - return resolvedCaseType; - } - /// /// Gets any ancestor polymorphic types that declare /// a type discriminator for the current type. Consulted diff --git a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index c1dc9bd827e060..bdeb5e80cbdb4d 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -413,14 +413,23 @@ public void UnionWithMixedAmbiguity_GetTypeInfoSucceeds() Assert.NotNull(typeInfo); } - [JsonNumberHandling(JsonNumberHandling.Strict)] - public union StrictIntOrString(int, string); - + [JsonUnion(TypeClassifier = typeof(IntCaseClassifierFactory))] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] - public union StringEncodedInt(int); + public union NumberHandlingIntUnion(int); + [JsonUnion(TypeClassifier = typeof(IntCaseClassifierFactory))] public union PlainIntUnion(int); + public sealed class IntCaseClassifierFactory : JsonTypeClassifierFactory + { + public override bool CanClassify(JsonTypeClassifierContext context) => + context.DeclaringType == typeof(NumberHandlingIntUnion) || + context.DeclaringType == typeof(PlainIntUnion); + + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => typeof(int); + } + public sealed class UnionNumberHandlingContainer { [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] @@ -430,27 +439,16 @@ public sealed class UnionNumberHandlingContainer public sealed class StrictUnionNumberHandlingContainer { [JsonNumberHandling(JsonNumberHandling.Strict)] - public MixedUnion Value { get; set; } = new(0); - } - - [Fact] - public async Task UnionNumberHandling_TypeAttributeOverridesOptions() - { - JsonSerializerOptions options = Serializer.CreateOptions( - static options => options.NumberHandling = JsonNumberHandling.AllowReadingFromString); - - StrictIntOrString? value = await Serializer.DeserializeWrapper("\"hello\"", options); - - Assert.Equal("hello", GetUnionValue(value!)); + public PlainIntUnion Value { get; set; } } [Fact] public async Task UnionNumberHandling_TypeAttributeSupportsReadingAndWritingNumbersAsStrings() { - StringEncodedInt? value = await Serializer.DeserializeWrapper("\"42\""); + NumberHandlingIntUnion? value = await Serializer.DeserializeWrapper("\"42\""); Assert.Equal(42, GetUnionValue(value!)); - string json = await Serializer.SerializeWrapper(new StringEncodedInt(42)); + string json = await Serializer.SerializeWrapper(new NumberHandlingIntUnion(42)); Assert.Equal("\"42\"", json); } @@ -488,12 +486,31 @@ public async Task UnionNumberHandling_PropertyAttributeSupportsReadingAndWriting public async Task UnionNumberHandling_StrictPropertyAttributeOverridesOptions() { JsonSerializerOptions options = Serializer.CreateOptions( - static options => options.NumberHandling = JsonNumberHandling.AllowReadingFromString); + static options => options.NumberHandling = JsonNumberHandling.WriteAsString); + + string json = await Serializer.SerializeWrapper( + new StrictUnionNumberHandlingContainer { Value = new PlainIntUnion(42) }, + options); + + JsonTestHelper.AssertJsonEqual("""{"Value":42}""", json); + } + + [Fact] + public async Task UnionNumberHandling_CaseTypeInfoOverridesOptions() + { + JsonSerializerOptions options = Serializer.CreateOptions( + static options => options.NumberHandling = JsonNumberHandling.WriteAsString, + modifier: static typeInfo => + { + if (typeInfo.Type == typeof(int)) + { + typeInfo.NumberHandling = JsonNumberHandling.Strict; + } + }); - StrictUnionNumberHandlingContainer? value = - await Serializer.DeserializeWrapper("""{"Value":"hello"}""", options); + string json = await Serializer.SerializeWrapper(new PlainIntUnion(42), options); - Assert.Equal("hello", GetUnionValue(value!.Value)); + Assert.Equal("42", json); } public class Animal { } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs index 4a7ccc39d1c602..8f7fa56655cc0f 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs @@ -33,8 +33,7 @@ public UnionTests_Metadata() [JsonSerializable(typeof(UnionWithCustomConverterCase))] [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] - [JsonSerializable(typeof(StrictIntOrString))] - [JsonSerializable(typeof(StringEncodedInt))] + [JsonSerializable(typeof(NumberHandlingIntUnion))] [JsonSerializable(typeof(PlainIntUnion))] [JsonSerializable(typeof(UnionNumberHandlingContainer))] [JsonSerializable(typeof(StrictUnionNumberHandlingContainer))] @@ -102,8 +101,7 @@ public UnionTests_Default() [JsonSerializable(typeof(UnionWithCustomConverterCase))] [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] - [JsonSerializable(typeof(StrictIntOrString))] - [JsonSerializable(typeof(StringEncodedInt))] + [JsonSerializable(typeof(NumberHandlingIntUnion))] [JsonSerializable(typeof(PlainIntUnion))] [JsonSerializable(typeof(UnionNumberHandlingContainer))] [JsonSerializable(typeof(StrictUnionNumberHandlingContainer))] From cddafb4121b97232ea9de010a359642f19e68408 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:05:26 +0000 Subject: [PATCH 05/13] Use regular number handling inheritance for unions Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Converters/Union/JsonUnionConverter.cs | 2 -- .../Serialization/Metadata/JsonPropertyInfo.cs | 17 +++++++---------- .../tests/Common/UnionTests.cs | 18 ------------------ 3 files changed, 7 insertions(+), 30 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs index 815efa605a2ba7..6bab2cc9bd1801 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs @@ -74,7 +74,6 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; - state.Current.NumberHandling ??= caseTypeInfo.NumberHandling; } JsonConverter caseConverter = caseTypeInfo.Converter; @@ -186,7 +185,6 @@ internal override bool OnTryWrite(Utf8JsonWriter writer, TUnion value, JsonSeria JsonTypeInfo caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; - state.Current.NumberHandling ??= caseTypeInfo.NumberHandling; return caseTypeInfo.Converter.TryWriteAsObject(writer, caseValue, options, ref state); } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs index e3138c01e3c2f9..e04356d7307a9a 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs @@ -567,7 +567,8 @@ private void DetermineNumberHandlingForTypeInfo() ThrowHelper.ThrowInvalidOperationException_NumberHandlingOnPropertyInvalid(this); } - if (NumberHandingIsApplicable()) + if (NumberHandingIsApplicable() || + EffectiveConverter.ConverterStrategy is ConverterStrategy.Union) { // This logic is to honor JsonNumberHandlingAttribute placed on // custom collections e.g. public class MyNumberList : List. @@ -576,9 +577,7 @@ private void DetermineNumberHandlingForTypeInfo() EffectiveNumberHandling = declaringTypeNumberHandling; // Priority 2: Get handling from JsonSerializerOptions instance. - if (!EffectiveNumberHandling.HasValue && - Options.NumberHandling != JsonNumberHandling.Strict && - EffectiveConverter.ConverterStrategy is not ConverterStrategy.Union) + if (!EffectiveNumberHandling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) { EffectiveNumberHandling = Options.NumberHandling; } @@ -593,15 +592,14 @@ private void DetermineNumberHandlingForProperty() bool numberHandlingIsApplicable = NumberHandingIsApplicable(); - if (numberHandlingIsApplicable) + if (numberHandlingIsApplicable || + EffectiveConverter.ConverterStrategy is ConverterStrategy.Union) { // Priority 1: Get handling from attribute on property/field, its parent class type or property type. JsonNumberHandling? handling = NumberHandling ?? DeclaringTypeInfo.NumberHandling ?? _jsonTypeInfo.NumberHandling; // Priority 2: Get handling from JsonSerializerOptions instance. - if (!handling.HasValue && - Options.NumberHandling != JsonNumberHandling.Strict && - EffectiveConverter.ConverterStrategy is not ConverterStrategy.Union) + if (!handling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) { handling = Options.NumberHandling; } @@ -692,8 +690,7 @@ Get is not null && private bool NumberHandingIsApplicable() { - if (EffectiveConverter.IsInternalConverterForNumberType || - EffectiveConverter.ConverterStrategy is ConverterStrategy.Union) + if (EffectiveConverter.IsInternalConverterForNumberType) { return true; } diff --git a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index bdeb5e80cbdb4d..2370d79dd9e7a6 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -495,24 +495,6 @@ public async Task UnionNumberHandling_StrictPropertyAttributeOverridesOptions() JsonTestHelper.AssertJsonEqual("""{"Value":42}""", json); } - [Fact] - public async Task UnionNumberHandling_CaseTypeInfoOverridesOptions() - { - JsonSerializerOptions options = Serializer.CreateOptions( - static options => options.NumberHandling = JsonNumberHandling.WriteAsString, - modifier: static typeInfo => - { - if (typeInfo.Type == typeof(int)) - { - typeInfo.NumberHandling = JsonNumberHandling.Strict; - } - }); - - string json = await Serializer.SerializeWrapper(new PlainIntUnion(42), options); - - Assert.Equal("42", json); - } - public class Animal { } public class Dog : Animal { } public class Lab : Dog { } From 4f2a1fa3889e69f045434ef7e39a1565451a8773 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:52:59 +0000 Subject: [PATCH 06/13] Target strict IntOrString union regression Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Metadata/JsonPropertyInfo.cs | 3 +- .../Serialization/Metadata/JsonTypeInfo.cs | 2 +- .../tests/Common/UnionTests.cs | 82 ++----------------- .../Serialization/UnionTests.cs | 10 +-- 4 files changed, 12 insertions(+), 85 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs index e04356d7307a9a..17de32b85c25af 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs @@ -592,8 +592,7 @@ private void DetermineNumberHandlingForProperty() bool numberHandlingIsApplicable = NumberHandingIsApplicable(); - if (numberHandlingIsApplicable || - EffectiveConverter.ConverterStrategy is ConverterStrategy.Union) + if (numberHandlingIsApplicable) { // Priority 1: Get handling from attribute on property/field, its parent class type or property type. JsonNumberHandling? handling = NumberHandling ?? DeclaringTypeInfo.NumberHandling ?? _jsonTypeInfo.NumberHandling; diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs index 96819b198b68c9..948f4d5d13a8e4 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs @@ -1175,7 +1175,7 @@ private static void BuildUnionValueTypeMap(IList unionCases, } JsonNumberHandling effectiveNumberHandling = - caseTypeInfo.NumberHandling ?? options.NumberHandling; + target.NumberHandling ?? caseTypeInfo.NumberHandling ?? options.NumberHandling; JsonValueType valueTypes = converter.GetSupportedJsonValueTypes(effectiveNumberHandling); AddUnionValueTypes(valueTypes, caseType, map, ref ambiguousValueTypes); diff --git a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index 2370d79dd9e7a6..e621bb149f4c01 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -413,86 +413,20 @@ public void UnionWithMixedAmbiguity_GetTypeInfoSucceeds() Assert.NotNull(typeInfo); } - [JsonUnion(TypeClassifier = typeof(IntCaseClassifierFactory))] - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] - public union NumberHandlingIntUnion(int); - - [JsonUnion(TypeClassifier = typeof(IntCaseClassifierFactory))] - public union PlainIntUnion(int); - - public sealed class IntCaseClassifierFactory : JsonTypeClassifierFactory - { - public override bool CanClassify(JsonTypeClassifierContext context) => - context.DeclaringType == typeof(NumberHandlingIntUnion) || - context.DeclaringType == typeof(PlainIntUnion); - - public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => - static (ref Utf8JsonReader reader) => typeof(int); - } - - public sealed class UnionNumberHandlingContainer - { - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] - public PlainIntUnion Value { get; set; } - } - - public sealed class StrictUnionNumberHandlingContainer - { - [JsonNumberHandling(JsonNumberHandling.Strict)] - public PlainIntUnion Value { get; set; } - } - - [Fact] - public async Task UnionNumberHandling_TypeAttributeSupportsReadingAndWritingNumbersAsStrings() - { - NumberHandlingIntUnion? value = await Serializer.DeserializeWrapper("\"42\""); - Assert.Equal(42, GetUnionValue(value!)); - - string json = await Serializer.SerializeWrapper(new NumberHandlingIntUnion(42)); - Assert.Equal("\"42\"", json); - } + [JsonNumberHandling(JsonNumberHandling.Strict)] + public union IntOrString(int, string); [Fact] - public async Task UnionNumberHandling_TypeInfoOverrideSupportsReadingAndWritingNumbersAsStrings() + public async Task UnionNumberHandling_StrictTypeAttributeOverridesWebDefaults() { - JsonSerializerOptions options = Serializer.GetDefaultOptionsWithMetadataModifier(static typeInfo => + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { - if (typeInfo.Type == typeof(PlainIntUnion)) - { - typeInfo.NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString; - } - }); - - PlainIntUnion? value = await Serializer.DeserializeWrapper("\"42\"", options); - Assert.Equal(42, GetUnionValue(value!)); - - string json = await Serializer.SerializeWrapper(new PlainIntUnion(42), options); - Assert.Equal("\"42\"", json); - } - - [Fact] - public async Task UnionNumberHandling_PropertyAttributeSupportsReadingAndWritingNumbersAsStrings() - { - UnionNumberHandlingContainer? value = - await Serializer.DeserializeWrapper("""{"Value":"42"}"""); - Assert.Equal(42, GetUnionValue(value!.Value)); - - string json = await Serializer.SerializeWrapper( - new UnionNumberHandlingContainer { Value = new PlainIntUnion(42) }); - JsonTestHelper.AssertJsonEqual("""{"Value":"42"}""", json); - } - - [Fact] - public async Task UnionNumberHandling_StrictPropertyAttributeOverridesOptions() - { - JsonSerializerOptions options = Serializer.CreateOptions( - static options => options.NumberHandling = JsonNumberHandling.WriteAsString); + TypeInfoResolver = Serializer.DefaultOptions.TypeInfoResolver, + }; - string json = await Serializer.SerializeWrapper( - new StrictUnionNumberHandlingContainer { Value = new PlainIntUnion(42) }, - options); + IntOrString? value = await Serializer.DeserializeWrapper("\"hello\"", options); - JsonTestHelper.AssertJsonEqual("""{"Value":42}""", json); + Assert.Equal("hello", GetUnionValue(value!)); } public class Animal { } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs index 8f7fa56655cc0f..6a7b0dd2f46153 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs @@ -33,10 +33,7 @@ public UnionTests_Metadata() [JsonSerializable(typeof(UnionWithCustomConverterCase))] [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] - [JsonSerializable(typeof(NumberHandlingIntUnion))] - [JsonSerializable(typeof(PlainIntUnion))] - [JsonSerializable(typeof(UnionNumberHandlingContainer))] - [JsonSerializable(typeof(StrictUnionNumberHandlingContainer))] + [JsonSerializable(typeof(IntOrString))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] @@ -101,10 +98,7 @@ public UnionTests_Default() [JsonSerializable(typeof(UnionWithCustomConverterCase))] [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] - [JsonSerializable(typeof(NumberHandlingIntUnion))] - [JsonSerializable(typeof(PlainIntUnion))] - [JsonSerializable(typeof(UnionNumberHandlingContainer))] - [JsonSerializable(typeof(StrictUnionNumberHandlingContainer))] + [JsonSerializable(typeof(IntOrString))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] From d52bec6b051cf7d7a56eb900b297713fcf071ad5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:25:14 +0000 Subject: [PATCH 07/13] Remove union number handling applicability check Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Text/Json/Serialization/Metadata/JsonPropertyInfo.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs index 17de32b85c25af..7d8752f81757a5 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs @@ -567,8 +567,7 @@ private void DetermineNumberHandlingForTypeInfo() ThrowHelper.ThrowInvalidOperationException_NumberHandlingOnPropertyInvalid(this); } - if (NumberHandingIsApplicable() || - EffectiveConverter.ConverterStrategy is ConverterStrategy.Union) + if (NumberHandingIsApplicable()) { // This logic is to honor JsonNumberHandlingAttribute placed on // custom collections e.g. public class MyNumberList : List. From 368c0900d0d38237194b5bc5af76e095f9e3a516 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:50:29 +0000 Subject: [PATCH 08/13] Apply number handling to union cases Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Parser.cs | 18 +++++++++--------- .../Converters/Union/JsonUnionConverter.cs | 8 ++++++++ .../tests/Common/UnionTests.cs | 18 ++++++++++++++++++ .../Serialization/UnionTests.cs | 2 ++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 4995aa3c16e79e..762b1c8b23c75d 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1788,11 +1788,12 @@ private void EmitUnionAmbiguityDiagnostics(INamedTypeSymbol unionType, List> valueTypeToTypes = new(); + JsonNumberHandling? unionNumberHandling = GetNumberHandling(unionType); foreach (ITypeSymbol caseType in caseTypes) { string caseTypeName = caseType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); - JsonValueType valueTypes = GetSupportedJsonValueTypes(caseType); + JsonValueType valueTypes = GetSupportedJsonValueTypes(caseType, unionNumberHandling); for (int flag = 1; flag <= (int)JsonValueType.Boolean; flag <<= 1) { @@ -1837,7 +1838,7 @@ private void EmitUnionAmbiguityDiagnostics(INamedTypeSymbol unionType, List 0 && - attr.ConstructorArguments[0].Value is int handlingValue && - ((JsonNumberHandling)handlingValue & JsonNumberHandling.AllowReadingFromString) != 0) + attr.ConstructorArguments[0].Value is int handlingValue) { - return true; + return (JsonNumberHandling)handlingValue; } } - return false; + return null; } private bool TryResolveCollectionType( diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs index 6bab2cc9bd1801..64b6c4d3266df2 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs @@ -31,6 +31,10 @@ public JsonUnionConverter() internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, scoped ref ReadStack state, out TUnion? value) { JsonTypeInfo typeInfo = (JsonTypeInfo)state.Current.JsonTypeInfo; + if (typeInfo.NumberHandling is { } numberHandling) + { + state.Current.NumberHandling = numberHandling; + } Func? constructor = typeInfo.UnionConstructor; if (constructor is null) @@ -165,6 +169,10 @@ internal override bool OnTryWrite(Utf8JsonWriter writer, TUnion value, JsonSeria } JsonTypeInfo typeInfo = (JsonTypeInfo)state.Current.JsonTypeInfo; + if (typeInfo.NumberHandling is { } numberHandling) + { + state.Current.NumberHandling = numberHandling; + } Func? deconstructor = typeInfo.UnionDeconstructor; if (deconstructor is null) diff --git a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index e621bb149f4c01..82c5b61c80ba1f 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -429,6 +429,24 @@ public async Task UnionNumberHandling_StrictTypeAttributeOverridesWebDefaults() Assert.Equal("hello", GetUnionValue(value!)); } + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public union IntOnly(int); + + [Fact] + public async Task UnionNumberHandling_TypeAttributeIsAppliedToCaseConverter() + { + JsonSerializerOptions options = new() + { + TypeInfoResolver = Serializer.DefaultOptions.TypeInfoResolver, + }; + + IntOnly? value = await Serializer.DeserializeWrapper("\"42\"", options); + Assert.Equal(42, GetUnionValue(value!)); + + string json = await Serializer.SerializeWrapper(new IntOnly(42), options); + Assert.Equal("\"42\"", json); + } + public class Animal { } public class Dog : Animal { } public class Lab : Dog { } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs index 6a7b0dd2f46153..9359d856667a03 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs @@ -34,6 +34,7 @@ public UnionTests_Metadata() [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] [JsonSerializable(typeof(IntOrString))] + [JsonSerializable(typeof(IntOnly))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] @@ -99,6 +100,7 @@ public UnionTests_Default() [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] [JsonSerializable(typeof(IntOrString))] + [JsonSerializable(typeof(IntOnly))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] From 7b943a028bdcc331beb277751b5573de270b815d Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 14 Sep 2026 17:14:48 +0300 Subject: [PATCH 09/13] Revert applying number handling to union cases This reverts commit 368c0900d0d38237194b5bc5af76e095f9e3a516. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Parser.cs | 18 +++++++++--------- .../Converters/Union/JsonUnionConverter.cs | 8 -------- .../tests/Common/UnionTests.cs | 18 ------------------ .../Serialization/UnionTests.cs | 2 -- 4 files changed, 9 insertions(+), 37 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 762b1c8b23c75d..4995aa3c16e79e 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1788,12 +1788,11 @@ private void EmitUnionAmbiguityDiagnostics(INamedTypeSymbol unionType, List> valueTypeToTypes = new(); - JsonNumberHandling? unionNumberHandling = GetNumberHandling(unionType); foreach (ITypeSymbol caseType in caseTypes) { string caseTypeName = caseType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); - JsonValueType valueTypes = GetSupportedJsonValueTypes(caseType, unionNumberHandling); + JsonValueType valueTypes = GetSupportedJsonValueTypes(caseType); for (int flag = 1; flag <= (int)JsonValueType.Boolean; flag <<= 1) { @@ -1838,7 +1837,7 @@ private void EmitUnionAmbiguityDiagnostics(INamedTypeSymbol unionType, List 0 && - attr.ConstructorArguments[0].Value is int handlingValue) + attr.ConstructorArguments[0].Value is int handlingValue && + ((JsonNumberHandling)handlingValue & JsonNumberHandling.AllowReadingFromString) != 0) { - return (JsonNumberHandling)handlingValue; + return true; } } - return null; + return false; } private bool TryResolveCollectionType( diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs index 64b6c4d3266df2..6bab2cc9bd1801 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs @@ -31,10 +31,6 @@ public JsonUnionConverter() internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, scoped ref ReadStack state, out TUnion? value) { JsonTypeInfo typeInfo = (JsonTypeInfo)state.Current.JsonTypeInfo; - if (typeInfo.NumberHandling is { } numberHandling) - { - state.Current.NumberHandling = numberHandling; - } Func? constructor = typeInfo.UnionConstructor; if (constructor is null) @@ -169,10 +165,6 @@ internal override bool OnTryWrite(Utf8JsonWriter writer, TUnion value, JsonSeria } JsonTypeInfo typeInfo = (JsonTypeInfo)state.Current.JsonTypeInfo; - if (typeInfo.NumberHandling is { } numberHandling) - { - state.Current.NumberHandling = numberHandling; - } Func? deconstructor = typeInfo.UnionDeconstructor; if (deconstructor is null) diff --git a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index 82c5b61c80ba1f..e621bb149f4c01 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -429,24 +429,6 @@ public async Task UnionNumberHandling_StrictTypeAttributeOverridesWebDefaults() Assert.Equal("hello", GetUnionValue(value!)); } - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] - public union IntOnly(int); - - [Fact] - public async Task UnionNumberHandling_TypeAttributeIsAppliedToCaseConverter() - { - JsonSerializerOptions options = new() - { - TypeInfoResolver = Serializer.DefaultOptions.TypeInfoResolver, - }; - - IntOnly? value = await Serializer.DeserializeWrapper("\"42\"", options); - Assert.Equal(42, GetUnionValue(value!)); - - string json = await Serializer.SerializeWrapper(new IntOnly(42), options); - Assert.Equal("\"42\"", json); - } - public class Animal { } public class Dog : Animal { } public class Lab : Dog { } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs index 9359d856667a03..6a7b0dd2f46153 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs @@ -34,7 +34,6 @@ public UnionTests_Metadata() [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] [JsonSerializable(typeof(IntOrString))] - [JsonSerializable(typeof(IntOnly))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] @@ -100,7 +99,6 @@ public UnionTests_Default() [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] [JsonSerializable(typeof(IntOrString))] - [JsonSerializable(typeof(IntOnly))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] From bf030bef2c13f21c6424fa47aa859de7ffd7b1c7 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 14 Sep 2026 18:33:25 +0300 Subject: [PATCH 10/13] Cover reported union inputs under Web defaults Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../System.Text.Json/tests/Common/UnionTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index e621bb149f4c01..388eeaee5ddeec 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -416,17 +416,20 @@ public void UnionWithMixedAmbiguity_GetTypeInfoSucceeds() [JsonNumberHandling(JsonNumberHandling.Strict)] public union IntOrString(int, string); - [Fact] - public async Task UnionNumberHandling_StrictTypeAttributeOverridesWebDefaults() + [Theory] + [InlineData("42", 42)] + [InlineData("\"hello\"", "hello")] + [InlineData("\"42\"", "42")] + public async Task UnionNumberHandling_StrictTypeAttributeOverridesWebDefaults(string json, object expectedValue) { JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = Serializer.DefaultOptions.TypeInfoResolver, }; - IntOrString? value = await Serializer.DeserializeWrapper("\"hello\"", options); + IntOrString? value = await Serializer.DeserializeWrapper(json, options); - Assert.Equal("hello", GetUnionValue(value!)); + Assert.Equal(expectedValue, GetUnionValue(value!)); } public class Animal { } From 2384db7ac31a940a07380dc6931eb83e7bbc0f5f Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 14 Sep 2026 23:18:32 +0300 Subject: [PATCH 11/13] Honor union number handling in classifiers and schemas Apply union number handling to structural classification, source-generator ambiguity diagnostics, and schema export. Cover Web defaults, metadata precedence, nullable cases, and context isolation in the corresponding test suites. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Parser.cs | 23 ++-- .../gen/Model/SourceGenerationOptionsSpec.cs | 3 + .../Text/Json/Schema/JsonSchemaExporter.cs | 4 +- .../JsonUnionTypeStructuralClassifier.cs | 7 +- .../JsonSchemaExporterTests.TestTypes.cs | 7 ++ .../tests/Common/JsonSchemaExporterTests.cs | 78 +++++++++++++ .../tests/Common/JsonTestHelper.cs | 30 +++++ .../StructuralJsonTypeClassifierTests.cs | 81 +++++++++++++ .../Serialization/JsonSchemaExporterTests.cs | 5 + .../StructuralJsonTypeClassifierTests.cs | 4 + .../JsonSourceGeneratorDiagnosticsTests.cs | 107 ++++++++++++++++++ 11 files changed, 337 insertions(+), 12 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 4995aa3c16e79e..62280397046b99 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -61,6 +61,7 @@ private sealed class Parser public List Diagnostics { get; } = new(); private Location? _contextClassLocation; + private JsonNumberHandling _contextNumberHandling; public void ReportDiagnostic(DiagnosticDescriptor descriptor, Location? location, params object?[]? messageArgs) { @@ -151,6 +152,8 @@ public Parser(KnownTypeSymbols knownSymbols) return null; } + _contextNumberHandling = options?.GetEffectiveNumberHandling() ?? JsonNumberHandling.Strict; + // Enqueue attribute data for spec generation foreach (TypeToGenerate rootSerializableType in rootSerializableTypes) { @@ -184,6 +187,7 @@ public Parser(KnownTypeSymbols knownSymbols) _generatedTypes.Clear(); _typesToGenerate.Clear(); _contextClassLocation = null; + _contextNumberHandling = default; return contextGenSpec; } @@ -1788,11 +1792,13 @@ private void EmitUnionAmbiguityDiagnostics(INamedTypeSymbol unionType, List> valueTypeToTypes = new(); + JsonNumberHandling? unionNumberHandling = GetNumberHandling(unionType); foreach (ITypeSymbol caseType in caseTypes) { string caseTypeName = caseType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); - JsonValueType valueTypes = GetSupportedJsonValueTypes(caseType); + JsonNumberHandling effectiveNumberHandling = unionNumberHandling ?? GetNumberHandling(caseType) ?? _contextNumberHandling; + JsonValueType valueTypes = GetSupportedJsonValueTypes(caseType, effectiveNumberHandling); for (int flag = 1; flag <= (int)JsonValueType.Boolean; flag <<= 1) { @@ -1837,7 +1843,7 @@ private void EmitUnionAmbiguityDiagnostics(INamedTypeSymbol unionType, List 0 && - attr.ConstructorArguments[0].Value is int handlingValue && - ((JsonNumberHandling)handlingValue & JsonNumberHandling.AllowReadingFromString) != 0) + attr.ConstructorArguments[0].Value is int handlingValue) { - return true; + return (JsonNumberHandling)handlingValue; } } - return false; + return null; } private bool TryResolveCollectionType( diff --git a/src/libraries/System.Text.Json/gen/Model/SourceGenerationOptionsSpec.cs b/src/libraries/System.Text.Json/gen/Model/SourceGenerationOptionsSpec.cs index fafcec03e88a59..6f316ac0f6017b 100644 --- a/src/libraries/System.Text.Json/gen/Model/SourceGenerationOptionsSpec.cs +++ b/src/libraries/System.Text.Json/gen/Model/SourceGenerationOptionsSpec.cs @@ -78,6 +78,9 @@ public sealed record SourceGenerationOptionsSpec public required bool? InferClosedTypePolymorphism { get; init; } + public JsonNumberHandling GetEffectiveNumberHandling() + => NumberHandling ?? (Defaults is JsonSerializerDefaults.Web ? JsonNumberHandling.AllowReadingFromString : JsonNumberHandling.Strict); + public JsonKnownNamingPolicy? GetEffectivePropertyNamingPolicy() => PropertyNamingPolicy ?? (Defaults is JsonSerializerDefaults.Web ? JsonKnownNamingPolicy.CamelCase : null); } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs index e9ff91cd7f1c49..5a6542947366d7 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchemaExporter.cs @@ -181,7 +181,7 @@ private static JsonSchema MapJsonSchemaCore( if (effectiveConverter.NullableElementConverter is { } elementConverter) { JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementConverter.Type!); - schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: elementConverter, cacheResult: false); + schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: elementConverter, customNumberHandling: customNumberHandling ?? typeInfo.NumberHandling, cacheResult: false); if (elementConverter.IsIeeeFloatingPointConverter && (effectiveNumberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0) @@ -371,7 +371,7 @@ private static JsonSchema MapJsonSchemaCore( JsonTypeInfo caseTypeInfo = typeInfo.Options.GetTypeInfoInternal(caseInfo.CaseType); state.PushSchemaNode(unionAnyOf.Count.ToString(CultureInfo.InvariantCulture)); - JsonSchema caseSchema = MapJsonSchemaCore(ref state, caseTypeInfo, cacheResult: false); + JsonSchema caseSchema = MapJsonSchemaCore(ref state, caseTypeInfo, customNumberHandling: typeInfo.NumberHandling, cacheResult: false); state.PopSchemaNode(); if (caseInfo.IsNullable) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonUnionTypeStructuralClassifier.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonUnionTypeStructuralClassifier.cs index ee5e902d30bd60..dc96da24871851 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonUnionTypeStructuralClassifier.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonUnionTypeStructuralClassifier.cs @@ -87,6 +87,8 @@ private static StructuralClassifier BuildStructuralClassifier( IReadOnlyList unionCases, JsonSerializerOptions options) { + JsonNumberHandling? unionNumberHandling = options.GetTypeInfo(unionType).NumberHandling; + // POCO object cases expose JsonPropertyInfo metadata through JsonTypeInfoKind.Object. // A non-POCO JSON object case advertises the Object shape without such metadata. Dictionary shapeBasedCases = new(); @@ -98,6 +100,7 @@ private static StructuralClassifier BuildStructuralClassifier( AddCase( unionType, unionCase.CaseType, + unionNumberHandling, options, shapeBasedCases, pocoObjectCaseList, @@ -163,12 +166,14 @@ static void AddPocoPropertyInfo( private static void AddCase( Type unionType, Type caseType, + JsonNumberHandling? unionNumberHandling, JsonSerializerOptions options, Dictionary shapeBasedCases, List pocoObjectCases, ref int requiredPropertyCount) { JsonTypeInfo typeInfo = options.GetTypeInfo(caseType); + JsonNumberHandling? numberHandlingOverride = unionNumberHandling ?? typeInfo.NumberHandling; if (typeInfo is { IsNullable: true, ElementTypeInfo: JsonTypeInfo elementTypeInfo }) { typeInfo = elementTypeInfo; @@ -188,7 +193,7 @@ private static void AddCase( caseType); } - JsonNumberHandling numberHandling = typeInfo.NumberHandling ?? options.NumberHandling; + JsonNumberHandling numberHandling = numberHandlingOverride ?? typeInfo.NumberHandling ?? options.NumberHandling; JsonValueType valueTypes = typeInfo.Converter.GetSupportedJsonValueTypes(numberHandling); bool isPocoObjectCase = typeInfo is diff --git a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs index 2a22505a689d73..b843ca6243e91d 100644 --- a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs +++ b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.TestTypes.cs @@ -1405,6 +1405,13 @@ public class PocoWithCustomNaming public string? StringProperty { get; set; } } + [JsonNumberHandling(JsonNumberHandling.Strict)] + public union StrictIntOrStringUnion(int, string); + + public union IntOrBoolUnion(int, bool); + + public union NullableIntUnion(int?); + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public class PocoWithCustomNumberHandling { diff --git a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs index 190166ef19c3f8..9e58181a2652b4 100644 --- a/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/JsonSchemaExporterTests.cs @@ -55,6 +55,84 @@ public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData) AssertDocumentMatchesSchema(schema, instance); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnionNumberHandling_StrictTypeAttributeOverridesWebDefaults(bool asCollectionElement) + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = Serializer.DefaultOptions.TypeInfoResolver, + }; + + Type type = asCollectionElement ? typeof(List) : typeof(StrictIntOrStringUnion); + JsonNode schema = Serializer.GetTypeInfo(type, options).GetJsonSchemaAsNode(); + JsonNode unionSchema = asCollectionElement ? schema["items"]! : schema; + + JsonTestHelper.AssertJsonEqual("""{"type":"integer"}""", unionSchema["anyOf"]![0]!.ToJsonString()); + } + + [Theory] + [MemberData(nameof(JsonTestHelper.GetUnionCaseNumberHandlingPrecedenceTestData), MemberType = typeof(JsonTestHelper))] + public void UnionNumberHandling_MetadataOverrides( + JsonNumberHandling globalHandling, JsonNumberHandling? unionHandling, JsonNumberHandling? caseHandling, JsonNumberHandling expectedHandling) + { + foreach ((Type unionType, Type numberType) in new[] { (typeof(IntOrBoolUnion), typeof(int)), (typeof(NullableIntUnion), typeof(int?)) }) + { + JsonSerializerOptions options = Serializer.CreateOptions( + configure: options => options.NumberHandling = globalHandling, + modifier: typeInfo => + { + if (typeInfo.Type == numberType) + { + typeInfo.NumberHandling = caseHandling; + } + }); + + JsonTypeInfo typeInfo = Serializer.GetTypeInfo(unionType, options, mutable: true); + typeInfo.NumberHandling = unionHandling; + JsonNode schema = typeInfo.GetJsonSchemaAsNode(); + JsonNode numberSchema = numberType == typeof(int) ? schema["anyOf"]![0]! : schema; + JsonNode schemaType = numberSchema["type"]!; + IEnumerable actualTypes = schemaType is JsonArray types + ? types.Select(type => (string?)type) + : [(string?)schemaType]; + + List expectedTypes = ["integer"]; + if ((expectedHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) + { + expectedTypes.Add("string"); + } + if (numberType == typeof(int?)) + { + expectedTypes.Add("null"); + } + + Assert.Equal(expectedTypes.OrderBy(type => type, StringComparer.Ordinal), actualTypes.OrderBy(type => type, StringComparer.Ordinal)); + } + } + + [Theory] + [InlineData(JsonNumberHandling.AllowReadingFromString, JsonNumberHandling.Strict)] + [InlineData(JsonNumberHandling.Strict, JsonNumberHandling.AllowReadingFromString)] + public void UnionNumberHandling_NullableCasePreservesElementOverride(JsonNumberHandling globalHandling, JsonNumberHandling elementHandling) + { + JsonSerializerOptions options = Serializer.CreateOptions( + configure: options => options.NumberHandling = globalHandling, + modifier: typeInfo => + { + if (typeInfo.Type == typeof(int)) + { + typeInfo.NumberHandling = elementHandling; + } + }); + + bool allowsStrings = (elementHandling & JsonNumberHandling.AllowReadingFromString) != 0; + JsonNode schema = Serializer.GetTypeInfo(options).GetJsonSchemaAsNode(); + JsonArray types = Assert.IsType(schema["type"]); + Assert.Equal(allowsStrings, types.Any(type => (string?)type == "string")); + } + [Theory] [InlineData(typeof(string), "string")] [InlineData(typeof(int[]), "array")] diff --git a/src/libraries/System.Text.Json/tests/Common/JsonTestHelper.cs b/src/libraries/System.Text.Json/tests/Common/JsonTestHelper.cs index 95535fa97de428..eaa1efecb93bb1 100644 --- a/src/libraries/System.Text.Json/tests/Common/JsonTestHelper.cs +++ b/src/libraries/System.Text.Json/tests/Common/JsonTestHelper.cs @@ -253,6 +253,36 @@ public static IEnumerable CrossJoin( public static string StripWhitespace(this string value) => s_stripWhitespace.Replace(value, string.Empty); + + public static TheoryData GetUnionCaseNumberHandlingPrecedenceTestData() + { + const JsonNumberHandling Strict = JsonNumberHandling.Strict; + const JsonNumberHandling ReadFromString = JsonNumberHandling.AllowReadingFromString; + const JsonNumberHandling WriteAsString = JsonNumberHandling.WriteAsString; + + // Columns: global options, union-type override, case-type override, expected effective case handling. + // null means no override; an explicit Strict value must still override less restrictive settings. + return new() + { + // With no type overrides, use the global options. + { Strict, null, null, Strict }, + { ReadFromString, null, null, ReadFromString }, + { WriteAsString, null, null, WriteAsString }, + + // The union-type override wins over both the case type and global options; flags are not merged. + { ReadFromString, Strict, null, Strict }, + { ReadFromString, Strict, ReadFromString, Strict }, + { Strict, ReadFromString, Strict, ReadFromString }, + { ReadFromString, WriteAsString, ReadFromString, WriteAsString }, + { ReadFromString | WriteAsString, Strict, null, Strict }, + + // Without a union override, the case-type setting wins over the global options. + { ReadFromString, null, Strict, Strict }, + { Strict, null, ReadFromString, ReadFromString }, + { Strict, null, WriteAsString, WriteAsString }, + { Strict, null, ReadFromString | WriteAsString, ReadFromString | WriteAsString }, + }; + } } /// diff --git a/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs b/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs index fb728ae4545b37..7d16c91d3d1851 100644 --- a/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs @@ -195,6 +195,80 @@ await Assert.ThrowsAsync( () => Serializer.DeserializeWrapper("\"42\"", options)); } + [Theory] + [InlineData("42", 42)] + [InlineData("\"hello\"", "hello")] + [InlineData("\"42\"", "42")] + public async Task StructuralClassifier_StrictNumberHandlingOverridesWebDefaults(string json, object expectedValue) + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = Serializer.DefaultOptions.TypeInfoResolver, + }; + + StrictNumericStringUnion? value = await Serializer.DeserializeWrapper(json, options); + Assert.Equal(expectedValue, GetUnionValue(value)); + } + + [Theory] + [MemberData(nameof(JsonTestHelper.GetUnionCaseNumberHandlingPrecedenceTestData), MemberType = typeof(JsonTestHelper))] + public async Task StructuralClassifier_NumberHandlingHonorsMetadataOverrides( + JsonNumberHandling globalHandling, JsonNumberHandling? unionHandling, JsonNumberHandling? caseHandling, JsonNumberHandling expectedHandling) + { + foreach ((Type unionType, Type numberType) in new[] { (typeof(NumericStringUnion), typeof(int)), (typeof(NullableNumericStringUnion), typeof(int?)) }) + { + JsonSerializerOptions options = Serializer.CreateOptions( + configure: options => options.NumberHandling = globalHandling, + modifier: typeInfo => + { + if (typeInfo.Type == unionType) + { + typeInfo.NumberHandling = unionHandling; + } + else if (typeInfo.Type == numberType) + { + typeInfo.NumberHandling = caseHandling; + } + }); + + if ((expectedHandling & JsonNumberHandling.AllowReadingFromString) != 0) + { + await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper("\"42\"", unionType, options)); + } + else + { + object value = await Serializer.DeserializeWrapper("\"42\"", unionType, options); + Assert.Equal("42", ((IUnion)value).Value); + } + } + } + + [Theory] + [InlineData(JsonNumberHandling.AllowReadingFromString, JsonNumberHandling.Strict)] + [InlineData(JsonNumberHandling.Strict, JsonNumberHandling.AllowReadingFromString)] + public async Task StructuralClassifier_NullableCasePreservesElementOverride(JsonNumberHandling globalHandling, JsonNumberHandling elementHandling) + { + JsonSerializerOptions options = Serializer.CreateOptions( + configure: options => options.NumberHandling = globalHandling, + modifier: typeInfo => + { + if (typeInfo.Type == typeof(int)) + { + typeInfo.NumberHandling = elementHandling; + } + }); + + if ((elementHandling & JsonNumberHandling.AllowReadingFromString) != 0) + { + await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper("\"42\"", options)); + } + else + { + NullableNumericStringUnion? value = await Serializer.DeserializeWrapper("\"42\"", options); + Assert.Equal("42", GetUnionValue(value)); + } + } + [Theory] [InlineData(typeof(PolymorphicOrStringUnion), nameof(PolyAnimal))] [InlineData(typeof(PolymorphicCollectionOrStringUnion), nameof(PolymorphicIntList))] @@ -664,6 +738,13 @@ public sealed class OtherRenamedPropertyCase [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] public union NumericStringUnion(int, string); + [JsonNumberHandling(JsonNumberHandling.Strict)] + [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] + public union StrictNumericStringUnion(int, string); + + [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] + public union NullableNumericStringUnion(int?, string); + [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] public union StringUnion(Guid, string); diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs index 6aaeecaef1c9f8..431560f7d631cf 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/JsonSchemaExporterTests.cs @@ -91,6 +91,11 @@ public sealed partial class JsonSchemaExporterTests_SourceGen() [JsonSerializable(typeof(IntEnum?))] [JsonSerializable(typeof(StringEnum?))] [JsonSerializable(typeof(SimpleRecordStruct?))] + // Union types + [JsonSerializable(typeof(StrictIntOrStringUnion))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(IntOrBoolUnion))] + [JsonSerializable(typeof(NullableIntUnion))] // User-defined POCOs [JsonSerializable(typeof(SimplePoco))] [JsonSerializable(typeof(SimpleRecord))] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/StructuralJsonTypeClassifierTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/StructuralJsonTypeClassifierTests.cs index d10d22ea60d5ef..0391f17bb5b353 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/StructuralJsonTypeClassifierTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/StructuralJsonTypeClassifierTests.cs @@ -64,6 +64,7 @@ public StructuralJsonTypeClassifierTests_Metadata() [JsonSerializable(typeof(ManyObjectCasesUnion))] [JsonSerializable(typeof(MisconfiguredPolymorphicBase))] [JsonSerializable(typeof(MisconfiguredPolymorphicDerived))] + [JsonSerializable(typeof(NullableNumericStringUnion))] [JsonSerializable(typeof(NumberHandlingUnion))] [JsonSerializable(typeof(NumericStringUnion))] [JsonSerializable(typeof(NumericUnion))] @@ -93,6 +94,7 @@ public StructuralJsonTypeClassifierTests_Metadata() [JsonSerializable(typeof(SinglePropertyCase))] [JsonSerializable(typeof(StatusReading))] [JsonSerializable(typeof(Strict))] + [JsonSerializable(typeof(StrictNumericStringUnion))] [JsonSerializable(typeof(StringUnion))] [JsonSerializable(typeof(SubtractionAlpha))] [JsonSerializable(typeof(SubtractionBeta))] @@ -169,6 +171,7 @@ public StructuralJsonTypeClassifierTests_Default() [JsonSerializable(typeof(ManyObjectCasesUnion))] [JsonSerializable(typeof(MisconfiguredPolymorphicBase))] [JsonSerializable(typeof(MisconfiguredPolymorphicDerived))] + [JsonSerializable(typeof(NullableNumericStringUnion))] [JsonSerializable(typeof(NumberHandlingUnion))] [JsonSerializable(typeof(NumericStringUnion))] [JsonSerializable(typeof(NumericUnion))] @@ -198,6 +201,7 @@ public StructuralJsonTypeClassifierTests_Default() [JsonSerializable(typeof(SinglePropertyCase))] [JsonSerializable(typeof(StatusReading))] [JsonSerializable(typeof(Strict))] + [JsonSerializable(typeof(StrictNumericStringUnion))] [JsonSerializable(typeof(StringUnion))] [JsonSerializable(typeof(SubtractionAlpha))] [JsonSerializable(typeof(SubtractionBeta))] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs index a4073e896f6e95..9fd1b5509e54d3 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Unit.Tests/JsonSourceGeneratorDiagnosticsTests.cs @@ -712,6 +712,113 @@ internal partial class MyContext : JsonSerializerContext { } CompilationHelper.AssertEqualDiagnosticMessages(expectedDiagnostics, result.Diagnostics); } + [Theory] + [InlineData("", null, false)] + [InlineData("", "JsonNumberHandling.AllowReadingFromString", true)] + [InlineData("NumberHandling = JsonNumberHandling.Strict", null, false)] + [InlineData("NumberHandling = JsonNumberHandling.AllowReadingFromString", null, true)] + [InlineData("JsonSerializerDefaults.General", null, false)] + [InlineData("JsonSerializerDefaults.Strict", null, false)] + [InlineData("JsonSerializerDefaults.Web", null, true)] + [InlineData("JsonSerializerDefaults.Web", "JsonNumberHandling.Strict", false)] + [InlineData("JsonSerializerDefaults.General", "JsonNumberHandling.AllowReadingFromString", true)] + [InlineData("JsonSerializerDefaults.Web", "JsonNumberHandling.WriteAsString", false)] + [InlineData("JsonSerializerDefaults.Web, NumberHandling = JsonNumberHandling.Strict", null, false)] + [InlineData("JsonSerializerDefaults.Web, NumberHandling = JsonNumberHandling.Strict", "JsonNumberHandling.AllowReadingFromString", true)] + [InlineData("JsonSerializerDefaults.General, NumberHandling = JsonNumberHandling.AllowReadingFromString", null, true)] + [InlineData("JsonSerializerDefaults.General, NumberHandling = JsonNumberHandling.AllowReadingFromString", "JsonNumberHandling.Strict", false)] + [InlineData("JsonSerializerDefaults.Web, NumberHandling = JsonNumberHandling.WriteAsString", null, false)] + [InlineData("JsonSerializerDefaults.General, NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString", null, true)] + public void UnionNumberHandling_ReportsExpectedAmbiguityDiagnostics(string contextOptions, string? unionNumberHandling, bool expectWarning) + { + string numberHandlingAttribute = unionNumberHandling is null ? "" : $"[JsonNumberHandling({unionNumberHandling})]"; + string source = $$""" + using System.Runtime.CompilerServices; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace TestApp + { + [JsonSourceGenerationOptions({{contextOptions}})] + [JsonSerializable(typeof(IntOrStringUnion))] + internal partial class MyContext : JsonSerializerContext { } + + [Union] + {{numberHandlingAttribute}} + public readonly struct IntOrStringUnion : IUnion + { + public IntOrStringUnion(int value) { Value = value; } + public IntOrStringUnion(string value) { Value = value; } + public object Value { get; } + } + } + """; + + Compilation compilation = CompilationHelper.CreateCompilation(source); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + + if (expectWarning) + { + Diagnostic diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("SYSLIB1227", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Contains("case types 'int', 'string' all serialize as JSON value type 'String'", diagnostic.GetMessage()); + } + else + { + Assert.Empty(result.Diagnostics); + } +#if NET + // These compiler versions do not support the generated union patterns. + Assert.Equal(new[] { "CS0037", "CS8121", "CS8121" }, result.NewCompilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity is DiagnosticSeverity.Warning or DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.Id) + .OrderBy(id => id, StringComparer.Ordinal)); +#endif + } + + [Fact] + public void UnionNumberHandling_ContextDefaultsDoNotLeak() + { + foreach (bool webContextFirst in new[] { true, false }) + { + const string WebOptions = "[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]"; + string source = $$""" + using System.Runtime.CompilerServices; + using System.Text.Json; + using System.Text.Json.Serialization; + + namespace TestApp + { + {{(webContextFirst ? WebOptions : "")}} + [JsonSerializable(typeof(IntOrStringUnion))] + internal partial class FirstContext : JsonSerializerContext { } + + {{(webContextFirst ? "" : WebOptions)}} + [JsonSerializable(typeof(IntOrStringUnion))] + internal partial class SecondContext : JsonSerializerContext { } + + [Union] + public readonly struct IntOrStringUnion : IUnion + { + public IntOrStringUnion(int value) { Value = value; } + public IntOrStringUnion(string value) { Value = value; } + public object Value { get; } + } + } + """; + + Compilation compilation = CompilationHelper.CreateCompilation(source); + JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, disableDiagnosticValidation: true); + + Assert.Contains(result.NewCompilation.SyntaxTrees, tree => Path.GetFileName(tree.FilePath) == "FirstContext.g.cs"); + Assert.Contains(result.NewCompilation.SyntaxTrees, tree => Path.GetFileName(tree.FilePath) == "SecondContext.g.cs"); + Diagnostic diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("SYSLIB1227", diagnostic.Id); + Assert.Contains("case types 'int', 'string' all serialize as JSON value type 'String'", diagnostic.GetMessage()); + } + } + [Fact] public void UnionWithCustomConverter_CompilesWithoutWarning() { From 4c944a7cf8695c81c6663cba425951f0e2c9ece8 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 15 Sep 2026 00:35:39 +0300 Subject: [PATCH 12/13] Propagate union number handling to case converters Set the read/write frame handling from the union override or the selected case metadata. Cover numeric and nullable cases, collection continuations, precedence, and POCO member independence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Converters/Union/JsonUnionConverter.cs | 2 + .../tests/Common/UnionTests.cs | 101 ++++++++++++++++++ .../Serialization/UnionTests.cs | 2 + 3 files changed, 105 insertions(+) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs index 6bab2cc9bd1801..84be12235c5617 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Union/JsonUnionConverter.cs @@ -74,6 +74,7 @@ internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; + state.Current.NumberHandling = typeInfo.NumberHandling ?? caseTypeInfo.PropertyInfoForTypeInfo.EffectiveNumberHandling; } JsonConverter caseConverter = caseTypeInfo.Converter; @@ -185,6 +186,7 @@ internal override bool OnTryWrite(Utf8JsonWriter writer, TUnion value, JsonSeria JsonTypeInfo caseTypeInfo = options.GetTypeInfoInternal(caseType); state.Current.JsonPropertyInfo = caseTypeInfo.PropertyInfoForTypeInfo; + state.Current.NumberHandling = typeInfo.NumberHandling ?? caseTypeInfo.PropertyInfoForTypeInfo.EffectiveNumberHandling; return caseTypeInfo.Converter.TryWriteAsObject(writer, caseValue, options, ref state); } } diff --git a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index 388eeaee5ddeec..caa3d686b9e45e 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -432,6 +432,107 @@ public async Task UnionNumberHandling_StrictTypeAttributeOverridesWebDefaults(st Assert.Equal(expectedValue, GetUnionValue(value!)); } + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public union NumberHandlingUnion(int, int[], bool); + + [Theory] + [InlineData("42", "\"42\"")] + [InlineData("\"42\"", "\"42\"")] + [InlineData("[1,2,3,4,5,6,7,8]", "[\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\"]")] + [InlineData("[\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\"]", "[\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\"]")] + [InlineData("true", "true")] + public async Task UnionNumberHandling_TypeAttributeAppliesToCaseConverter(string json, string expectedJson) + { + JsonSerializerOptions options = Serializer.CreateOptions(configure: options => + { + options.NumberHandling = JsonNumberHandling.Strict; + options.DefaultBufferSize = 1; + }); + + NumberHandlingUnion value = await Serializer.DeserializeWrapper(json, options); + Assert.Equal(expectedJson, await Serializer.SerializeWrapper(value, options)); + } + + [Fact] + public async Task UnionNumberHandling_StrictTypeAttributeOverridesGlobalWriteAsString() + { + JsonSerializerOptions options = Serializer.CreateOptions( + configure: options => options.NumberHandling = JsonNumberHandling.WriteAsString); + + Assert.Equal("42", await Serializer.SerializeWrapper(new IntOrString(42), options)); + } + + [Theory] + [MemberData(nameof(JsonTestHelper.GetUnionCaseNumberHandlingPrecedenceTestData), MemberType = typeof(JsonTestHelper))] + public async Task UnionNumberHandling_CaseConvertersHonorPrecedence( + JsonNumberHandling globalHandling, JsonNumberHandling? unionHandling, JsonNumberHandling? caseHandling, JsonNumberHandling expectedHandling) + { + foreach ((Type unionType, Type numberType) in new[] { (typeof(IntOrBool), typeof(int)), (typeof(SingleNullableValueTypeUnion), typeof(int?)) }) + { + JsonSerializerOptions options = Serializer.CreateOptions( + configure: options => options.NumberHandling = globalHandling, + modifier: typeInfo => + { + if (typeInfo.Type == unionType) + { + typeInfo.NumberHandling = unionHandling; + } + else if (typeInfo.Type == numberType) + { + typeInfo.NumberHandling = caseHandling; + } + }); + + object value = await Serializer.DeserializeWrapper("42", unionType, options); + Assert.Equal(42, ((IUnion)value).Value); + + string expectedJson = (expectedHandling & JsonNumberHandling.WriteAsString) != 0 ? "\"42\"" : "42"; + Assert.Equal(expectedJson, await Serializer.SerializeWrapper(value, unionType, options)); + + if ((expectedHandling & JsonNumberHandling.AllowReadingFromString) != 0) + { + value = await Serializer.DeserializeWrapper("\"42\"", unionType, options); + Assert.Equal(42, ((IUnion)value).Value); + } + else + { + await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper("\"42\"", unionType, options)); + } + + if (numberType == typeof(int?)) + { + value = await Serializer.DeserializeWrapper("null", unionType, options); + Assert.Null(((IUnion)value).Value); + Assert.Equal("null", await Serializer.SerializeWrapper(value, unionType, options)); + } + } + } + + [Theory] + [InlineData(JsonNumberHandling.Strict, JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString, """{"Name":"item","Values":["42"]}""")] + [InlineData(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString, JsonNumberHandling.Strict, """{"Name":"item","Values":[42]}""")] + public async Task UnionNumberHandling_DoesNotOverridePocoMembers( + JsonNumberHandling unionHandling, JsonNumberHandling pocoHandling, string expectedJson) + { + JsonSerializerOptions options = Serializer.CreateOptions( + modifier: typeInfo => + { + if (typeInfo.Type == typeof(MixedUnion)) + { + typeInfo.NumberHandling = unionHandling; + } + else if (typeInfo.Type == typeof(Payload)) + { + typeInfo.NumberHandling = pocoHandling; + } + }); + + MixedUnion value = new(new Payload { Name = "item", Values = [42] }); + JsonTestHelper.AssertJsonEqual(expectedJson, await Serializer.SerializeWrapper(value, options)); + MixedUnion? result = await Serializer.DeserializeWrapper(expectedJson, options); + Assert.Equal(new[] { 42 }, Assert.IsType(GetUnionValue(result!)).Values); + } + public class Animal { } public class Dog : Animal { } public class Lab : Dog { } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs index 6a7b0dd2f46153..6f5b90b8b7808f 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs @@ -34,6 +34,7 @@ public UnionTests_Metadata() [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] [JsonSerializable(typeof(IntOrString))] + [JsonSerializable(typeof(NumberHandlingUnion))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] @@ -99,6 +100,7 @@ public UnionTests_Default() [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] [JsonSerializable(typeof(IntOrString))] + [JsonSerializable(typeof(NumberHandlingUnion))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] From 8a3eaf4c8e69db8c686904005bed143e7dabb4d7 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 15 Sep 2026 10:41:18 +0300 Subject: [PATCH 13/13] Preserve originating metadata in classifier contexts Carry the originating JsonTypeInfo in an internal context property so structural classification honors directly supplied contract overrides without resolving another contract through options. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JsonTypeClassifierContext.cs | 11 +++++---- .../JsonUnionTypeStructuralClassifier.cs | 7 +++--- .../Serialization/Metadata/JsonTypeInfo.cs | 4 ++-- .../Common/PolymorphicTests.TypeClassifier.cs | 24 +++++++++---------- .../StructuralJsonTypeClassifierTests.cs | 23 ++++++++++++++++++ 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonTypeClassifierContext.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonTypeClassifierContext.cs index b474d6b9ff6084..1d50c73a508ba9 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonTypeClassifierContext.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonTypeClassifierContext.cs @@ -28,19 +28,19 @@ public sealed class JsonTypeClassifierContext /// Initializes a new instance of the class. /// /// The type of classifier metadata being configured. - /// The type being configured for classification. + /// The contract being configured for classification. /// The union cases of the declaring type, or an empty list. /// The derived types of the declaring type, or an empty list. /// The JSON property name used for type discrimination, or . internal JsonTypeClassifierContext( JsonTypeClassifierKind kind, - Type declaringType, + JsonTypeInfo declaringTypeInfo, IReadOnlyList unionCases, IReadOnlyList derivedTypes, string? typeDiscriminatorPropertyName) { Kind = kind; - DeclaringType = declaringType; + DeclaringTypeInfo = declaringTypeInfo; UnionCases = unionCases; DerivedTypes = derivedTypes; TypeDiscriminatorPropertyName = typeDiscriminatorPropertyName; @@ -58,7 +58,10 @@ internal JsonTypeClassifierContext( /// For polymorphic types, this is the base class (e.g., Animal). /// For union types, this is the union type (e.g., IntOrString). /// - public Type DeclaringType { get; } + public Type DeclaringType => DeclaringTypeInfo.Type; + + // The contract may still be mutable when a modifier reads TypeClassifier. + internal JsonTypeInfo DeclaringTypeInfo { get; } /// /// Gets the union cases of . diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonUnionTypeStructuralClassifier.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonUnionTypeStructuralClassifier.cs index dc96da24871851..f00fda7ae412b8 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonUnionTypeStructuralClassifier.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonUnionTypeStructuralClassifier.cs @@ -78,16 +78,17 @@ public override JsonTypeClassifier CreateJsonClassifier( ThrowHelper.ThrowNotSupportedException_UnionTypeStructuralClassifierPreserveReferencesNotSupported(context.DeclaringType); } - StructuralClassifier classifier = BuildStructuralClassifier(context.DeclaringType, context.UnionCases, options); + StructuralClassifier classifier = BuildStructuralClassifier(context.DeclaringTypeInfo, context.UnionCases, options); return classifier.Classify; } private static StructuralClassifier BuildStructuralClassifier( - Type unionType, + JsonTypeInfo unionTypeInfo, IReadOnlyList unionCases, JsonSerializerOptions options) { - JsonNumberHandling? unionNumberHandling = options.GetTypeInfo(unionType).NumberHandling; + Type unionType = unionTypeInfo.Type; + JsonNumberHandling? unionNumberHandling = unionTypeInfo.NumberHandling; // POCO object cases expose JsonPropertyInfo metadata through JsonTypeInfoKind.Object. // A non-POCO JSON object case advertises the Object shape without such metadata. diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs index 948f4d5d13a8e4..2736ab06cb2d37 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs @@ -1096,7 +1096,7 @@ private void ConfigureTypeClassifier() Debug.Assert(UnionCases.Count > 0); ctx = new JsonTypeClassifierContext( JsonTypeClassifierKind.Union, - Type, + this, new List(UnionCases), Array.Empty(), typeDiscriminatorPropertyName: null); @@ -1108,7 +1108,7 @@ private void ConfigureTypeClassifier() ctx = new JsonTypeClassifierContext( JsonTypeClassifierKind.PolymorphicType, - Type, + this, Array.Empty(), new List(polymorphismOptions.DerivedTypes), polymorphismOptions.TypeDiscriminatorPropertyName); diff --git a/src/libraries/System.Text.Json/tests/Common/PolymorphicTests.TypeClassifier.cs b/src/libraries/System.Text.Json/tests/Common/PolymorphicTests.TypeClassifier.cs index 14d386fce2368a..4cf00a170fbade 100644 --- a/src/libraries/System.Text.Json/tests/Common/PolymorphicTests.TypeClassifier.cs +++ b/src/libraries/System.Text.Json/tests/Common/PolymorphicTests.TypeClassifier.cs @@ -21,16 +21,16 @@ internal static class JsonTypeClassifierContextTestExtensions .GetConstructor( BindingFlags.Instance | BindingFlags.NonPublic, binder: null, - types: [typeof(JsonTypeClassifierKind), typeof(Type), typeof(IReadOnlyList), typeof(IReadOnlyList), typeof(string)], + types: [typeof(JsonTypeClassifierKind), typeof(JsonTypeInfo), typeof(IReadOnlyList), typeof(IReadOnlyList), typeof(string)], modifiers: null)!; public static JsonTypeClassifierContext Create( - Type declaringType, + JsonTypeInfo declaringTypeInfo, IReadOnlyList unionCases, IReadOnlyList derivedTypes, string? typeDiscriminatorPropertyName, JsonTypeClassifierKind kind = JsonTypeClassifierKind.PolymorphicType) - => (JsonTypeClassifierContext)s_ctor.Invoke([kind, declaringType, unionCases, derivedTypes, typeDiscriminatorPropertyName]); + => (JsonTypeClassifierContext)s_ctor.Invoke([kind, declaringTypeInfo, unionCases, derivedTypes, typeDiscriminatorPropertyName]); } /// @@ -310,7 +310,7 @@ public override JsonTypeClassifier CreateJsonClassifier( { var innerFactory = new TestDiscriminatorClassifierFactory(); var innerContext = JsonTypeClassifierContextTestExtensions.Create( - context.DeclaringType, + options.GetTypeInfo(context.DeclaringType), Array.Empty(), new JsonDerivedType[] { new JsonDerivedType(typeof(ClassifiedDog), "dog"), @@ -642,7 +642,7 @@ public async Task Classifier_DiscriminatorBased_ResolvesCorrectType(string json, { var factory = new TestDiscriminatorClassifierFactory(); var context = JsonTypeClassifierContextTestExtensions.Create( - typeof(ClassifiedAnimalBase), + Serializer.GetTypeInfo(), Array.Empty(), new JsonDerivedType[] { new JsonDerivedType(typeof(ClassifiedDog), "dog"), @@ -664,7 +664,7 @@ public async Task Classifier_DiscriminatorBased_AnyPropertyPosition(string json) { var factory = new TestDiscriminatorClassifierFactory(); var context = JsonTypeClassifierContextTestExtensions.Create( - typeof(ClassifiedAnimalBase), + Serializer.GetTypeInfo(), Array.Empty(), new JsonDerivedType[] { new JsonDerivedType(typeof(ClassifiedDog), "dog"), @@ -686,7 +686,7 @@ public async Task Classifier_IntDiscriminator_ResolvesCorrectType(string json, T { var factory = new TestDiscriminatorClassifierFactory(); var context = JsonTypeClassifierContextTestExtensions.Create( - typeof(ClassifiedAnimalBase), + Serializer.GetTypeInfo(), Array.Empty(), new JsonDerivedType[] { new JsonDerivedType(typeof(ClassifiedDog), 1), @@ -990,7 +990,7 @@ public async Task Classifier_CollectionWithMixedDiscriminators() { var factory = new TestDiscriminatorClassifierFactory(); var context = JsonTypeClassifierContextTestExtensions.Create( - typeof(ClassifiedAnimalBase), + Serializer.GetTypeInfo(), Array.Empty(), new JsonDerivedType[] { new JsonDerivedType(typeof(ClassifiedDog), "dog"), @@ -1109,7 +1109,7 @@ public async Task Classifier_PlusAllowOutOfOrder_ClassifierWins() { var factory = new TestDiscriminatorClassifierFactory(); var context = JsonTypeClassifierContextTestExtensions.Create( - typeof(ClassifiedAnimalBase), + Serializer.GetTypeInfo(), Array.Empty(), new JsonDerivedType[] { new JsonDerivedType(typeof(ClassifiedDog), "dog"), @@ -1233,7 +1233,7 @@ public async Task Classifier_DifferentClassifiersForDifferentBaseTypes() { var animalFactory = new TestDiscriminatorClassifierFactory(); var animalContext = JsonTypeClassifierContextTestExtensions.Create( - typeof(ClassifiedAnimalBase), + Serializer.GetTypeInfo(), Array.Empty(), new JsonDerivedType[] { new JsonDerivedType(typeof(ClassifiedDog), "dog"), @@ -1440,7 +1440,7 @@ private JsonSerializerOptions CreateOptionsWithStructuralClassifier() { var factory = new TestStructuralClassifierFactory(); var context = JsonTypeClassifierContextTestExtensions.Create( - typeof(TBase), + typeInfo, Array.Empty(), typeInfo.PolymorphismOptions.DerivedTypes.ToList(), typeInfo.PolymorphismOptions.TypeDiscriminatorPropertyName); @@ -1456,7 +1456,7 @@ public async Task Classifier_WithReferenceHandlerPreserve_PreservesReferences() { var factory = new TestDiscriminatorClassifierFactory(); var context = JsonTypeClassifierContextTestExtensions.Create( - typeof(ClassifiedAnimalBase), + Serializer.GetTypeInfo(), Array.Empty(), new JsonDerivedType[] { new JsonDerivedType(typeof(ClassifiedDog), "dog"), diff --git a/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs b/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs index 7d16c91d3d1851..6e821d6a5ae4fe 100644 --- a/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs @@ -269,6 +269,29 @@ public async Task StructuralClassifier_NullableCasePreservesElementOverride(Json } } + [Theory] + [InlineData("42", 42)] + [InlineData("\"42\"", "42")] + [InlineData("\"hello\"", "hello")] + public async Task StructuralClassifier_UsesSuppliedTypeInfoNumberHandling(string json, object expectedValue) + { + foreach (Type unionType in new[] { typeof(NumericStringUnion), typeof(NullableNumericStringUnion) }) + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = Serializer.DefaultOptions.TypeInfoResolver, + }; + + JsonTypeInfo typeInfo = Serializer.GetTypeInfo(unionType, options, mutable: true); + Assert.False(typeInfo.IsReadOnly); + typeInfo.NumberHandling = JsonNumberHandling.Strict; + + object value = await Serializer.DeserializeWrapper(json, typeInfo); + Assert.Equal(expectedValue, ((IUnion)value).Value); + Assert.True(typeInfo.IsReadOnly); + } + } + [Theory] [InlineData(typeof(PolymorphicOrStringUnion), nameof(PolyAnimal))] [InlineData(typeof(PolymorphicCollectionOrStringUnion), nameof(PolymorphicIntList))]