diff --git a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs index 0837c6f42f8871..3827f707ee4763 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs @@ -191,6 +191,18 @@ public KnownTypeSymbols(Compilation compilation) public INamedTypeSymbol? JsonElementType => GetOrResolveType("System.Text.Json.JsonElement", ref _JsonElementType); private Option _JsonElementType; + public INamedTypeSymbol? StringObjectDictionaryType => _StringObjectDictionaryType.HasValue + ? _StringObjectDictionaryType.Value + : (_StringObjectDictionaryType = new(DictionaryOfTKeyTValueType?.Construct(StringType, ObjectType))).Value; + private Option _StringObjectDictionaryType; + + public INamedTypeSymbol? StringJsonElementDictionaryType => _StringJsonElementDictionaryType.HasValue + ? _StringJsonElementDictionaryType.Value + : (_StringJsonElementDictionaryType = new(DictionaryOfTKeyTValueType is { } dictType && JsonElementType is { } jsonElemType + ? dictType.Construct(StringType, jsonElemType) + : null)).Value; + private Option _StringJsonElementDictionaryType; + public INamedTypeSymbol? JsonNodeType => GetOrResolveType("System.Text.Json.Nodes.JsonNode", ref _JsonNodeType); private Option _JsonNodeType; diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index fc64ef50b766f3..9d4d1eacd6d2db 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -1105,14 +1105,39 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type) } INamedTypeSymbol? actualDictionaryType = type.GetCompatibleGenericBaseType(_knownSymbols.IDictionaryOfTKeyTValueType); - if (actualDictionaryType == null) + if (actualDictionaryType != null) { - return false; + if (SymbolEqualityComparer.Default.Equals(actualDictionaryType.TypeArguments[0], _knownSymbols.StringType) && + (SymbolEqualityComparer.Default.Equals(actualDictionaryType.TypeArguments[1], _knownSymbols.ObjectType) || + SymbolEqualityComparer.Default.Equals(actualDictionaryType.TypeArguments[1], _knownSymbols.JsonElementType))) + { + return true; + } } - return SymbolEqualityComparer.Default.Equals(actualDictionaryType.TypeArguments[0], _knownSymbols.StringType) && - (SymbolEqualityComparer.Default.Equals(actualDictionaryType.TypeArguments[1], _knownSymbols.ObjectType) || - SymbolEqualityComparer.Default.Equals(actualDictionaryType.TypeArguments[1], _knownSymbols.JsonElementType)); + // Also check for IReadOnlyDictionary or IReadOnlyDictionary + // but only if Dictionary can be assigned to it (to exclude ImmutableDictionary and similar types) + INamedTypeSymbol? actualReadOnlyDictionaryType = type.GetCompatibleGenericBaseType(_knownSymbols.IReadonlyDictionaryOfTKeyTValueType); + if (actualReadOnlyDictionaryType != null) + { + if (SymbolEqualityComparer.Default.Equals(actualReadOnlyDictionaryType.TypeArguments[0], _knownSymbols.StringType) && + (SymbolEqualityComparer.Default.Equals(actualReadOnlyDictionaryType.TypeArguments[1], _knownSymbols.ObjectType) || + SymbolEqualityComparer.Default.Equals(actualReadOnlyDictionaryType.TypeArguments[1], _knownSymbols.JsonElementType))) + { + // Check if Dictionary can be assigned to this type + INamedTypeSymbol? dictionaryType = SymbolEqualityComparer.Default.Equals(actualReadOnlyDictionaryType.TypeArguments[1], _knownSymbols.ObjectType) + ? _knownSymbols.StringObjectDictionaryType + : _knownSymbols.StringJsonElementDictionaryType; + + if (dictionaryType != null) + { + Conversion conversion = _knownSymbols.Compilation.ClassifyConversion(dictionaryType, type); + return conversion.IsImplicit || conversion.IsIdentity; + } + } + } + + return false; } private PropertyGenerationSpec? ParsePropertyGenerationSpec( diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonExtensionDataAttribute.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonExtensionDataAttribute.cs index 42b9957d3e32de..21da84f2976293 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonExtensionDataAttribute.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonExtensionDataAttribute.cs @@ -4,12 +4,14 @@ namespace System.Text.Json.Serialization { /// - /// When placed on a property or field of type or - /// , any properties that do not have a + /// When placed on a property or field of type , + /// , or + /// , any properties that do not have a /// matching property or field are added during deserialization and written during serialization. /// /// - /// When using , the TKey value must be + /// When using or + /// , the TKey value must be /// and TValue must be or . /// /// During deserializing with a extension property with TValue as 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 64a9de11e893a7..5daf1c73d346c8 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 @@ -110,11 +110,19 @@ internal static void CreateExtensionDataProperty( Debug.Assert(jsonPropertyInfo != null); object? extensionData = jsonPropertyInfo.GetValueAsObject(obj); - if (extensionData == null) + + // For IReadOnlyDictionary, if there's an existing non-null instance, we need to create a new mutable + // Dictionary seeded with the existing contents so we can add the deserialized extension data to it. + bool isReadOnlyDictionary = jsonPropertyInfo.PropertyType == typeof(IReadOnlyDictionary) || + jsonPropertyInfo.PropertyType == typeof(IReadOnlyDictionary); + + if (extensionData == null || (isReadOnlyDictionary && extensionData != null)) { // Create the appropriate dictionary type. We already verified the types. #if DEBUG - Type underlyingIDictionaryType = jsonPropertyInfo.PropertyType.GetCompatibleGenericInterface(typeof(IDictionary<,>))!; + Type? underlyingIDictionaryType = jsonPropertyInfo.PropertyType.GetCompatibleGenericInterface(typeof(IDictionary<,>)) + ?? jsonPropertyInfo.PropertyType.GetCompatibleGenericInterface(typeof(IReadOnlyDictionary<,>)); + Debug.Assert(underlyingIDictionaryType is not null); Type[] genericArgs = underlyingIDictionaryType.GetGenericArguments(); Debug.Assert(underlyingIDictionaryType.IsGenericType); @@ -136,6 +144,48 @@ internal static void CreateExtensionDataProperty( { ThrowHelper.ThrowInvalidOperationException_NodeJsonObjectCustomConverterNotAllowedOnExtensionProperty(); } + // For IReadOnlyDictionary or IReadOnlyDictionary interface types, + // create a Dictionary instance seeded with any existing contents. + else if (jsonPropertyInfo.PropertyType == typeof(IReadOnlyDictionary)) + { + if (extensionData != null) + { + var existing = (IReadOnlyDictionary)extensionData; + var newDict = new Dictionary(); + foreach (KeyValuePair kvp in existing) + { + newDict[kvp.Key] = kvp.Value; + } + extensionData = newDict; + } + else + { + extensionData = new Dictionary(); + } + Debug.Assert(jsonPropertyInfo.Set != null); + jsonPropertyInfo.Set(obj, extensionData); + return; + } + else if (jsonPropertyInfo.PropertyType == typeof(IReadOnlyDictionary)) + { + if (extensionData != null) + { + var existing = (IReadOnlyDictionary)extensionData; + var newDict = new Dictionary(); + foreach (KeyValuePair kvp in existing) + { + newDict[kvp.Key] = kvp.Value; + } + extensionData = newDict; + } + else + { + extensionData = new Dictionary(); + } + Debug.Assert(jsonPropertyInfo.Set != null); + jsonPropertyInfo.Set(obj, extensionData); + return; + } else { ThrowHelper.ThrowNotSupportedException_SerializationNotSupported(jsonPropertyInfo.PropertyType); 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 10e6db00dca22b..5456030b9bcaea 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 @@ -1326,6 +1326,8 @@ internal static bool IsValidExtensionDataProperty(Type propertyType) { return typeof(IDictionary).IsAssignableFrom(propertyType) || typeof(IDictionary).IsAssignableFrom(propertyType) || + propertyType == typeof(IReadOnlyDictionary) || + propertyType == typeof(IReadOnlyDictionary) || // Avoid a reference to typeof(JsonNode) to support trimming. (propertyType.FullName == JsonObjectTypeName && ReferenceEquals(propertyType.Assembly, typeof(JsonTypeInfo).Assembly)); } diff --git a/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs b/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs index 5a675f2851df04..570624a2a229df 100644 --- a/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs @@ -1483,5 +1483,142 @@ public class ClassWithEmptyPropertyNameAndExtensionProperty [JsonExtensionData] public IDictionary MyOverflow { get; set; } } + + [Fact] + public async Task IReadOnlyDictionary_ObjectExtensionPropertyRoundTrip() + { + string json = @"{""MyIntMissing"":2, ""MyInt"":1}"; + ClassWithIReadOnlyDictionaryExtensionPropertyAsObjectWithProperty obj = await Serializer.DeserializeWrapper(json); + + Assert.NotNull(obj.MyOverflow); + Assert.Equal(1, obj.MyInt); + Assert.IsType(obj.MyOverflow["MyIntMissing"]); + Assert.Equal(2, ((JsonElement)obj.MyOverflow["MyIntMissing"]).GetInt32()); + + string jsonSerialized = await Serializer.SerializeWrapper(obj); + Assert.Contains("\"MyIntMissing\"", jsonSerialized); + Assert.Contains("\"MyInt\"", jsonSerialized); + Assert.DoesNotContain(nameof(ClassWithIReadOnlyDictionaryExtensionPropertyAsObjectWithProperty.MyOverflow), jsonSerialized); + } + + [Fact] + public async Task IReadOnlyDictionary_JsonElementExtensionPropertyRoundTrip() + { + string json = @"{""MyIntMissing"":2, ""MyInt"":1}"; + ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty obj = await Serializer.DeserializeWrapper(json); + + Assert.NotNull(obj.MyOverflow); + Assert.Equal(1, obj.MyInt); + Assert.Equal(2, obj.MyOverflow["MyIntMissing"].GetInt32()); + + string jsonSerialized = await Serializer.SerializeWrapper(obj); + Assert.Contains("\"MyIntMissing\"", jsonSerialized); + Assert.Contains("\"MyInt\"", jsonSerialized); + Assert.DoesNotContain(nameof(ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty.MyOverflow), jsonSerialized); + } + + [Fact] + public async Task IReadOnlyDictionary_ExtensionPropertyIgnoredWhenWritingDefault() + { + string expected = @"{}"; + string actual = await Serializer.SerializeWrapper(new ClassWithIReadOnlyDictionaryExtensionPropertyAsObject()); + Assert.Equal(expected, actual); + } + + [Fact] + public async Task IReadOnlyDictionary_PrePopulated_SeedsNewInstance() + { + string json = @"{""MyIntMissing"":2, ""KeyToOverwrite"":""NewValue"", ""MyInt"":1}"; + var obj = await Serializer.DeserializeWrapper(json); + + Assert.NotNull(obj.MyOverflow); + Assert.Equal(1, obj.MyInt); + + // Should have the existing key from the initializer + Assert.True(obj.MyOverflow.ContainsKey("ExistingKey")); + Assert.Equal("ExistingValue", ((JsonElement)obj.MyOverflow["ExistingKey"]).GetString()); + + // Should have the new key from deserialization + Assert.True(obj.MyOverflow.ContainsKey("MyIntMissing")); + Assert.Equal(2, ((JsonElement)obj.MyOverflow["MyIntMissing"]).GetInt32()); + + // Existing key should be overwritten with new value from deserialization + Assert.True(obj.MyOverflow.ContainsKey("KeyToOverwrite")); + Assert.Equal("NewValue", ((JsonElement)obj.MyOverflow["KeyToOverwrite"]).GetString()); + } + + [Fact] + public async Task IReadOnlyDictionary_PrePopulated_JsonElement_SeedsNewInstance() + { + string json = @"{""MyIntMissing"":2, ""KeyToOverwrite"":""NewValue"", ""MyInt"":1}"; + var obj = await Serializer.DeserializeWrapper(json); + + Assert.NotNull(obj.MyOverflow); + Assert.Equal(1, obj.MyInt); + + // Should have the existing key from the initializer + Assert.True(obj.MyOverflow.ContainsKey("ExistingKey")); + Assert.Equal("ExistingValue", obj.MyOverflow["ExistingKey"].GetString()); + + // Should have the new key from deserialization + Assert.True(obj.MyOverflow.ContainsKey("MyIntMissing")); + Assert.Equal(2, obj.MyOverflow["MyIntMissing"].GetInt32()); + + // Existing key should be overwritten with new value from deserialization + Assert.True(obj.MyOverflow.ContainsKey("KeyToOverwrite")); + Assert.Equal("NewValue", obj.MyOverflow["KeyToOverwrite"].GetString()); + } + + public class ClassWithIReadOnlyDictionaryExtensionPropertyAsObject + { + [JsonExtensionData] + public IReadOnlyDictionary MyOverflow { get; set; } + } + + public class ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElement + { + [JsonExtensionData] + public IReadOnlyDictionary MyOverflow { get; set; } + } + + public class ClassWithIReadOnlyDictionaryExtensionPropertyAsObjectWithProperty + { + public int MyInt { get; set; } + + [JsonExtensionData] + public IReadOnlyDictionary MyOverflow { get; set; } + } + + public class ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty + { + public int MyInt { get; set; } + + [JsonExtensionData] + public IReadOnlyDictionary MyOverflow { get; set; } + } + + public class ClassWithIReadOnlyDictionaryAlreadyInstantiated + { + public int MyInt { get; set; } + + [JsonExtensionData] + public IReadOnlyDictionary MyOverflow { get; set; } = new Dictionary + { + ["ExistingKey"] = JsonDocument.Parse("\"ExistingValue\"").RootElement, + ["KeyToOverwrite"] = JsonDocument.Parse("\"OldValue\"").RootElement + }; + } + + public class ClassWithIReadOnlyDictionaryJsonElementAlreadyInstantiated + { + public int MyInt { get; set; } + + [JsonExtensionData] + public IReadOnlyDictionary MyOverflow { get; set; } = new Dictionary + { + ["ExistingKey"] = JsonDocument.Parse("\"ExistingValue\"").RootElement, + ["KeyToOverwrite"] = JsonDocument.Parse("\"OldValue\"").RootElement + }; + } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ExtensionDataTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ExtensionDataTests.cs index ba67748368ff3b..89829b304bf4b2 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ExtensionDataTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ExtensionDataTests.cs @@ -68,6 +68,12 @@ public ExtensionDataTests_Metadata() [JsonSerializable(typeof(int))] [JsonSerializable(typeof(DummyObj))] [JsonSerializable(typeof(DummyStruct))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsObject))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElement))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsObjectWithProperty))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryAlreadyInstantiated))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryJsonElementAlreadyInstantiated))] internal sealed partial class ExtensionDataTestsContext_Metadata : JsonSerializerContext { } @@ -132,6 +138,12 @@ public ExtensionDataTests_Default() [JsonSerializable(typeof(int))] [JsonSerializable(typeof(DummyObj))] [JsonSerializable(typeof(DummyStruct))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsObject))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElement))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsObjectWithProperty))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryAlreadyInstantiated))] + [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryJsonElementAlreadyInstantiated))] internal sealed partial class ExtensionDataTestsContext_Default : JsonSerializerContext { } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/MetadataTests/DefaultJsonTypeInfoResolverTests.JsonPropertyInfo.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/MetadataTests/DefaultJsonTypeInfoResolverTests.JsonPropertyInfo.cs index 4f263a7c882895..f3ac893e9e5c19 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/MetadataTests/DefaultJsonTypeInfoResolverTests.JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/MetadataTests/DefaultJsonTypeInfoResolverTests.JsonPropertyInfo.cs @@ -1285,6 +1285,8 @@ public static void ClassWithExtensionDataAttribute_RemovingExtensionDataProperty [Theory] [InlineData(typeof(IDictionary))] [InlineData(typeof(IDictionary))] + [InlineData(typeof(IReadOnlyDictionary))] + [InlineData(typeof(IReadOnlyDictionary))] [InlineData(typeof(Dictionary))] [InlineData(typeof(Dictionary))] [InlineData(typeof(ConcurrentDictionary))]