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/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/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 ee5e902d30bd60..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,15 +78,18 @@ 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) { + 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. Dictionary shapeBasedCases = new(); @@ -98,6 +101,7 @@ private static StructuralClassifier BuildStructuralClassifier( AddCase( unionType, unionCase.CaseType, + unionNumberHandling, options, shapeBasedCases, pocoObjectCaseList, @@ -163,12 +167,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 +194,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/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs index 96819b198b68c9..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); @@ -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/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/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 fb728ae4545b37..6e821d6a5ae4fe 100644 --- a/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/StructuralJsonTypeClassifierTests.cs @@ -195,6 +195,103 @@ 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("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))] @@ -664,6 +761,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/Common/UnionTests.cs b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs index c5dfd273bb938e..caa3d686b9e45e 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnionTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnionTests.cs @@ -413,6 +413,126 @@ public void UnionWithMixedAmbiguity_GetTypeInfoSucceeds() Assert.NotNull(typeInfo); } + [JsonNumberHandling(JsonNumberHandling.Strict)] + public union IntOrString(int, string); + + [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(json, options); + + 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/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.Tests/Serialization/UnionTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnionTests.cs index a175b77446f6d0..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 @@ -33,6 +33,8 @@ public UnionTests_Metadata() [JsonSerializable(typeof(UnionWithCustomConverterCase))] [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] + [JsonSerializable(typeof(IntOrString))] + [JsonSerializable(typeof(NumberHandlingUnion))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] @@ -97,6 +99,8 @@ public UnionTests_Default() [JsonSerializable(typeof(UnionWithCustomConverterCase))] [JsonSerializable(typeof(UnionWithIntAndLongCase))] [JsonSerializable(typeof(UnionWithMixedAmbiguity))] + [JsonSerializable(typeof(IntOrString))] + [JsonSerializable(typeof(NumberHandlingUnion))] [JsonSerializable(typeof(AsyncEnumerableUnion))] [JsonSerializable(typeof(CustomCase))] [JsonSerializable(typeof(OtherCase))] 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() {