From acfa4db7d5f7b7b13c7a391b0dcb242c71158a51 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 23 Dec 2022 11:50:26 +0000 Subject: [PATCH 1/5] Add support for JsonUnmappedMemberHandling. --- .../Common/JsonUnmappedMemberHandling.cs | 27 +++ .../gen/JsonSourceGenerator.Emitter.cs | 13 ++ .../gen/JsonSourceGenerator.Parser.cs | 9 + .../System.Text.Json.SourceGeneration.targets | 1 + .../gen/TypeGenerationSpec.cs | 3 + .../System.Text.Json/ref/System.Text.Json.cs | 13 ++ .../src/Resources/Strings.resx | 6 + .../src/System.Text.Json.csproj | 2 + .../JsonUnmappedMemberHandlingAttribute.cs | 27 +++ .../Json/Serialization/JsonConverterOfT.cs | 2 +- .../Serialization/JsonSerializer.Helpers.cs | 3 + .../JsonSerializer.Read.HandlePropertyName.cs | 20 +- .../JsonSerializerOptions.Caching.cs | 2 + .../Serialization/JsonSerializerOptions.cs | 20 +- .../Metadata/JsonPropertyInfo.cs | 22 +- .../Serialization/Metadata/JsonTypeInfo.cs | 64 ++++- .../Metadata/ReflectionJsonTypeInfoOfT.cs | 11 + .../Metadata/SourceGenJsonTypeInfoOfT.cs | 2 +- .../Text/Json/ThrowHelper.Serialization.cs | 12 + .../tests/Common/JsonSerializerWrapper.cs | 14 ++ .../tests/Common/JsonTestHelper.cs | 57 +++++ .../Common/UnmappedMemberHandlingTests.cs | 220 ++++++++++++++++++ .../UnmappedMemberHandlingTests.cs | 33 +++ ...m.Text.Json.SourceGeneration.Tests.targets | 2 + .../Serialization/CacheTests.cs | 1 + ...tJsonTypeInfoResolverTests.JsonTypeInfo.cs | 45 ++++ .../Serialization/OptionsTests.cs | 37 +-- .../UnmappedMemberHandlingTests.cs | 15 ++ .../System.Text.Json.Tests.csproj | 2 + 29 files changed, 628 insertions(+), 57 deletions(-) create mode 100644 src/libraries/System.Text.Json/Common/JsonUnmappedMemberHandling.cs create mode 100644 src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonUnmappedMemberHandlingAttribute.cs create mode 100644 src/libraries/System.Text.Json/tests/Common/UnmappedMemberHandlingTests.cs create mode 100644 src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnmappedMemberHandlingTests.cs create mode 100644 src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/UnmappedMemberHandlingTests.cs diff --git a/src/libraries/System.Text.Json/Common/JsonUnmappedMemberHandling.cs b/src/libraries/System.Text.Json/Common/JsonUnmappedMemberHandling.cs new file mode 100644 index 00000000000000..dccd7a33fb03ec --- /dev/null +++ b/src/libraries/System.Text.Json/Common/JsonUnmappedMemberHandling.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Text.Json.Serialization +{ + /// + /// Determines how handles JSON properties that + /// cannot be mapped to a specific .NET member when deserializing object types. + /// +#if BUILDING_SOURCE_GENERATOR + internal +#else + public +#endif + enum JsonUnmappedMemberHandling + { + /// + /// Silently skips any unmapped properties. This is the default behavior. + /// + Skip = 0, + + /// + /// Throws an exception when an unmapped property is encountered. + /// + Disallow = 1, + } +} diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs index 55c7e5ad90df36..9743eaec41f3e3 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs @@ -31,6 +31,7 @@ private sealed partial class Emitter private const string PropertyInfoVarName = "propertyInfo"; internal const string JsonContextVarName = "jsonContext"; private const string NumberHandlingPropName = "NumberHandling"; + private const string UnmappedMemberHandlingPropName = "UnmappedMemberHandling"; private const string ObjectCreatorPropName = "ObjectCreator"; private const string OptionsInstanceVariableName = "Options"; private const string JsonTypeInfoReturnValueLocalVariableName = "jsonTypeInfo"; @@ -64,6 +65,7 @@ private sealed partial class Emitter private const string JsonCollectionInfoValuesTypeRef = "global::System.Text.Json.Serialization.Metadata.JsonCollectionInfoValues"; private const string JsonIgnoreConditionTypeRef = "global::System.Text.Json.Serialization.JsonIgnoreCondition"; private const string JsonNumberHandlingTypeRef = "global::System.Text.Json.Serialization.JsonNumberHandling"; + private const string JsonUnmappedMemberHandlingTypeRef = "global::System.Text.Json.Serialization.JsonUnmappedMemberHandling"; private const string JsonMetadataServicesTypeRef = "global::System.Text.Json.Serialization.Metadata.JsonMetadataServices"; private const string JsonObjectInfoValuesTypeRef = "global::System.Text.Json.Serialization.Metadata.JsonObjectInfoValues"; private const string JsonParameterInfoValuesTypeRef = "global::System.Text.Json.Serialization.Metadata.JsonParameterInfoValues"; @@ -646,6 +648,14 @@ private string GenerateForObject(TypeGenerationSpec typeMetadata) {JsonTypeInfoReturnValueLocalVariableName} = {JsonMetadataServicesTypeRef}.CreateObjectInfo<{typeMetadata.TypeRef}>({OptionsLocalVariableName}, {ObjectInfoVarName});"; + if (typeMetadata.UnmappedMemberHandling != null) + { + objectInfoInitSource += $""" + + {JsonTypeInfoReturnValueLocalVariableName}.{UnmappedMemberHandlingPropName} = {GetUnmappedMemberHandlingAsStr(typeMetadata.UnmappedMemberHandling.Value)}; +"""; + } + string additionalSource = @$"{propMetadataInitFuncSource}{serializeFuncSource}{ctorParamMetadataInitFuncSource}"; return GenerateForType(typeMetadata, objectInfoInitSource, additionalSource); @@ -1392,6 +1402,9 @@ private static string GetNumberHandlingAsStr(JsonNumberHandling? numberHandling) ? $"({JsonNumberHandlingTypeRef}){(int)numberHandling.Value}" : "default"; + private static string GetUnmappedMemberHandlingAsStr(JsonUnmappedMemberHandling unmappedMemberHandling) => + $"({JsonUnmappedMemberHandlingTypeRef}){(int)unmappedMemberHandling}"; + private static string GetCreateValueInfoMethodRef(string typeCompilableName) => $"{CreateValueInfoMethodName}<{typeCompilableName}>"; private static string FormatBool(bool value) => value ? "true" : "false"; diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index f06442f07ed433..6b3ca7fe542964 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -38,6 +38,7 @@ private sealed class Parser private const string JsonIgnoreConditionFullName = "System.Text.Json.Serialization.JsonIgnoreCondition"; private const string JsonIncludeAttributeFullName = "System.Text.Json.Serialization.JsonIncludeAttribute"; private const string JsonNumberHandlingAttributeFullName = "System.Text.Json.Serialization.JsonNumberHandlingAttribute"; + private const string JsonUnmappedMemberHandlingAttributeFullName = "System.Text.Json.Serialization.JsonUnmappedMemberHandlingAttribute"; private const string JsonPropertyNameAttributeFullName = "System.Text.Json.Serialization.JsonPropertyNameAttribute"; private const string JsonPropertyOrderAttributeFullName = "System.Text.Json.Serialization.JsonPropertyOrderAttribute"; private const string JsonRequiredAttributeFullName = "System.Text.Json.Serialization.JsonRequiredAttribute"; @@ -706,6 +707,7 @@ private TypeGenerationSpec GetOrAddTypeGenerationSpec(Type type, JsonSourceGener List? propertyInitializerSpecList = null; CollectionType collectionType = CollectionType.NotApplicable; JsonNumberHandling? numberHandling = null; + JsonUnmappedMemberHandling? unmappedMemberHandling = null; bool foundDesignTimeCustomConverter = false; string? converterInstatiationLogic = null; bool implementsIJsonOnSerialized = false; @@ -727,6 +729,12 @@ private TypeGenerationSpec GetOrAddTypeGenerationSpec(Type type, JsonSourceGener numberHandling = (JsonNumberHandling)ctorArgs[0].Value!; continue; } + else if (attributeTypeFullName == JsonUnmappedMemberHandlingAttributeFullName) + { + IList ctorArgs = attributeData.ConstructorArguments; + unmappedMemberHandling = (JsonUnmappedMemberHandling)ctorArgs[0].Value!; + continue; + } else if (!foundDesignTimeCustomConverter && attributeType.GetCompatibleBaseClass(JsonConverterAttributeFullName) != null) { foundDesignTimeCustomConverter = true; @@ -1130,6 +1138,7 @@ void CacheMemberHelper(Location memberLocation) generationMode, classType, numberHandling, + unmappedMemberHandling, propGenSpecList, paramGenSpecArray, propertyInitializerSpecList, diff --git a/src/libraries/System.Text.Json/gen/System.Text.Json.SourceGeneration.targets b/src/libraries/System.Text.Json/gen/System.Text.Json.SourceGeneration.targets index 81ab875fdd87ab..6c977db11d6bbe 100644 --- a/src/libraries/System.Text.Json/gen/System.Text.Json.SourceGeneration.targets +++ b/src/libraries/System.Text.Json/gen/System.Text.Json.SourceGeneration.targets @@ -42,6 +42,7 @@ + diff --git a/src/libraries/System.Text.Json/gen/TypeGenerationSpec.cs b/src/libraries/System.Text.Json/gen/TypeGenerationSpec.cs index e8dfb07ac6bd12..b65b2862516476 100644 --- a/src/libraries/System.Text.Json/gen/TypeGenerationSpec.cs +++ b/src/libraries/System.Text.Json/gen/TypeGenerationSpec.cs @@ -62,6 +62,7 @@ public TypeGenerationSpec(Type type) public bool CanBeNull { get; private set; } public JsonNumberHandling? NumberHandling { get; private set; } + public JsonUnmappedMemberHandling? UnmappedMemberHandling { get; private set; } public List? PropertyGenSpecList { get; private set; } @@ -129,6 +130,7 @@ public void Initialize( JsonSourceGenerationMode generationMode, ClassType classType, JsonNumberHandling? numberHandling, + JsonUnmappedMemberHandling? unmappedMemberHandling, List? propertyGenSpecList, ParameterGenerationSpec[]? ctorParamGenSpecArray, List? propertyInitializerSpecList, @@ -153,6 +155,7 @@ public void Initialize( CanBeNull = !IsValueType || nullableUnderlyingTypeMetadata != null; IsPolymorphic = isPolymorphic; NumberHandling = numberHandling; + UnmappedMemberHandling = unmappedMemberHandling; PropertyGenSpecList = propertyGenSpecList; PropertyInitializerSpecList = propertyInitializerSpecList; CtorParamGenSpecArray = ctorParamGenSpecArray; diff --git a/src/libraries/System.Text.Json/ref/System.Text.Json.cs b/src/libraries/System.Text.Json/ref/System.Text.Json.cs index 24c19d8c55c108..81dd1d06194d10 100644 --- a/src/libraries/System.Text.Json/ref/System.Text.Json.cs +++ b/src/libraries/System.Text.Json/ref/System.Text.Json.cs @@ -385,6 +385,7 @@ public JsonSerializerOptions(System.Text.Json.JsonSerializerOptions options) { } public System.Text.Json.Serialization.ReferenceHandler? ReferenceHandler { get { throw null; } set { } } public System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver? TypeInfoResolver { get { throw null; } set { } } public System.Text.Json.Serialization.JsonUnknownTypeHandling UnknownTypeHandling { get { throw null; } set { } } + public System.Text.Json.Serialization.JsonUnmappedMemberHandling UnmappedMemberHandling { get { throw null; } set { } } public bool WriteIndented { get { throw null; } set { } } public void AddContext() where TContext : System.Text.Json.Serialization.JsonSerializerContext, new() { } [System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute("Getting a converter for a type may require reflection which depends on runtime code generation.")] @@ -1036,6 +1037,17 @@ public enum JsonUnknownTypeHandling JsonElement = 0, JsonNode = 1, } + public enum JsonUnmappedMemberHandling + { + Skip = 0, + Disallow = 1, + } + [System.AttributeUsageAttribute(System.AttributeTargets.Class | System.AttributeTargets.Interface | System.AttributeTargets.Struct, AllowMultiple=false, Inherited=false)] + public partial class JsonUnmappedMemberHandlingAttribute : System.Text.Json.Serialization.JsonAttribute + { + public JsonUnmappedMemberHandlingAttribute(System.Text.Json.Serialization.JsonUnmappedMemberHandling unmappedMemberHandling) { } + public System.Text.Json.Serialization.JsonUnmappedMemberHandling UnmappedMemberHandling { get { throw null; } } + } public abstract partial class ReferenceHandler { protected ReferenceHandler() { } @@ -1235,6 +1247,7 @@ internal JsonTypeInfo() { } public System.Text.Json.Serialization.Metadata.JsonPolymorphismOptions? PolymorphismOptions { get { throw null; } set { } } public System.Collections.Generic.IList Properties { get { throw null; } } public System.Type Type { get { throw null; } } + public System.Text.Json.Serialization.JsonUnmappedMemberHandling? UnmappedMemberHandling { get { throw null; } set { } } [System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications.")] [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications.")] public System.Text.Json.Serialization.Metadata.JsonPropertyInfo CreateJsonPropertyInfo(System.Type propertyType, string name) { throw null; } diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index f719671a08174f..9b7fa0faa03a87 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -360,6 +360,9 @@ The type '{0}' cannot have more than one member that has the attribute '{1}'. + + The type '{0}' is marked 'UnmappedMemberHandling.Strict' which conflicts with extension data property '{1}'. + The type '{0}' is not supported. @@ -479,6 +482,9 @@ The metadata property is either not supported by the type or is not the first property in the deserialized JSON object. + + The JSON property '{0}' could not be mapped to any .NET member contained in type '{1}'. + Deserialized object contains a duplicate type discriminator metadata property. diff --git a/src/libraries/System.Text.Json/src/System.Text.Json.csproj b/src/libraries/System.Text.Json/src/System.Text.Json.csproj index 32758d44325f7b..f43694170d27f1 100644 --- a/src/libraries/System.Text.Json/src/System.Text.Json.csproj +++ b/src/libraries/System.Text.Json/src/System.Text.Json.csproj @@ -33,6 +33,7 @@ The System.Text.Json library is built-in as part of the shared framework in .NET + @@ -104,6 +105,7 @@ The System.Text.Json library is built-in as part of the shared framework in .NET + diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonUnmappedMemberHandlingAttribute.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonUnmappedMemberHandlingAttribute.cs new file mode 100644 index 00000000000000..6004ea91d82ed3 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonUnmappedMemberHandlingAttribute.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Text.Json.Serialization +{ + /// + /// When placed on a type, determines the configuration + /// for the specific type, overriding the global setting. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, + AllowMultiple = false, Inherited = false)] + public class JsonUnmappedMemberHandlingAttribute : JsonAttribute + { + /// + /// Initializes a new instance of . + /// + public JsonUnmappedMemberHandlingAttribute(JsonUnmappedMemberHandling unmappedMemberHandling) + { + UnmappedMemberHandling = unmappedMemberHandling; + } + + /// + /// Specifies the unmapped member handling setting for the attribute. + /// + public JsonUnmappedMemberHandling UnmappedMemberHandling { get; } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs index 5f3d59a569f0ef..972a33db7d3470 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs @@ -638,7 +638,7 @@ internal sealed override void WriteAsPropertyNameCoreAsObject(Utf8JsonWriter wri // For consistency do not return any default converters for options instances linked to a // JsonSerializerContext, even if the default converters might have been rooted. - if (!IsInternalConverter && options.SerializerContext is null) + if (!IsInternalConverter && options.TypeInfoResolver is not JsonSerializerContext) { result = _fallbackConverterForPropertyNameSerialization; diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Helpers.cs index 5713cf079581df..b0141c05428d20 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Helpers.cs @@ -78,5 +78,8 @@ internal static bool IsValidNumberHandlingValue(JsonNumberHandling handling) => JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowNamedFloatingPointLiterals)); + + internal static bool IsValidUnmappedMemberHandlingValue(JsonUnmappedMemberHandling handling) => + handling is JsonUnmappedMemberHandling.Skip or JsonUnmappedMemberHandling.Disallow; } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandlePropertyName.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandlePropertyName.cs index c3d6916546ab4d..707997aff8d31f 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandlePropertyName.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandlePropertyName.cs @@ -24,17 +24,18 @@ internal static JsonPropertyInfo LookupProperty( out bool useExtensionProperty, bool createExtensionProperty = true) { + JsonTypeInfo jsonTypeInfo = state.Current.JsonTypeInfo; #if DEBUG - if (state.Current.JsonTypeInfo.Kind != JsonTypeInfoKind.Object) + if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object) { string objTypeName = obj?.GetType().FullName ?? ""; - Debug.Fail($"obj.GetType() => {objTypeName}; {state.Current.JsonTypeInfo.GetPropertyDebugInfo(unescapedPropertyName)}"); + Debug.Fail($"obj.GetType() => {objTypeName}; {jsonTypeInfo.GetPropertyDebugInfo(unescapedPropertyName)}"); } #endif useExtensionProperty = false; - JsonPropertyInfo jsonPropertyInfo = state.Current.JsonTypeInfo.GetProperty( + JsonPropertyInfo jsonPropertyInfo = jsonTypeInfo.GetProperty( unescapedPropertyName, ref state.Current, out byte[] utf8PropertyName); @@ -45,11 +46,18 @@ internal static JsonPropertyInfo LookupProperty( // For case insensitive and missing property support of JsonPath, remember the value on the temporary stack. state.Current.JsonPropertyName = utf8PropertyName; - // Determine if we should use the extension property. + // Handle missing properties if (jsonPropertyInfo == JsonPropertyInfo.s_missingProperty) { - JsonPropertyInfo? dataExtProperty = state.Current.JsonTypeInfo.ExtensionDataProperty; - if (dataExtProperty != null && dataExtProperty.HasGetter && dataExtProperty.HasSetter) + if (jsonTypeInfo.EffectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + { + Debug.Assert(jsonTypeInfo.ExtensionDataProperty is null, "jsonTypeInfo.Configure() should have caught conflicting configuration."); + string stringPropertyName = JsonHelpers.Utf8GetString(unescapedPropertyName); + ThrowHelper.ThrowJsonException_UnmappedJsonProperty(jsonTypeInfo.Type, stringPropertyName); + } + + // Determine if we should use the extension property. + if (jsonTypeInfo.ExtensionDataProperty is JsonPropertyInfo { HasGetter: true, HasSetter: true } dataExtProperty) { state.Current.JsonPropertyNameAsString = JsonHelpers.Utf8GetString(unescapedPropertyName); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Caching.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Caching.cs index f4e02f566e5b42..ceca2369a07f02 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Caching.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.Caching.cs @@ -291,6 +291,7 @@ public bool Equals(JsonSerializerOptions? left, JsonSerializerOptions? right) left._defaultIgnoreCondition == right._defaultIgnoreCondition && left._numberHandling == right._numberHandling && left._unknownTypeHandling == right._unknownTypeHandling && + left._unmappedMemberHandling == right._unmappedMemberHandling && left._defaultBufferSize == right._defaultBufferSize && left._maxDepth == right._maxDepth && left._allowTrailingCommas == right._allowTrailingCommas && @@ -336,6 +337,7 @@ public int GetHashCode(JsonSerializerOptions options) AddHashCode(ref hc, options._defaultIgnoreCondition); AddHashCode(ref hc, options._numberHandling); AddHashCode(ref hc, options._unknownTypeHandling); + AddHashCode(ref hc, options._unmappedMemberHandling); AddHashCode(ref hc, options._defaultBufferSize); AddHashCode(ref hc, options._maxDepth); AddHashCode(ref hc, options._allowTrailingCommas); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs index f58bfa024be3c8..cfc36739317afe 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs @@ -62,6 +62,7 @@ public static JsonSerializerOptions Default private JsonIgnoreCondition _defaultIgnoreCondition; private JsonNumberHandling _numberHandling; private JsonUnknownTypeHandling _unknownTypeHandling; + private JsonUnmappedMemberHandling _unmappedMemberHandling; private int _defaultBufferSize = BufferSizeDefault; private int _maxDepth; @@ -107,6 +108,7 @@ public JsonSerializerOptions(JsonSerializerOptions options) _defaultIgnoreCondition = options._defaultIgnoreCondition; _numberHandling = options._numberHandling; _unknownTypeHandling = options._unknownTypeHandling; + _unmappedMemberHandling = options._unmappedMemberHandling; _defaultBufferSize = options._defaultBufferSize; _maxDepth = options._maxDepth; @@ -550,6 +552,20 @@ public JsonUnknownTypeHandling UnknownTypeHandling } } + /// + /// Determines how handles JSON properties that + /// cannot be mapped to a specific .NET member when deserializing object types. + /// + public JsonUnmappedMemberHandling UnmappedMemberHandling + { + get => _unmappedMemberHandling; + set + { + VerifyMutable(); + _unmappedMemberHandling = value; + } + } + /// /// Defines whether JSON should pretty print which includes: /// indenting nested JSON tokens, adding new lines, and adding white space between property names and values. @@ -585,14 +601,12 @@ public ReferenceHandler? ReferenceHandler } } - internal JsonSerializerContext? SerializerContext => _typeInfoResolver as JsonSerializerContext; - internal bool CanUseFastPathSerializationLogic { get { Debug.Assert(IsReadOnly); - return _canUseFastPathSerializationLogic ??= SerializerContext?.CanUseFastPathSerializationLogic(this) ?? false; + return _canUseFastPathSerializationLogic ??= _typeInfoResolver is JsonSerializerContext ctx ? ctx.CanUseFastPathSerializationLogic(this) : false; } } 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 c752e30292c6f5..a73e1c23cf99b9 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 @@ -289,11 +289,6 @@ internal void EnsureConfigured() internal void Configure() { - Debug.Assert(ParentTypeInfo != null, "We should have ensured parent is assigned in JsonTypeInfo"); - Debug.Assert(!ParentTypeInfo.IsConfigured); - - DeclaringTypeNumberHandling = ParentTypeInfo.NumberHandling; - if (!IsForTypeInfo) { CacheNameAsUtf8BytesAndEscapedNameSection(); @@ -443,7 +438,12 @@ private void DetermineSerializationCapabilities() private void DetermineNumberHandlingForTypeInfo() { - if (DeclaringTypeNumberHandling != null && DeclaringTypeNumberHandling != JsonNumberHandling.Strict && !EffectiveConverter.IsInternalConverter) + Debug.Assert(ParentTypeInfo != null, "We should have ensured parent is assigned in JsonTypeInfo"); + Debug.Assert(!ParentTypeInfo.IsConfigured); + + JsonNumberHandling? declaringTypeNumberHandling = ParentTypeInfo.NumberHandling; + + if (declaringTypeNumberHandling != null && declaringTypeNumberHandling != JsonNumberHandling.Strict && !EffectiveConverter.IsInternalConverter) { ThrowHelper.ThrowInvalidOperationException_NumberHandlingOnPropertyInvalid(this); } @@ -454,7 +454,7 @@ private void DetermineNumberHandlingForTypeInfo() // custom collections e.g. public class MyNumberList : List. // Priority 1: Get handling from the type (parent type in this case is the type itself). - EffectiveNumberHandling = DeclaringTypeNumberHandling; + EffectiveNumberHandling = declaringTypeNumberHandling; // Priority 2: Get handling from JsonSerializerOptions instance. if (!EffectiveNumberHandling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) @@ -466,6 +466,7 @@ private void DetermineNumberHandlingForTypeInfo() private void DetermineNumberHandlingForProperty() { + Debug.Assert(ParentTypeInfo != null, "We should have ensured parent is assigned in JsonTypeInfo"); Debug.Assert(!IsConfigured, "Should not be called post-configuration."); Debug.Assert(_jsonTypeInfo != null, "Must have already been determined on configuration."); @@ -474,7 +475,7 @@ private void DetermineNumberHandlingForProperty() if (numberHandlingIsApplicable) { // Priority 1: Get handling from attribute on property/field, its parent class type or property type. - JsonNumberHandling? handling = NumberHandling ?? DeclaringTypeNumberHandling ?? _jsonTypeInfo.NumberHandling; + JsonNumberHandling? handling = NumberHandling ?? ParentTypeInfo.NumberHandling ?? _jsonTypeInfo.NumberHandling; // Priority 2: Get handling from JsonSerializerOptions instance. if (!handling.HasValue && Options.NumberHandling != JsonNumberHandling.Strict) @@ -825,11 +826,6 @@ internal JsonTypeInfo JsonTypeInfo /// internal bool SrcGen_IsPublic { get; set; } - /// - /// Number handling for declaring type - /// - internal JsonNumberHandling? DeclaringTypeNumberHandling { get; set; } - /// /// Gets or sets the applied to the current property. /// 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 dc020c39b3e9d7..c1c45de3638095 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 @@ -446,9 +446,12 @@ internal JsonTypeInfo? KeyTypeInfo /// /// The instance has been locked for further modification. /// + /// + /// Specified an invalid value. + /// /// /// For contracts originating from or , - /// the value of this callback will be mapped from any annotations. + /// the value of this callback will be mapped from any annotations. /// public JsonNumberHandling? NumberHandling { @@ -456,12 +459,59 @@ public JsonNumberHandling? NumberHandling set { VerifyMutable(); + + if (value is not null && !JsonSerializer.IsValidNumberHandlingValue(value.Value)) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _numberHandling = value; } } private JsonNumberHandling? _numberHandling; + /// + /// Gets or sets the type-level override. + /// + /// + /// The instance has been locked for further modification. + /// + /// -or- + /// + /// Unmapped member handling only supported for . + /// + /// + /// Specified an invalid value. + /// + /// + /// For contracts originating from or , + /// the value of this callback will be mapped from any annotations. + /// + public JsonUnmappedMemberHandling? UnmappedMemberHandling + { + get => _unmappedMemberHandling; + set + { + VerifyMutable(); + + if (Kind != JsonTypeInfoKind.Object) + { + ThrowHelper.ThrowInvalidOperationException_JsonTypeInfoOperationNotPossibleForKind(Kind); + } + + if (value is not null && !JsonSerializer.IsValidUnmappedMemberHandlingValue(value.Value)) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _unmappedMemberHandling = value; + } + } + + private JsonUnmappedMemberHandling? _unmappedMemberHandling; + internal JsonUnmappedMemberHandling EffectiveUnmappedMemberHandling => _unmappedMemberHandling ?? Options.UnmappedMemberHandling; + internal JsonTypeInfo(Type type, JsonConverter converter, JsonSerializerOptions options) { Type = type; @@ -565,7 +615,7 @@ internal void Configure() if (converter.ConstructorIsParameterized) { - InitializeConstructorParameters(GetParameterInfoValues(), sourceGenMode: Options.SerializerContext != null); + InitializeConstructorParameters(GetParameterInfoValues(), sourceGenMode: Options.TypeInfoResolver is JsonSerializerContext); } } @@ -785,6 +835,11 @@ internal void CacheMember(JsonPropertyInfo jsonPropertyInfo, JsonPropertyDiction if (jsonPropertyInfo.IsExtensionData) { + if (EffectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + { + ThrowHelper.ThrowInvalidOperationException_ExtensionDataConflictsWithUnmappedMemberHandling(Type, jsonPropertyInfo); + } + if (ExtensionDataProperty != null) { ThrowHelper.ThrowInvalidOperationException_SerializationDuplicateTypeAttribute(Type, typeof(JsonExtensionDataAttribute)); @@ -907,6 +962,11 @@ internal void InitializePropertyCache() { if (property.IsExtensionData) { + if (EffectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + { + ThrowHelper.ThrowInvalidOperationException_ExtensionDataConflictsWithUnmappedMemberHandling(Type, property); + } + if (ExtensionDataProperty != null) { ThrowHelper.ThrowInvalidOperationException_SerializationDuplicateTypeAttribute(Type, typeof(JsonExtensionDataAttribute)); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionJsonTypeInfoOfT.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionJsonTypeInfoOfT.cs index fee942254b8015..57143057dbec7a 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionJsonTypeInfoOfT.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/ReflectionJsonTypeInfoOfT.cs @@ -20,6 +20,11 @@ internal ReflectionJsonTypeInfo(JsonConverter converter, JsonSerializerOptions o : base(converter, options) { NumberHandling = GetNumberHandlingForType(Type); + if (Kind == JsonTypeInfoKind.Object) + { + UnmappedMemberHandling = GetUnmappedMemberHandling(Type); + } + PopulatePolymorphismMetadata(); MapInterfaceTypesToCallbacks(); @@ -232,6 +237,12 @@ private void CacheMember( return numberHandlingAttribute?.Handling; } + private static JsonUnmappedMemberHandling? GetUnmappedMemberHandling(Type type) + { + JsonUnmappedMemberHandlingAttribute? numberHandlingAttribute = type.GetUniqueCustomAttribute(inherit: false); + return numberHandlingAttribute?.UnmappedMemberHandling; + } + private static bool PropertyIsOverriddenAndIgnored( string currentMemberName, Type currentMemberType, diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/SourceGenJsonTypeInfoOfT.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/SourceGenJsonTypeInfoOfT.cs index e16b1592bf6f5b..c3e28a729e0271 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/SourceGenJsonTypeInfoOfT.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/SourceGenJsonTypeInfoOfT.cs @@ -129,7 +129,7 @@ internal override void LateAddProperties() return; } - JsonSerializerContext? context = Options.SerializerContext; + JsonSerializerContext? context = Options.TypeInfoResolver as JsonSerializerContext; JsonPropertyInfo[] array; if (PropInitFunc == null || (array = PropInitFunc(context!)) == null) { diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs index b1e3627eccbb66..6fe76016a365dc 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/ThrowHelper.Serialization.cs @@ -463,6 +463,12 @@ public static void ThrowInvalidOperationException_SerializationDuplicateTypeAttr throw new InvalidOperationException(SR.Format(SR.SerializationDuplicateTypeAttribute, classType, typeof(TAttribute))); } + [DoesNotReturn] + public static void ThrowInvalidOperationException_ExtensionDataConflictsWithUnmappedMemberHandling(Type classType, JsonPropertyInfo jsonPropertyInfo) + { + throw new InvalidOperationException(SR.Format(SR.ExtensionDataConflictsWithUnmappedMemberHandling, classType, jsonPropertyInfo.MemberName)); + } + [DoesNotReturn] public static void ThrowInvalidOperationException_SerializationDataExtensionPropertyInvalid(JsonPropertyInfo jsonPropertyInfo) { @@ -590,6 +596,12 @@ public static void ThrowJsonException_MetadataUnexpectedProperty(ReadOnlySpan public abstract partial class JsonSerializerWrapper { + /// + /// Either JsonSerializerOptions.Default for reflection or the JsonSerializerContext.Options for source gen. + /// public abstract JsonSerializerOptions DefaultOptions { get; } /// @@ -39,6 +42,17 @@ public abstract partial class JsonSerializerWrapper public abstract Task DeserializeWrapper(string json, Type type, JsonSerializerContext context); + + public JsonTypeInfo GetTypeInfo(Type type, bool mutable = false) + { + JsonSerializerOptions defaultOptions = DefaultOptions; + // return a fresh mutable instance or the cached readonly metadata + return mutable ? defaultOptions.TypeInfoResolver.GetTypeInfo(type, defaultOptions) : defaultOptions.GetTypeInfo(type); + } + + public JsonTypeInfo GetTypeInfo(bool mutable = false) + => (JsonTypeInfo)GetTypeInfo(typeof(T), mutable); + public JsonSerializerOptions GetDefaultOptionsWithMetadataModifier(Action modifier) { JsonSerializerOptions defaultOptions = DefaultOptions; diff --git a/src/libraries/System.Text.Json/tests/Common/JsonTestHelper.cs b/src/libraries/System.Text.Json/tests/Common/JsonTestHelper.cs index 56656e180530de..f95fb43c4d5d7e 100644 --- a/src/libraries/System.Text.Json/tests/Common/JsonTestHelper.cs +++ b/src/libraries/System.Text.Json/tests/Common/JsonTestHelper.cs @@ -118,6 +118,63 @@ static string BuildJsonPath(Stack path) } } + /// + /// Linq Cartesian product + /// + public static IEnumerable<(TFirst First, TSecond Second)> CrossJoin(this IEnumerable first, IEnumerable second) + { + TSecond[]? secondCached = null; + foreach (TFirst f in first) + { + secondCached ??= second.ToArray(); + foreach (TSecond s in secondCached) + { + yield return (f, s); + } + } + } + + /// + /// Linq Cartesian product + /// + public static IEnumerable<(TFirst First, TSecond Second, TThird Third)> CrossJoin( + this IEnumerable first, + IEnumerable second, + IEnumerable third) + { + TSecond[]? secondCached = null; + TThird[]? thirdCached = null; + + foreach (TFirst f in first) + { + secondCached ??= second.ToArray(); + foreach (TSecond s in secondCached) + { + thirdCached ??= third.ToArray(); + foreach (TThird t in thirdCached) + { + yield return (f, s, t); + } + } + } + } + + /// + /// Linq Cartesian product + /// + public static IEnumerable CrossJoin(this IEnumerable first, IEnumerable second, Func resultSelector) + => first.CrossJoin(second).Select(tuple => resultSelector(tuple.First, tuple.Second)); + + /// + /// Linq Cartesian product + /// + public static IEnumerable CrossJoin( + this IEnumerable first, + IEnumerable second, + IEnumerable third, + Func resultSelector) + => first.CrossJoin(second, third).Select(tuple => resultSelector(tuple.First, tuple.Second, tuple.Third)); + public static async Task> ToListAsync(this IAsyncEnumerable source) { var list = new List(); diff --git a/src/libraries/System.Text.Json/tests/Common/UnmappedMemberHandlingTests.cs b/src/libraries/System.Text.Json/tests/Common/UnmappedMemberHandlingTests.cs new file mode 100644 index 00000000000000..e6554472d1d434 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/Common/UnmappedMemberHandlingTests.cs @@ -0,0 +1,220 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization.Metadata; +using System.Threading.Tasks; +using Xunit; + +namespace System.Text.Json.Serialization.Tests +{ + public abstract class UnmappedMemberHandlingTests : SerializerTests + { + public UnmappedMemberHandlingTests(JsonSerializerWrapper serializer) : base(serializer) + { } + + [Theory] + [MemberData(nameof(SkipHandling_JsonWithoutUnmappedMembers_MemberData))] + public async Task SkipHandling_JsonWithoutUnmappedMembers_Succeeds(TypeConfiguration typeConfig, JsonInput jsonInput) + { + JsonTypeInfo typeInfo = ResolveTypeInfo(typeConfig); + + object result = await Serializer.DeserializeWrapper(jsonInput.json, typeInfo); + IPoco poco = Assert.IsAssignableFrom(result); + Assert.Equal(jsonInput.expectedId, poco.Id); + } + + public static IEnumerable SkipHandling_JsonWithoutUnmappedMembers_MemberData() + => GetAllTestConfigurations() + .Where(x => x.typeConfig.ExpectedUnmappedMemberHandling is JsonUnmappedMemberHandling.Skip) + .Where(x => !x.jsonInput.containsUnmappedMember) + .Select(x => new object[] { x.typeConfig, x.jsonInput }); + + [Theory] + [MemberData(nameof(SkipHandling_JsonWithUnmappedMembers_MemberData))] + public async Task SkipHandling_JsonWithUnmappedMembers_Succeeds(TypeConfiguration typeConfig, JsonInput jsonInput) + { + JsonTypeInfo typeInfo = ResolveTypeInfo(typeConfig); + + object result = await Serializer.DeserializeWrapper(jsonInput.json, typeInfo); + + IPoco poco = Assert.IsAssignableFrom(result); + Assert.Equal(jsonInput.expectedId, poco.Id); + } + + public static IEnumerable SkipHandling_JsonWithUnmappedMembers_MemberData() + => GetAllTestConfigurations() + .Where(x => x.typeConfig.ExpectedUnmappedMemberHandling is JsonUnmappedMemberHandling.Skip) + .Where(x => x.jsonInput.containsUnmappedMember) + .Select(x => new object[] { x.typeConfig, x.jsonInput }); + + [Theory] + [MemberData(nameof(DisallowHandling_JsonWithoutUnmappedMembers_MemberData))] + public async Task DisallowHandling_JsonWithoutUnmappedMembers_Succeeds(TypeConfiguration typeConfig, JsonInput jsonInput) + { + JsonTypeInfo typeInfo = ResolveTypeInfo(typeConfig); + + object result = await Serializer.DeserializeWrapper(jsonInput.json, typeInfo); + IPoco poco = Assert.IsAssignableFrom(result); + Assert.Equal(jsonInput.expectedId, poco.Id); + } + + public static IEnumerable DisallowHandling_JsonWithoutUnmappedMembers_MemberData() + => GetAllTestConfigurations() + .Where(x => x.typeConfig.ExpectedUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + .Where(x => !x.jsonInput.containsUnmappedMember) + .Select(x => new object[] { x.typeConfig, x.jsonInput }); + + [Theory] + [MemberData(nameof(DisallowHandling_JsonWithUnmappedMembers_MemberData))] + public async Task DisallowHandling_JsonWithUnmappedMembers_ThrowsJsonException(TypeConfiguration typeConfig, JsonInput jsonInput) + { + JsonTypeInfo typeInfo = ResolveTypeInfo(typeConfig); + await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper(jsonInput.json, typeInfo)); + } + + public static IEnumerable DisallowHandling_JsonWithUnmappedMembers_MemberData() + => GetAllTestConfigurations() + .Where(x => x.typeConfig.ExpectedUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + .Where(x => x.jsonInput.containsUnmappedMember) + .Select(x => new object[] { x.typeConfig, x.jsonInput }); + + [Theory] + [MemberData(nameof(JsonTypeInfo_ReturnsExpectedUnmappedMemberHandling_MemberData))] + public void JsonTypeInfo_ReturnsExpectedUnmappedMemberHandling(TypeConfiguration typeConfig) + { + JsonTypeInfo typeInfo = ResolveTypeInfo(typeConfig); + + Assert.Equal(typeConfig.contractCustomizationOverride ?? typeConfig.attributeAnnotation, typeInfo.UnmappedMemberHandling); + } + + public static IEnumerable JsonTypeInfo_ReturnsExpectedUnmappedMemberHandling_MemberData() + => GetTypeConfigurations().Select(x => new object[] { x }); + + private JsonTypeInfo ResolveTypeInfo(TypeConfiguration typeConfig) + { + var options = new JsonSerializerOptions(Serializer.DefaultOptions) { UnmappedMemberHandling = typeConfig.globalHandling }; + JsonTypeInfo typeInfo = options.GetTypeInfo(typeConfig.type); + + if (typeConfig.contractCustomizationOverride != null) + { + typeInfo.UnmappedMemberHandling = typeConfig.contractCustomizationOverride.Value; + } + + return typeInfo; + } + + #region Test Case Generators + + public record struct TypeConfiguration( + Type type, + JsonUnmappedMemberHandling globalHandling, + JsonUnmappedMemberHandling? attributeAnnotation, + JsonUnmappedMemberHandling? contractCustomizationOverride) + { + public JsonUnmappedMemberHandling ExpectedUnmappedMemberHandling => contractCustomizationOverride ?? attributeAnnotation ?? globalHandling; + } + + public record struct JsonInput(string json, bool containsUnmappedMember = false, int expectedId = 0); + + private static IEnumerable<(TypeConfiguration typeConfig, JsonInput jsonInput)> GetAllTestConfigurations() + => GetTypeConfigurations().CrossJoin(GetJsonInputs()); + + private static IEnumerable GetTypeConfigurations() + => GetTypesAndAttributeAnnotations().CrossJoin( + GetGlobalUnmappedMemberConfigurations(), + GetContractCustomizationOverrides(), + static (tc, globalConfig, contractOverride) => new TypeConfiguration(tc.type, globalConfig, tc.attributeAnnotation, contractOverride)); + + private static IEnumerable GetGlobalUnmappedMemberConfigurations() + => new[] { JsonUnmappedMemberHandling.Skip, JsonUnmappedMemberHandling.Disallow }; + + private static IEnumerable GetContractCustomizationOverrides() + => new JsonUnmappedMemberHandling?[] { null, JsonUnmappedMemberHandling.Skip, JsonUnmappedMemberHandling.Disallow }; + + private static IEnumerable<(Type type, JsonUnmappedMemberHandling? attributeAnnotation)> GetTypesAndAttributeAnnotations() + { + yield return (typeof(PocoWithoutAnnotations), null); + yield return (typeof(PocoWithSkipAnnotation), JsonUnmappedMemberHandling.Skip); + yield return (typeof(PocoWithDisallowAnnotation), JsonUnmappedMemberHandling.Disallow); + yield return (typeof(PocoInheritingDisallowAnnotation), null); + } + + private static IEnumerable GetJsonInputs() + { + yield return new("""{}"""); + yield return new("""{"Id": 42}""", expectedId: 42); + yield return new("""{"UnmappedProperty" : null}""", containsUnmappedMember: true); + yield return new("""{"Id": 42, "UnmappedProperty" : null}""", containsUnmappedMember: true, expectedId: 42); + yield return new("""{"UnmappedMember" : null, "Id": 42}""", containsUnmappedMember: true, expectedId: 42); + } + + public interface IPoco + { + int Id { get; set; } + } + + public class PocoWithoutAnnotations : IPoco + { + public int Id { get; set; } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Skip)] + public class PocoWithSkipAnnotation : IPoco + { + public int Id { get; set; } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public class PocoWithDisallowAnnotation : IPoco + { + public int Id { get; set; } + } + + public class PocoInheritingDisallowAnnotation : PocoWithDisallowAnnotation + { + } + #endregion + + #region JsonExtensionData Interop + + [Fact] + public async Task ClassWithExtensionData_GlobalDisallowHandling_ThrowsInvalidOperationException() + { + var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; + await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper("{}", options)); + } + + [Fact] + public async Task ClassWithExtensionDataAndDisallowHandling_ThrowsInvalidOperationException() + { + await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper("{}")); + } + + [Fact] + public async Task ClassWithExtensionDataAndDisallowHandling_DisableUnmappedMemberHandling_Succeeds() + { + JsonTypeInfo typeInfo = Serializer.GetTypeInfo(mutable: true); + + typeInfo.UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip; + ClassWithExtensionDataAndDisallowHandling result = await Serializer.DeserializeWrapper("""{"ExtensionData":{}}""", typeInfo); + + Assert.NotNull(result.ExtensionData); + } + + public class ClassWithExtensionData + { + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public class ClassWithExtensionDataAndDisallowHandling + { + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + #endregion + } +} diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnmappedMemberHandlingTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnmappedMemberHandlingTests.cs new file mode 100644 index 00000000000000..c0926a084c4cd2 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/UnmappedMemberHandlingTests.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Tests; + +namespace System.Text.Json.SourceGeneration.Tests +{ + public sealed partial class UnmappedMemberHandlingTests_Metadata_String : UnmappedMemberHandlingTests + { + public UnmappedMemberHandlingTests_Metadata_String() + : base(new StringSerializerWrapper(UnmappedMemberHandlingTestsContext.Default)) + { + } + + [JsonSerializable(typeof(PocoWithoutAnnotations))] + [JsonSerializable(typeof(PocoWithSkipAnnotation))] + [JsonSerializable(typeof(PocoWithDisallowAnnotation))] + [JsonSerializable(typeof(PocoInheritingDisallowAnnotation))] + [JsonSerializable(typeof(ClassWithExtensionData))] + [JsonSerializable(typeof(ClassWithExtensionDataAndDisallowHandling))] + public partial class UnmappedMemberHandlingTestsContext : JsonSerializerContext + { } + } + + public sealed class UnmappedMemberHandlingTests_Metadata_Async : UnmappedMemberHandlingTests + { + public UnmappedMemberHandlingTests_Metadata_Async() + : base(new AsyncStreamSerializerWrapper(UnmappedMemberHandlingTests_Metadata_String.UnmappedMemberHandlingTestsContext.Default)) + { + } + } +} diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets index cce811001f17b3..a569e0355b753d 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/System.Text.Json.SourceGeneration.Tests.targets @@ -60,6 +60,7 @@ + @@ -98,6 +99,7 @@ + diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CacheTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CacheTests.cs index e53b33c3b4dda8..673bafa651506a 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CacheTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CacheTests.cs @@ -361,6 +361,7 @@ public static void JsonSerializerOptions_EqualityComparer_ChangingAnySettingShou yield return (GetProp(nameof(JsonSerializerOptions.IgnoreNullValues)), true); yield return (GetProp(nameof(JsonSerializerOptions.DefaultIgnoreCondition)), JsonIgnoreCondition.WhenWritingDefault); yield return (GetProp(nameof(JsonSerializerOptions.NumberHandling)), JsonNumberHandling.AllowReadingFromString); + yield return (GetProp(nameof(JsonSerializerOptions.UnmappedMemberHandling)), JsonUnmappedMemberHandling.Disallow); yield return (GetProp(nameof(JsonSerializerOptions.IgnoreReadOnlyProperties)), true); yield return (GetProp(nameof(JsonSerializerOptions.IgnoreReadOnlyFields)), true); yield return (GetProp(nameof(JsonSerializerOptions.IncludeFields)), true); diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/MetadataTests/DefaultJsonTypeInfoResolverTests.JsonTypeInfo.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/MetadataTests/DefaultJsonTypeInfoResolverTests.JsonTypeInfo.cs index 03992b2767cc35..f172260cbf9790 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/MetadataTests/DefaultJsonTypeInfoResolverTests.JsonTypeInfo.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/MetadataTests/DefaultJsonTypeInfoResolverTests.JsonTypeInfo.cs @@ -219,6 +219,16 @@ public static void TypeInfoKindNoneNumberHandling() Assert.Equal(testObj.IntProp, deserialized.IntProp); } + [Theory] + [InlineData((JsonNumberHandling)(-1))] + [InlineData((JsonNumberHandling)8)] + [InlineData((JsonNumberHandling)int.MaxValue)] + public static void NumberHandling_SetInvalidValue_ThrowsArgumentOutOfRangeException(JsonNumberHandling handling) + { + JsonTypeInfo jsonTypeInfo = JsonTypeInfo.CreateJsonTypeInfo(typeof(Poco), new()); + Assert.Throws(() => jsonTypeInfo.NumberHandling = handling); + } + [Theory] [InlineData(typeof(List), JsonTypeInfoKind.Enumerable)] [InlineData(typeof(Dictionary), JsonTypeInfoKind.Dictionary)] @@ -408,6 +418,7 @@ private static void TestTypeInfoImmutability(JsonTypeInfo typeInfo) Assert.Throws(() => typeInfo.CreateObject = typeInfo.CreateObject); Assert.Throws(() => typeInfo.NumberHandling = typeInfo.NumberHandling); Assert.Throws(() => typeInfo.CreateJsonPropertyInfo(typeof(string), "foo")); + Assert.Throws(() => typeInfo.UnmappedMemberHandling = typeInfo.UnmappedMemberHandling); Assert.Throws(() => typeInfo.Properties.Clear()); Assert.Throws(() => typeInfo.PolymorphismOptions = null); Assert.Throws(() => typeInfo.PolymorphismOptions = new()); @@ -1386,5 +1397,39 @@ public class ClassWithCallBacks : public void OnDeserializing() => IsOnDeserializingInvocations++; public void OnDeserialized() => IsOnDeserializedInvocations++; } + + [Theory] + [InlineData(null)] + [InlineData(JsonUnmappedMemberHandling.Skip)] + [InlineData(JsonUnmappedMemberHandling.Disallow)] + public static void UnmappedMemberHandling_ShouldGetSetValue(JsonUnmappedMemberHandling? handling) + { + JsonTypeInfo jsonTypeInfo = JsonTypeInfo.CreateJsonTypeInfo(typeof(Poco), JsonSerializerOptions.Default); + jsonTypeInfo.UnmappedMemberHandling = handling; + Assert.Equal(handling, jsonTypeInfo.UnmappedMemberHandling); + JsonSerializer.Serialize(new Poco(), jsonTypeInfo); + Assert.Equal(handling, jsonTypeInfo.UnmappedMemberHandling); + } + + [Theory] + [InlineData((JsonUnmappedMemberHandling)(-1))] + [InlineData((JsonUnmappedMemberHandling)2)] + [InlineData((JsonUnmappedMemberHandling)int.MaxValue)] + public static void UnmappedMemberHandling_SetInvalidValue_ThrowsArgumentOutOfRangeException(JsonUnmappedMemberHandling handling) + { + JsonTypeInfo jsonTypeInfo = JsonTypeInfo.CreateJsonTypeInfo(typeof(Poco), new()); + Assert.Throws(() => jsonTypeInfo.UnmappedMemberHandling = handling); + } + + [Theory] + [InlineData(typeof(int))] + [InlineData(typeof(string))] + [InlineData(typeof(int[]))] + [InlineData(typeof(Dictionary))] + public static void UnmappedMemberHandling_InvalidMetadataKind_ThrowsInvalidOperationException(Type type) + { + JsonTypeInfo jsonTypeInfo = JsonTypeInfo.CreateJsonTypeInfo(type, new()); + Assert.Throws(() => jsonTypeInfo.UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip); + } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs index 1c85fcfe105bcc..7a653a4c00c62f 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs @@ -61,6 +61,7 @@ public static void SetOptionsFail() Assert.False(options.PropertyNameCaseInsensitive); Assert.Null(options.PropertyNamingPolicy); Assert.Equal(JsonCommentHandling.Disallow, options.ReadCommentHandling); + Assert.Equal(JsonUnmappedMemberHandling.Skip, options.UnmappedMemberHandling); Assert.False(options.WriteIndented); TestIListNonThrowingOperationsWhenImmutable(options.Converters, tc); @@ -76,6 +77,7 @@ public static void SetOptionsFail() Assert.Throws(() => options.PropertyNameCaseInsensitive = options.PropertyNameCaseInsensitive); Assert.Throws(() => options.PropertyNamingPolicy = options.PropertyNamingPolicy); Assert.Throws(() => options.ReadCommentHandling = options.ReadCommentHandling); + Assert.Throws(() => options.UnmappedMemberHandling = options.UnmappedMemberHandling); Assert.Throws(() => options.WriteIndented = options.WriteIndented); Assert.Throws(() => options.TypeInfoResolver = options.TypeInfoResolver); @@ -883,6 +885,7 @@ and not nameof(JsonSerializerOptions.IsReadOnly)) // Property is not structural options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault; options.NumberHandling = JsonNumberHandling.AllowReadingFromString; options.UnknownTypeHandling = JsonUnknownTypeHandling.JsonNode; + options.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow; } else { @@ -901,18 +904,9 @@ private static void VerifyOptionsEqual(JsonSerializerOptions options, JsonSerial { Type propertyType = property.PropertyType; - if (propertyType == typeof(bool)) - { - if (property.Name == nameof(JsonSerializerOptions.IsReadOnly)) - { - break; // readonly-ness is not a structural property of JsonSerializerOptions. - } - - Assert.Equal((bool)property.GetValue(options), (bool)property.GetValue(newOptions)); - } - else if (propertyType == typeof(int)) + if (property.Name == nameof(JsonSerializerOptions.IsReadOnly)) { - Assert.Equal((int)property.GetValue(options), (int)property.GetValue(newOptions)); + break; // readonly-ness is not a structural property of JsonSerializerOptions. } else if (propertyType == typeof(IList)) { @@ -927,26 +921,7 @@ private static void VerifyOptionsEqual(JsonSerializerOptions options, JsonSerial } else if (propertyType.IsValueType) { - if (property.Name == nameof(JsonSerializerOptions.ReadCommentHandling)) - { - Assert.Equal(options.ReadCommentHandling, newOptions.ReadCommentHandling); - } - else if (property.Name == nameof(JsonSerializerOptions.DefaultIgnoreCondition)) - { - Assert.Equal(options.DefaultIgnoreCondition, newOptions.DefaultIgnoreCondition); - } - else if (property.Name == nameof(JsonSerializerOptions.NumberHandling)) - { - Assert.Equal(options.NumberHandling, newOptions.NumberHandling); - } - else if (property.Name == nameof(JsonSerializerOptions.UnknownTypeHandling)) - { - Assert.Equal(options.UnknownTypeHandling, newOptions.UnknownTypeHandling); - } - else - { - Assert.True(false, $"Public option was added to JsonSerializerOptions but not copied in the copy ctor: {property.Name}"); - } + Assert.Equal(property.GetValue(options), property.GetValue(newOptions)); } else { diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/UnmappedMemberHandlingTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/UnmappedMemberHandlingTests.cs new file mode 100644 index 00000000000000..2b532b6e6f44b1 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/UnmappedMemberHandlingTests.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Text.Json.Serialization.Tests +{ + public sealed partial class UnmappedMemberHandlingTests_String : UnmappedMemberHandlingTests + { + public UnmappedMemberHandlingTests_String() : base(JsonSerializerWrapper.StringSerializer) { } + } + + public sealed partial class UnmappedMemberHandlingTests_AsyncStream : UnmappedMemberHandlingTests + { + public UnmappedMemberHandlingTests_AsyncStream() : base(JsonSerializerWrapper.AsyncStreamSerializer) { } + } +} diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/System.Text.Json.Tests.csproj b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/System.Text.Json.Tests.csproj index 4d09a6c19547f5..50afc6ecda997c 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/System.Text.Json.Tests.csproj +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/System.Text.Json.Tests.csproj @@ -62,6 +62,7 @@ + @@ -200,6 +201,7 @@ + From 845642b6902ea249c7d3b5bcceb043d36634796b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 9 Jan 2023 15:22:03 +0000 Subject: [PATCH 2/5] Address feedback --- src/libraries/System.Text.Json/src/Resources/Strings.resx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Text.Json/src/Resources/Strings.resx b/src/libraries/System.Text.Json/src/Resources/Strings.resx index 9b7fa0faa03a87..57076fff30ad99 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -361,7 +361,7 @@ The type '{0}' cannot have more than one member that has the attribute '{1}'. - The type '{0}' is marked 'UnmappedMemberHandling.Strict' which conflicts with extension data property '{1}'. + The type '{0}' is marked 'JsonUnmappedMemberHandling.Disallow' which conflicts with extension data property '{1}'. The type '{0}' is not supported. From 25844c74d42f7131397a3ab463d04af5a22bacda Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Jan 2023 14:20:59 +0000 Subject: [PATCH 3/5] Ignore global UnmappedMemberHandling setting when a JsonExtensionDataAttribute is specified. --- .../Json/Serialization/Metadata/JsonTypeInfo.cs | 13 ++++++++++--- .../tests/Common/UnmappedMemberHandlingTests.cs | 7 +++++-- 2 files changed, 15 insertions(+), 5 deletions(-) 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 c1c45de3638095..759e2b9cd54b12 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 @@ -510,7 +510,8 @@ public JsonUnmappedMemberHandling? UnmappedMemberHandling } private JsonUnmappedMemberHandling? _unmappedMemberHandling; - internal JsonUnmappedMemberHandling EffectiveUnmappedMemberHandling => _unmappedMemberHandling ?? Options.UnmappedMemberHandling; + + internal JsonUnmappedMemberHandling EffectiveUnmappedMemberHandling { get; private set; } internal JsonTypeInfo(Type type, JsonConverter converter, JsonSerializerOptions options) { @@ -835,7 +836,7 @@ internal void CacheMember(JsonPropertyInfo jsonPropertyInfo, JsonPropertyDiction if (jsonPropertyInfo.IsExtensionData) { - if (EffectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + if (UnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) { ThrowHelper.ThrowInvalidOperationException_ExtensionDataConflictsWithUnmappedMemberHandling(Type, jsonPropertyInfo); } @@ -962,7 +963,7 @@ internal void InitializePropertyCache() { if (property.IsExtensionData) { - if (EffectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + if (UnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) { ThrowHelper.ThrowInvalidOperationException_ExtensionDataConflictsWithUnmappedMemberHandling(Type, property); } @@ -1017,6 +1018,12 @@ internal void InitializePropertyCache() } NumberOfRequiredProperties = numberOfRequiredProperties; + // Override global UnmappedMemberHandling configuration + // if type specifies an extension data property. + EffectiveUnmappedMemberHandling = UnmappedMemberHandling ?? + (ExtensionDataProperty is null + ? Options.UnmappedMemberHandling + : JsonUnmappedMemberHandling.Skip); } internal void InitializeConstructorParameters(JsonParameterInfoValues[] jsonParameters, bool sourceGenMode = false) diff --git a/src/libraries/System.Text.Json/tests/Common/UnmappedMemberHandlingTests.cs b/src/libraries/System.Text.Json/tests/Common/UnmappedMemberHandlingTests.cs index e6554472d1d434..7f570b49df290c 100644 --- a/src/libraries/System.Text.Json/tests/Common/UnmappedMemberHandlingTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/UnmappedMemberHandlingTests.cs @@ -180,10 +180,13 @@ public class PocoInheritingDisallowAnnotation : PocoWithDisallowAnnotation #region JsonExtensionData Interop [Fact] - public async Task ClassWithExtensionData_GlobalDisallowHandling_ThrowsInvalidOperationException() + public async Task ClassWithExtensionData_GlobalDisallowHandling_ClassConfigurationOverridesGlobalSetting() { var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; - await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper("{}", options)); + ClassWithExtensionData result = await Serializer.DeserializeWrapper("""{"unmappedMember":null}""", options); + + Assert.NotNull(result.ExtensionData); + Assert.True(result.ExtensionData.ContainsKey("unmappedMember")); } [Fact] From a1bab6e3d7ff0818be74e0e06d40e8059c0a5d6f Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 12 Jan 2023 14:42:54 +0000 Subject: [PATCH 4/5] Fix VerifyOptionsEqual method. --- .../tests/System.Text.Json.Tests/Serialization/OptionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs index 7a653a4c00c62f..869464ca9684ce 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs @@ -906,7 +906,7 @@ private static void VerifyOptionsEqual(JsonSerializerOptions options, JsonSerial if (property.Name == nameof(JsonSerializerOptions.IsReadOnly)) { - break; // readonly-ness is not a structural property of JsonSerializerOptions. + continue; // readonly-ness is not a structural property of JsonSerializerOptions. } else if (propertyType == typeof(IList)) { From 82068ce6c525592a72a4ff6afe82a73b5dc46994 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 12 Jan 2023 19:55:30 +0000 Subject: [PATCH 5/5] Update src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs Co-authored-by: Krzysztof Wicher --- .../src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 759e2b9cd54b12..034864ca8160ae 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 @@ -482,7 +482,7 @@ public JsonNumberHandling? NumberHandling /// Unmapped member handling only supported for . /// /// - /// Specified an invalid value. + /// Specified an invalid value. /// /// /// For contracts originating from or ,