Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,18 @@ public KnownTypeSymbols(Compilation compilation)
public INamedTypeSymbol? JsonElementType => GetOrResolveType("System.Text.Json.JsonElement", ref _JsonElementType);
private Option<INamedTypeSymbol?> _JsonElementType;

public INamedTypeSymbol? StringObjectDictionaryType => _StringObjectDictionaryType.HasValue
? _StringObjectDictionaryType.Value
: (_StringObjectDictionaryType = new(DictionaryOfTKeyTValueType?.Construct(StringType, ObjectType))).Value;
private Option<INamedTypeSymbol?> _StringObjectDictionaryType;

public INamedTypeSymbol? StringJsonElementDictionaryType => _StringJsonElementDictionaryType.HasValue
? _StringJsonElementDictionaryType.Value
: (_StringJsonElementDictionaryType = new(DictionaryOfTKeyTValueType is { } dictType && JsonElementType is { } jsonElemType
? dictType.Construct(StringType, jsonElemType)
: null)).Value;
private Option<INamedTypeSymbol?> _StringJsonElementDictionaryType;

public INamedTypeSymbol? JsonNodeType => GetOrResolveType("System.Text.Json.Nodes.JsonNode", ref _JsonNodeType);
private Option<INamedTypeSymbol?> _JsonNodeType;

