From fa7bc370e1f8db1021ca39f49f425ef2f5a14b0f Mon Sep 17 00:00:00 2001 From: Layomi Akinrinade Date: Sat, 23 May 2020 09:58:42 -0700 Subject: [PATCH 1/3] Loosen property name collision detection involving hidden properties --- .../Text/Json/Serialization/JsonClassInfo.cs | 32 +++++--- .../Serialization/PropertyVisibilityTests.cs | 77 +++++++++++++++++++ 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs index b97b6117923d46..f457a0bdd24fec 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs @@ -101,6 +101,9 @@ public JsonClassInfo(Type type, JsonSerializerOptions options) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + HashSet ignoredProperties = new HashSet(); + + // We start from the most derived type and ascend to the base type. for (Type? currentType = type; currentType != null; currentType = currentType.BaseType) { foreach (PropertyInfo propertyInfo in currentType.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) @@ -111,31 +114,42 @@ public JsonClassInfo(Type type, JsonSerializerOptions options) continue; } - // For now we only support public getters\setters + // For now we only support public properties (i.e. setter and/or getter is public). if (propertyInfo.GetMethod?.IsPublic == true || propertyInfo.SetMethod?.IsPublic == true) { JsonPropertyInfo jsonPropertyInfo = AddProperty(propertyInfo, currentType, options); Debug.Assert(jsonPropertyInfo != null && jsonPropertyInfo.NameAsString != null); - // If the JsonPropertyNameAttribute or naming policy results in collisions, throw an exception. + string propertyName = propertyInfo.Name; + bool isIgnored = jsonPropertyInfo.IsIgnored; + + // The JsonPropertyNameAttribute or naming policy resulted in a collision. if (!JsonHelpers.TryAdd(cache, jsonPropertyInfo.NameAsString, jsonPropertyInfo)) { JsonPropertyInfo other = cache[jsonPropertyInfo.NameAsString]; - if (other.ShouldDeserialize == false && other.ShouldSerialize == false) + if (other.IsIgnored) { - // Overwrite the one just added since it has [JsonIgnore]. + // Overwrite previously cached property since it has [JsonIgnore]. cache[jsonPropertyInfo.NameAsString] = jsonPropertyInfo; } - else if (other.PropertyInfo?.Name != jsonPropertyInfo.PropertyInfo?.Name && - (jsonPropertyInfo.ShouldDeserialize == true || jsonPropertyInfo.ShouldSerialize == true)) + else if (!(isIgnored || other.PropertyInfo!.Name == propertyName || ignoredProperties.Contains(propertyName))) { - // Check for name equality is required to determine when a new slot is used for the member. - // Therefore, if names are not the same, there is conflict due to the name policy or attributes. + // The collision is invalid if none of the following is true: + // 1. The current property has [JsonIgnore]. + // 2. The current property was hidden by a previously cached property with the same CLR property name + // (either by overriding or with the new operator). + // 3. The current property has a conflicting name with a previously cached property that did not hide it. + // The property that hid it has [JsonIgnore] (and was overwritten), so we'll ignore this one as well. ThrowHelper.ThrowInvalidOperationException_SerializerPropertyNameConflict(Type, jsonPropertyInfo); } - // else ignore jsonPropertyInfo since it has [JsonIgnore] or it's hidden by a new slot. + // Ignore the current property. + } + + if (jsonPropertyInfo.IsIgnored) + { + ignoredProperties.Add(propertyName); } } else diff --git a/src/libraries/System.Text.Json/tests/Serialization/PropertyVisibilityTests.cs b/src/libraries/System.Text.Json/tests/Serialization/PropertyVisibilityTests.cs index 8cc9cf6e6bde70..18fea3dc5bdfb5 100644 --- a/src/libraries/System.Text.Json/tests/Serialization/PropertyVisibilityTests.cs +++ b/src/libraries/System.Text.Json/tests/Serialization/PropertyVisibilityTests.cs @@ -327,6 +327,28 @@ public static void Throw_PublicProperty_ConflictDuePolicy_DobuleInheritance() () => JsonSerializer.Deserialize(json, options)); } + [Fact] + public static void HiddenPropertiesIgnored_WhenOverridesIgnored_AndPropertyNameConflicts() + { + string serialized = JsonSerializer.Serialize(new DerivedClass_With_IgnoredOverride()); + Assert.Equal(@"{""MyProp"":false}", serialized); + + serialized = JsonSerializer.Serialize(new DerivedClass_With_IgnoredOverride_And_ConflictingPropertyName()); + Assert.Equal(@"{""MyProp"":null}", serialized); + + serialized = JsonSerializer.Serialize(new DerivedClass_With_NewProperty()); + Assert.Equal(@"{""MyProp"":false}", serialized); + + serialized = JsonSerializer.Serialize(new DerivedClass_With_NewProperty_And_ConflictingPropertyName()); + Assert.Equal(@"{""MyProp"":null}", serialized); + + serialized = JsonSerializer.Serialize(new DerivedClass_WithNewProperty_Of_DifferentType()); + Assert.Equal(@"{""MyProp"":false}", serialized); + + serialized = JsonSerializer.Serialize(new DerivedClass_WithNewProperty_Of_DifferentType_And_ConflictingPropertyName()); + Assert.Equal(@"{""MyProp"":null}", serialized); + } + public class ClassWithInternalProperty { internal string MyString { get; set; } = "DefaultValue"; @@ -474,6 +496,61 @@ public class ClassWithNewSlotAttributedDecimalProperty : ClassWithHiddenByNewSlo public new decimal MyNumeric { get; set; } = 1.5M; } + private class Class_With_VirtualProperty + { + public virtual bool MyProp { get; set; } + } + + private class DerivedClass_With_IgnoredOverride : Class_With_VirtualProperty + { + [JsonIgnore] + public override bool MyProp { get; set; } + } + + private class DerivedClass_With_IgnoredOverride_And_ConflictingPropertyName : Class_With_VirtualProperty + { + [JsonPropertyName("MyProp")] + public string MyString { get; set; } + + [JsonIgnore] + public override bool MyProp { get; set; } + } + + private class Class_With_Property + { + public bool MyProp { get; set; } + } + + private class DerivedClass_With_NewProperty : Class_With_Property + { + [JsonIgnore] + public new bool MyProp { get; set; } + } + + private class DerivedClass_With_NewProperty_And_ConflictingPropertyName : Class_With_Property + { + [JsonPropertyName("MyProp")] + public string MyString { get; set; } + + [JsonIgnore] + public new bool MyProp { get; set; } + } + + private class DerivedClass_WithNewProperty_Of_DifferentType : Class_With_Property + { + [JsonIgnore] + public new int MyProp { get; set; } + } + + private class DerivedClass_WithNewProperty_Of_DifferentType_And_ConflictingPropertyName : Class_With_Property + { + [JsonPropertyName("MyProp")] + public string MyString { get; set; } + + [JsonIgnore] + public new int MyProp { get; set; } + } + [Fact] public static void NoSetter() { From ad0bc1274c9dc94071b0d7694c0846f9170baec0 Mon Sep 17 00:00:00 2001 From: Layomi Akinrinade Date: Tue, 26 May 2020 14:14:59 -0700 Subject: [PATCH 2/3] Delay ignored prop cache creation; add more tests --- .../Text/Json/Serialization/JsonClassInfo.cs | 9 ++--- .../Serialization/PropertyVisibilityTests.cs | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs index f457a0bdd24fec..05d80ad86867e9 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs @@ -101,7 +101,7 @@ public JsonClassInfo(Type type, JsonSerializerOptions options) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); - HashSet ignoredProperties = new HashSet(); + HashSet? ignoredProperties = null; // We start from the most derived type and ascend to the base type. for (Type? currentType = type; currentType != null; currentType = currentType.BaseType) @@ -134,14 +134,15 @@ public JsonClassInfo(Type type, JsonSerializerOptions options) // Overwrite previously cached property since it has [JsonIgnore]. cache[jsonPropertyInfo.NameAsString] = jsonPropertyInfo; } - else if (!(isIgnored || other.PropertyInfo!.Name == propertyName || ignoredProperties.Contains(propertyName))) + else if (!(isIgnored || other.PropertyInfo!.Name == propertyName || ignoredProperties?.Contains(propertyName) == true)) { // The collision is invalid if none of the following is true: // 1. The current property has [JsonIgnore]. // 2. The current property was hidden by a previously cached property with the same CLR property name // (either by overriding or with the new operator). // 3. The current property has a conflicting name with a previously cached property that did not hide it. - // The property that hid it has [JsonIgnore] (and was overwritten), so we'll ignore this one as well. + // The property that hid it has [JsonIgnore] (and was overwritten from the caches), + // so we'll ignore this one as well. ThrowHelper.ThrowInvalidOperationException_SerializerPropertyNameConflict(Type, jsonPropertyInfo); } // Ignore the current property. @@ -149,7 +150,7 @@ public JsonClassInfo(Type type, JsonSerializerOptions options) if (jsonPropertyInfo.IsIgnored) { - ignoredProperties.Add(propertyName); + (ignoredProperties ??= new HashSet()).Add(propertyName); } } else diff --git a/src/libraries/System.Text.Json/tests/Serialization/PropertyVisibilityTests.cs b/src/libraries/System.Text.Json/tests/Serialization/PropertyVisibilityTests.cs index 18fea3dc5bdfb5..f3a20ca5d9b187 100644 --- a/src/libraries/System.Text.Json/tests/Serialization/PropertyVisibilityTests.cs +++ b/src/libraries/System.Text.Json/tests/Serialization/PropertyVisibilityTests.cs @@ -347,6 +347,17 @@ public static void HiddenPropertiesIgnored_WhenOverridesIgnored_AndPropertyNameC serialized = JsonSerializer.Serialize(new DerivedClass_WithNewProperty_Of_DifferentType_And_ConflictingPropertyName()); Assert.Equal(@"{""MyProp"":null}", serialized); + + serialized = JsonSerializer.Serialize(new DerivedClass_WithIgnoredOverride()); + Assert.Equal(@"{""MyProp"":false}", serialized); + + serialized = JsonSerializer.Serialize(new FurtherDerivedClass_With_ConflictingPropertyName()); + Assert.Equal(@"{""MyProp"":null}", serialized); + + Assert.Throws(() => JsonSerializer.Serialize(new DerivedClass_WithConflictingPropertyName())); + + serialized = JsonSerializer.Serialize(new FurtherDerivedClass_With_IgnoredOverride()); + Assert.Equal(@"{""MyProp"":null}", serialized); } public class ClassWithInternalProperty @@ -551,6 +562,30 @@ private class DerivedClass_WithNewProperty_Of_DifferentType_And_ConflictingPrope public new int MyProp { get; set; } } + private class DerivedClass_WithIgnoredOverride : Class_With_VirtualProperty + { + [JsonIgnore] + public override bool MyProp { get; set; } + } + + private class FurtherDerivedClass_With_ConflictingPropertyName : DerivedClass_WithIgnoredOverride + { + [JsonPropertyName("MyProp")] + public string MyString { get; set; } + } + + private class DerivedClass_WithConflictingPropertyName : Class_With_VirtualProperty + { + [JsonPropertyName("MyProp")] + public string MyString { get; set; } + } + + private class FurtherDerivedClass_With_IgnoredOverride : DerivedClass_WithConflictingPropertyName + { + [JsonIgnore] + public override bool MyProp { get; set; } + } + [Fact] public static void NoSetter() { From 5c89389bf6de6ce0b96af4d742c5e23d3dada6ac Mon Sep 17 00:00:00 2001 From: Layomi Akinrinade Date: Wed, 27 May 2020 13:09:46 -0700 Subject: [PATCH 3/3] Clarify comments --- .../Text/Json/Serialization/JsonClassInfo.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs index 05d80ad86867e9..7c24c672dcaf72 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonClassInfo.cs @@ -122,7 +122,6 @@ public JsonClassInfo(Type type, JsonSerializerOptions options) Debug.Assert(jsonPropertyInfo != null && jsonPropertyInfo.NameAsString != null); string propertyName = propertyInfo.Name; - bool isIgnored = jsonPropertyInfo.IsIgnored; // The JsonPropertyNameAttribute or naming policy resulted in a collision. if (!JsonHelpers.TryAdd(cache, jsonPropertyInfo.NameAsString, jsonPropertyInfo)) @@ -134,15 +133,17 @@ public JsonClassInfo(Type type, JsonSerializerOptions options) // Overwrite previously cached property since it has [JsonIgnore]. cache[jsonPropertyInfo.NameAsString] = jsonPropertyInfo; } - else if (!(isIgnored || other.PropertyInfo!.Name == propertyName || ignoredProperties?.Contains(propertyName) == true)) + else if ( + // Does the current property have `JsonIgnoreAttribute`? + !jsonPropertyInfo.IsIgnored && + // Is the current property hidden by the previously cached property + // (with `new` keyword, or by overriding)? + other.PropertyInfo!.Name != propertyName && + // Was a property with the same CLR name was ignored? That property hid the current property, + // thus, if it was ignored, the current property should be ignored too. + ignoredProperties?.Contains(propertyName) != true) { - // The collision is invalid if none of the following is true: - // 1. The current property has [JsonIgnore]. - // 2. The current property was hidden by a previously cached property with the same CLR property name - // (either by overriding or with the new operator). - // 3. The current property has a conflicting name with a previously cached property that did not hide it. - // The property that hid it has [JsonIgnore] (and was overwritten from the caches), - // so we'll ignore this one as well. + // We throw if we have two public properties that have the same JSON property name, and neither have been ignored. ThrowHelper.ThrowInvalidOperationException_SerializerPropertyNameConflict(Type, jsonPropertyInfo); } // Ignore the current property.