Expand Down
35 changes: 30 additions & 5 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object> or IReadOnlyDictionary<string, JsonElement>
// 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
namespace System.Text.Json.Serialization
{
/// <summary>
/// When placed on a property or field of type <see cref="System.Text.Json.Nodes.JsonObject"/> or
/// <see cref="System.Collections.Generic.IDictionary{TKey, TValue}"/>, any properties that do not have a
/// When placed on a property or field of type <see cref="System.Text.Json.Nodes.JsonObject"/>,
/// <see cref="System.Collections.Generic.IDictionary{TKey, TValue}"/>, or
/// <see cref="System.Collections.Generic.IReadOnlyDictionary{TKey, TValue}"/>, any properties that do not have a
/// matching property or field are added during deserialization and written during serialization.
/// </summary>
/// <remarks>
/// When using <see cref="System.Collections.Generic.IDictionary{TKey, TValue}"/>, the TKey value must be <see cref="string"/>
/// When using <see cref="System.Collections.Generic.IDictionary{TKey, TValue}"/> or
/// <see cref="System.Collections.Generic.IReadOnlyDictionary{TKey, TValue}"/>, the TKey value must be <see cref="string"/>
/// and TValue must be <see cref="JsonElement"/> or <see cref="object"/>.
///
/// During deserializing with a <see cref="System.Collections.Generic.IDictionary{TKey, TValue}"/> extension property with TValue as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>) ||
jsonPropertyInfo.PropertyType == typeof(IReadOnlyDictionary<string, JsonElement>);

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);
Expand All @@ -136,6 +144,48 @@ internal static void CreateExtensionDataProperty(
{
ThrowHelper.ThrowInvalidOperationException_NodeJsonObjectCustomConverterNotAllowedOnExtensionProperty();
}
// For IReadOnlyDictionary<string, object> or IReadOnlyDictionary<string, JsonElement> interface types,
// create a Dictionary<TKey, TValue> instance seeded with any existing contents.
else if (jsonPropertyInfo.PropertyType == typeof(IReadOnlyDictionary<string, object>))
{
if (extensionData != null)
{
var existing = (IReadOnlyDictionary<string, object>)extensionData;
var newDict = new Dictionary<string, object>();
foreach (KeyValuePair<string, object> kvp in existing)
{
newDict[kvp.Key] = kvp.Value;
}
extensionData = newDict;
}
else
{
extensionData = new Dictionary<string, object>();
}
Debug.Assert(jsonPropertyInfo.Set != null);
jsonPropertyInfo.Set(obj, extensionData);
return;
}
else if (jsonPropertyInfo.PropertyType == typeof(IReadOnlyDictionary<string, JsonElement>))
{
if (extensionData != null)
{
var existing = (IReadOnlyDictionary<string, JsonElement>)extensionData;
var newDict = new Dictionary<string, JsonElement>();
foreach (KeyValuePair<string, JsonElement> kvp in existing)
{
newDict[kvp.Key] = kvp.Value;
}
extensionData = newDict;
}
else
{
extensionData = new Dictionary<string, JsonElement>();
}
Debug.Assert(jsonPropertyInfo.Set != null);
jsonPropertyInfo.Set(obj, extensionData);
return;
}
else
{
ThrowHelper.ThrowNotSupportedException_SerializationNotSupported(jsonPropertyInfo.PropertyType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,8 @@ internal static bool IsValidExtensionDataProperty(Type propertyType)
{
return typeof(IDictionary<string, object>).IsAssignableFrom(propertyType) ||
typeof(IDictionary<string, JsonElement>).IsAssignableFrom(propertyType) ||
propertyType == typeof(IReadOnlyDictionary<string, object>) ||
propertyType == typeof(IReadOnlyDictionary<string, JsonElement>) ||
// Avoid a reference to typeof(JsonNode) to support trimming.
(propertyType.FullName == JsonObjectTypeName && ReferenceEquals(propertyType.Assembly, typeof(JsonTypeInfo).Assembly));
}
Expand Down
137 changes: 137 additions & 0 deletions src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1483,5 +1483,142 @@ public class ClassWithEmptyPropertyNameAndExtensionProperty
[JsonExtensionData]
public IDictionary<string, JsonElement> MyOverflow { get; set; }
}

[Fact]
public async Task IReadOnlyDictionary_ObjectExtensionPropertyRoundTrip()
{
string json = @"{""MyIntMissing"":2, ""MyInt"":1}";
ClassWithIReadOnlyDictionaryExtensionPropertyAsObjectWithProperty obj = await Serializer.DeserializeWrapper<ClassWithIReadOnlyDictionaryExtensionPropertyAsObjectWithProperty>(json);

Assert.NotNull(obj.MyOverflow);
Assert.Equal(1, obj.MyInt);
Assert.IsType<JsonElement>(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<ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty>(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<ClassWithIReadOnlyDictionaryAlreadyInstantiated>(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<ClassWithIReadOnlyDictionaryJsonElementAlreadyInstantiated>(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<string, object> MyOverflow { get; set; }
}

public class ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElement
{
[JsonExtensionData]
public IReadOnlyDictionary<string, JsonElement> MyOverflow { get; set; }
}

public class ClassWithIReadOnlyDictionaryExtensionPropertyAsObjectWithProperty
{
public int MyInt { get; set; }

[JsonExtensionData]
public IReadOnlyDictionary<string, object> MyOverflow { get; set; }
}

public class ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty
{
public int MyInt { get; set; }

[JsonExtensionData]
public IReadOnlyDictionary<string, JsonElement> MyOverflow { get; set; }
}

public class ClassWithIReadOnlyDictionaryAlreadyInstantiated
{
public int MyInt { get; set; }

[JsonExtensionData]
public IReadOnlyDictionary<string, object> MyOverflow { get; set; } = new Dictionary<string, object>
{
["ExistingKey"] = JsonDocument.Parse("\"ExistingValue\"").RootElement,
["KeyToOverwrite"] = JsonDocument.Parse("\"OldValue\"").RootElement
};
}

public class ClassWithIReadOnlyDictionaryJsonElementAlreadyInstantiated
{
public int MyInt { get; set; }

[JsonExtensionData]
public IReadOnlyDictionary<string, JsonElement> MyOverflow { get; set; } = new Dictionary<string, JsonElement>
{
["ExistingKey"] = JsonDocument.Parse("\"ExistingValue\"").RootElement,
["KeyToOverwrite"] = JsonDocument.Parse("\"OldValue\"").RootElement
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
Expand Down Expand Up @@ -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
{
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1285,6 +1285,8 @@ public static void ClassWithExtensionDataAttribute_RemovingExtensionDataProperty
[Theory]
[InlineData(typeof(IDictionary<string, object>))]
[InlineData(typeof(IDictionary<string, JsonElement>))]
[InlineData(typeof(IReadOnlyDictionary<string, object>))]
[InlineData(typeof(IReadOnlyDictionary<string, JsonElement>))]
[InlineData(typeof(Dictionary<string, object>))]
[InlineData(typeof(Dictionary<string, JsonElement>))]
[InlineData(typeof(ConcurrentDictionary<string, JsonElement>))]
Expand Down