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 c43f9c9a5f55c..468e698cc04c2 100644 --- a/src/libraries/System.Text.Json/ref/System.Text.Json.cs +++ b/src/libraries/System.Text.Json/ref/System.Text.Json.cs @@ -463,6 +463,7 @@ public JsonSerializerOptions() { } public bool PropertyNameCaseInsensitive { get { throw null; } set { } } public System.Text.Json.JsonNamingPolicy? PropertyNamingPolicy { get { throw null; } set { } } public System.Text.Json.JsonCommentHandling ReadCommentHandling { get { throw null; } set { } } + public System.Text.Json.Serialization.ReferenceHandling ReferenceHandling { get { throw null; } set { } } public bool WriteIndented { get { throw null; } set { } } public System.Text.Json.Serialization.JsonConverter? GetConverter(System.Type typeToConvert) { throw null; } } @@ -775,4 +776,10 @@ public JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy? namingPolicy = public override bool CanConvert(System.Type typeToConvert) { throw null; } public override System.Text.Json.Serialization.JsonConverter CreateConverter(System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) { throw null; } } + public sealed partial class ReferenceHandling + { + internal ReferenceHandling() { } + public static System.Text.Json.Serialization.ReferenceHandling Default { get { throw null; } } + public static System.Text.Json.Serialization.ReferenceHandling Preserve { get { 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 59b88fa60bf18..f1d11f8a343c2 100644 --- a/src/libraries/System.Text.Json/src/Resources/Strings.resx +++ b/src/libraries/System.Text.Json/src/Resources/Strings.resx @@ -421,7 +421,7 @@ Either the JSON value is not in a supported format, or is out of bounds for a UInt16. - A possible object cycle was detected which is not supported. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of {0}. + A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of {0}. Consider using ReferenceHanlding.Preserve on JsonSerializerOptions to support cycles. Expected a number, but instead got empty string. @@ -447,4 +447,42 @@ This JsonElement instance was not built from a JsonNode and is immutable. - \ No newline at end of file + + Cannot parse a JSON object containing metadata properties like '$id' into an array or immutable collection type. Type '{0}'. + + + The value of the '$id' metadata property '{0}' conflicts with an existing identifier. + + + The metadata property '$id' must be the first property in the JSON object. + + + Invalid reference to value type '{0}'. + + + The '$values' metadata property must be a JSON array. Current token type is '{0}'. + + + Deserialization failed for one of these reasons: +1. {0} +2. {1} + + + Invalid property '{0}' found within a JSON object that must only contain metadata properties and the nested JSON array to be preserved. + + + One or more metadata properties, such as '$id' and '$values', were not found within a JSON object that must only contain metadata properties and the nested JSON array to be preserved. + + + A JSON object that contains a '$ref' metadata property must not contain any other properties. + + + Reference '{0}' not found. + + + The '$id' and '$ref' metadata properties must be JSON strings. Current token type is '{0}'. + + + Properties that start with '$' are not allowed on preserve mode, either escape the character or turn off preserve references by setting ReferenceHandling to ReferenceHandling.Default. + + 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 6f5707920aecd..36cfd578f9643 100644 --- a/src/libraries/System.Text.Json/src/System.Text.Json.csproj +++ b/src/libraries/System.Text.Json/src/System.Text.Json.csproj @@ -78,6 +78,7 @@ + @@ -94,6 +95,7 @@ + @@ -102,6 +104,7 @@ + @@ -125,10 +128,13 @@ + + + diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/DefaultReferenceResolver.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/DefaultReferenceResolver.cs new file mode 100644 index 0000000000000..4f18e6ad0e270 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/DefaultReferenceResolver.cs @@ -0,0 +1,92 @@ +// Licensed to the.NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace System.Text.Json.Serialization +{ + /// + /// The default ReferenceResolver implementation to handle duplicate object references. + /// + /// + /// It is currently a struct to save one unnecessary allcation while (de)serializing. + /// If we choose to expose the ReferenceResolver in a future, we may need to create an abstract class/interface and change this type to become a class that inherits from that abstract class/interface. + /// + internal struct DefaultReferenceResolver + { + private uint _referenceCount; + private readonly Dictionary? _referenceIdToObjectMap; + private readonly Dictionary? _objectToReferenceIdMap; + + public DefaultReferenceResolver(bool writing) + { + _referenceCount = default; + + if (writing) + { + // Comparer used here to always do a Reference Equality comparison on serialization which is where we use the objects as the TKey in our dictionary. + _objectToReferenceIdMap = new Dictionary(ReferenceEqualsEqualityComparer.Comparer); + _referenceIdToObjectMap = null; + } + else + { + _referenceIdToObjectMap = new Dictionary(); + _objectToReferenceIdMap = null; + } + } + + + /// + /// Adds an entry to the bag of references using the specified id and value. + /// This method gets called when an $id metadata property from a JSON object is read. + /// + /// The identifier of the respective JSON object or array. + /// The value of the respective CLR reference type object that results from parsing the JSON object. + public void AddReferenceOnDeserialize(string referenceId, object value) + { + if (!JsonHelpers.TryAdd(_referenceIdToObjectMap!, referenceId, value)) + { + ThrowHelper.ThrowJsonException_MetadataDuplicateIdFound(referenceId); + } + } + + /// + /// Gets the reference id of the specified value if exists; otherwise a new id is assigned. + /// This method gets called before a CLR object is written so we can decide whether to write $id and the rest of its properties or $ref and step into the next object. + /// The first $id value will be 1. + /// + /// The value of the CLR reference type object to get or add an id for. + /// The id realated to the object. + /// + public bool TryGetOrAddReferenceOnSerialize(object value, out string referenceId) + { + if (!_objectToReferenceIdMap!.TryGetValue(value, out referenceId!)) + { + _referenceCount++; + referenceId = _referenceCount.ToString(); + _objectToReferenceIdMap.Add(value, referenceId); + + return false; + } + + return true; + } + + /// + /// Resolves the CLR reference type object related to the specified reference id. + /// This method gets called when $ref metadata property is read. + /// + /// The id related to the returned object. + /// + public object ResolveReferenceOnDeserialize(string referenceId) + { + if (!_referenceIdToObjectMap!.TryGetValue(referenceId, out object? value)) + { + ThrowHelper.ThrowJsonException_MetadataReferenceNotFound(referenceId); + } + + return value; + } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPreservableArrayReference.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPreservableArrayReference.cs new file mode 100644 index 0000000000000..cd5cc91334805 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPreservableArrayReference.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace System.Text.Json +{ + /// + /// JSON objects that contain metadata properties and the nested JSON array are wrapped into this class. + /// + /// The original type of the enumerable. + internal class JsonPreservableArrayReference + { + /// + /// The actual enumerable instance being preserved is extracted when we finish processing the JSON object on HandleEndObject. + /// + public T Values { get; set; } = default!; + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs index 3b890e01aa1f8..0a4b38f869aab 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfo.cs @@ -603,5 +603,7 @@ private void VerifyWrite(int originalDepth, Utf8JsonWriter writer) ThrowHelper.ThrowJsonException_SerializationConverterWrite(ConverterBase); } } + + public abstract Type GetJsonPreservableArrayReferenceType(); } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoCommon.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoCommon.cs index a67879610b5b2..69c4246bab922 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoCommon.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonPropertyInfoCommon.cs @@ -235,5 +235,10 @@ public override IDictionary CreateImmutableDictionaryInstance(ref ReadStack stat return collection; } + + public override Type GetJsonPreservableArrayReferenceType() + { + return typeof(JsonPreservableArrayReference); + } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleArray.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleArray.cs index 854b9a2391ae1..136c555e28130 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleArray.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleArray.cs @@ -212,7 +212,7 @@ internal static void ApplyObjectToEnumerable( else if (state.Current.IsProcessingObject(ClassType.Dictionary) || (state.Current.IsProcessingProperty(ClassType.Dictionary) && !setPropertyDirectly)) { string? key = state.Current.KeyName; - Debug.Assert(!string.IsNullOrEmpty(key)); + Debug.Assert(key != null); if (state.Current.TempDictionaryValues != null) { @@ -288,7 +288,7 @@ internal static void ApplyValueToEnumerable( else if (state.Current.IsProcessingDictionary()) { string? key = state.Current.KeyName; - Debug.Assert(!string.IsNullOrEmpty(key)); + Debug.Assert(key != null); if (state.Current.TempDictionaryValues != null) { diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleDictionary.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleDictionary.cs index 29a11f6ca075e..03a3ac673faaa 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleDictionary.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleDictionary.cs @@ -31,6 +31,8 @@ private static void HandleStartDictionary(JsonSerializerOptions options, ref Rea JsonClassInfo classInfo = state.Current.JsonClassInfo; + Debug.Assert(state.Current.IsProcessingDictionary() || state.Current.IsProcessingObject(ClassType.Object) || state.Current.IsProcessingObject(ClassType.Enumerable)); + if (state.Current.IsProcessingDictionary()) { object? dictValue = ReadStackFrame.CreateDictionaryValue(ref state); @@ -53,9 +55,15 @@ private static void HandleStartDictionary(JsonSerializerOptions options, ref Rea state.Current.ReturnValue = classInfo.CreateObject(); } - else + else if (state.Current.IsProcessingObject(ClassType.Enumerable)) { - ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(classInfo.Type); + // Array with metadata within the dictionary. + HandleStartObjectInEnumerable(ref state, options, classInfo.Type); + + Debug.Assert(options.ReferenceHandling.ShouldReadPreservedReferences()); + Debug.Assert(state.Current.JsonClassInfo!.Type.GetGenericTypeDefinition() == typeof(JsonPreservableArrayReference<>)); + + state.Current.ReturnValue = state.Current.JsonClassInfo.CreateObject!(); } return; diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleMetadata.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleMetadata.cs new file mode 100644 index 0000000000000..898b1b26e9426 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleMetadata.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; + +namespace System.Text.Json +{ + public static partial class JsonSerializer + { + private static void HandleMetadataPropertyValue(ref Utf8JsonReader reader, ref ReadStack state) + { + Debug.Assert(state.Current.JsonClassInfo!.Options.ReferenceHandling.ShouldReadPreservedReferences()); + + if (reader.TokenType != JsonTokenType.String) + { + ThrowHelper.ThrowJsonException_MetadataValueWasNotString(reader.TokenType); + } + + MetadataPropertyName metadata = state.Current.LastSeenMetadataProperty; + string key = reader.GetString()!; + Debug.Assert(metadata == MetadataPropertyName.Id || metadata == MetadataPropertyName.Ref); + + if (metadata == MetadataPropertyName.Id) + { + // Special case for dictionary properties since those do not push into the ReadStack. + // There is no need to check for enumerables since those will always be wrapped into JsonPreservableArrayReference which turns enumerables into objects. + object value = state.Current.IsProcessingProperty(ClassType.Dictionary) ? + state.Current.JsonPropertyInfo!.GetValueAsObject(state.Current.ReturnValue)! : + state.Current.ReturnValue!; + + state.ReferenceResolver.AddReferenceOnDeserialize(key, value); + } + else if (metadata == MetadataPropertyName.Ref) + { + state.Current.ReferenceId = key; + } + } + + private static MetadataPropertyName GetMetadataPropertyName(ReadOnlySpan propertyName, ref ReadStack state, ref Utf8JsonReader reader) + { + Debug.Assert(state.Current.JsonClassInfo!.Options.ReferenceHandling.ShouldReadPreservedReferences()); + + if (state.Current.ReferenceId != null) + { + ThrowHelper.ThrowJsonException_MetadataReferenceObjectCannotContainOtherProperties(); + } + + if (propertyName.Length > 0 && propertyName[0] == '$') + { + switch (propertyName.Length) + { + case 3: + if (propertyName[1] == 'i' && + propertyName[2] == 'd') + { + return MetadataPropertyName.Id; + } + break; + + case 4: + if (propertyName[1] == 'r' && + propertyName[2] == 'e' && + propertyName[3] == 'f') + { + return MetadataPropertyName.Ref; + } + break; + + case 7: + // Only enumerables wrapped in JsonPreservableArrayReference are allowed to understand $values as metadata. + if (state.Current.IsPreservedArray && + propertyName[1] == 'v' && + propertyName[2] == 'a' && + propertyName[3] == 'l' && + propertyName[4] == 'u' && + propertyName[5] == 'e' && + propertyName[6] == 's') + { + return MetadataPropertyName.Values; + } + break; + } + + ThrowHelper.ThrowJsonException_MetadataInvalidPropertyWithLeadingDollarSign(propertyName, ref state, in reader); + } + + return MetadataPropertyName.NoMetadata; + } + + private static void HandleReference(ref ReadStack state) + { + Debug.Assert(state.Current.JsonClassInfo!.Options.ReferenceHandling.ShouldReadPreservedReferences()); + + object referenceValue = state.ReferenceResolver.ResolveReferenceOnDeserialize(state.Current.ReferenceId!); + if (state.Current.IsProcessingProperty(ClassType.Dictionary)) + { + ApplyObjectToEnumerable(referenceValue, ref state, setPropertyDirectly: true); + state.Current.EndProperty(); + } + else + { + state.Current.ReturnValue = referenceValue; + HandleEndObject(ref state); + } + + // Set back to null to no longer treat subsequent objects as references. + state.Current.ReferenceId = null; + } + + internal static JsonPropertyInfo GetValuesPropertyInfoFromJsonPreservableArrayRef(ref ReadStackFrame current) + { + Debug.Assert(current.JsonClassInfo!.Options.ReferenceHandling.ShouldReadPreservedReferences()); + Debug.Assert(current.JsonClassInfo.Type.GetGenericTypeDefinition() == typeof(JsonPreservableArrayReference<>)); + + JsonPropertyInfo info = current.JsonClassInfo.PropertyCacheArray![0]; + + Debug.Assert(info == current.JsonClassInfo.PropertyCache!["Values"]); + Debug.Assert(info.ClassType == ClassType.Enumerable); + + return info; + } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleObject.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleObject.cs index 9d351b873ac87..8791858f102f6 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleObject.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleObject.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Diagnostics; +using System.Runtime.CompilerServices; namespace System.Text.Json { @@ -18,16 +19,7 @@ private static void HandleStartObject(JsonSerializerOptions options, ref ReadSta if (state.Current.IsProcessingEnumerable()) { // A nested object within an enumerable (non-dictionary). - - if (!state.Current.CollectionPropertyInitialized) - { - // We have bad JSON: enumerable element appeared without preceding StartArray token. - ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(state.Current.JsonPropertyInfo!.DeclaredPropertyType); - } - - Type objType = state.Current.GetElementType(); - state.Push(); - state.Current.Initialize(objType, options); + HandleStartObjectInEnumerable(ref state, options, state.Current.JsonPropertyInfo!.DeclaredPropertyType); } else if (state.Current.JsonPropertyInfo != null) { @@ -41,6 +33,8 @@ private static void HandleStartObject(JsonSerializerOptions options, ref ReadSta JsonClassInfo classInfo = state.Current.JsonClassInfo!; + Debug.Assert(state.Current.IsProcessingObject(ClassType.Dictionary) || state.Current.IsProcessingObject(ClassType.Object) || state.Current.IsProcessingObject(ClassType.Enumerable)); + if (state.Current.IsProcessingObject(ClassType.Dictionary)) { object? value = ReadStackFrame.CreateDictionaryValue(ref state); @@ -63,10 +57,16 @@ private static void HandleStartObject(JsonSerializerOptions options, ref ReadSta state.Current.ReturnValue = classInfo.CreateObject(); } - else + else if (state.Current.IsProcessingObject(ClassType.Enumerable)) { - // Only dictionaries or objects are valid given the `StartObject` token. - ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(classInfo.Type); + // Nested array with metadata within another array with metadata. + HandleStartObjectInEnumerable(ref state, options, classInfo.Type); + + Debug.Assert(options.ReferenceHandling.ShouldReadPreservedReferences()); + Debug.Assert(state.Current.JsonClassInfo!.Type.GetGenericTypeDefinition() == typeof(JsonPreservableArrayReference<>)); + + state.Current.ReturnValue = state.Current.JsonClassInfo.CreateObject!(); + state.Current.IsNestedPreservedArray = true; } } @@ -74,8 +74,10 @@ private static void HandleEndObject(ref ReadStack state) { Debug.Assert(state.Current.JsonClassInfo != null); - // Only allow dictionaries to be processed here if this is the DataExtensionProperty. - Debug.Assert(!state.Current.IsProcessingDictionary() || state.Current.JsonClassInfo.DataExtensionProperty == state.Current.JsonPropertyInfo); + // Only allow dictionaries to be processed here if this is the DataExtensionProperty or if the dictionary is a preserved reference. + Debug.Assert(!state.Current.IsProcessingDictionary() || + state.Current.JsonClassInfo.DataExtensionProperty == state.Current.JsonPropertyInfo || + (state.Current.IsProcessingObject(ClassType.Dictionary) && state.Current.ReferenceId != null)); // Check if we are trying to build the sorted cache. if (state.Current.PropertyRefCache != null) @@ -83,7 +85,16 @@ private static void HandleEndObject(ref ReadStack state) state.Current.JsonClassInfo.UpdateSortedPropertyCache(ref state.Current); } - object? value = state.Current.ReturnValue; + object? value; + // Used for ReferenceHandling.Preserve + if (state.Current.IsPreservedArray) + { + value = GetPreservedArrayValue(ref state); + } + else + { + value = state.Current.ReturnValue; + } if (state.IsLastFrame) { @@ -92,9 +103,73 @@ private static void HandleEndObject(ref ReadStack state) } else { + // Set directly when handling non-nested preserved array + bool setPropertyDirectly = state.Current.IsPreservedArray && !state.Current.IsNestedPreservedArray; state.Pop(); - ApplyObjectToEnumerable(value, ref state); + ApplyObjectToEnumerable(value, ref state, setPropertyDirectly); + } + } + + private static object GetPreservedArrayValue(ref ReadStack state) + { + JsonPropertyInfo info = GetValuesPropertyInfoFromJsonPreservableArrayRef(ref state.Current); + object? value = info.GetValueAsObject(state.Current.ReturnValue); + + if (value == null) + { + ThrowHelper.ThrowJsonException_MetadataPreservedArrayValuesNotFound(info.DeclaredPropertyType); + } + + return value; + } + + private static void HandleStartPreservedArray(ref ReadStack state, JsonSerializerOptions options) + { + // Check we are not parsing into an immutable list or array. + if (state.Current.JsonPropertyInfo!.EnumerableConverter != null) + { + ThrowHelper.ThrowJsonException_MetadataCannotParsePreservedObjectIntoImmutable(state.Current.JsonPropertyInfo.DeclaredPropertyType); + } + Type preservedObjType = state.Current.JsonPropertyInfo.GetJsonPreservableArrayReferenceType(); + if (state.Current.IsProcessingProperty(ClassType.Enumerable)) + { + state.Push(); + state.Current.Initialize(preservedObjType, options); + } + else + { + // For array objects, we don't need to Push a new frame to the stack, + // so we just call Initialize again passing the wrapper class + // since we are going to handle the array at the moment we step into JsonPreservableArrayReference.Values. + state.Current.Initialize(preservedObjType, options); + } + + state.Current.IsPreservedArray = true; + } + + [PreserveDependency("get_Values", "System.Text.Json.JsonPreservableArrayReference`1")] + [PreserveDependency("set_Values", "System.Text.Json.JsonPreservableArrayReference`1")] + [PreserveDependency(".ctor()", "System.Text.Json.JsonPreservableArrayReference`1")] + private static void HandleStartObjectInEnumerable(ref ReadStack state, JsonSerializerOptions options, Type type) + { + if (!state.Current.CollectionPropertyInitialized) + { + if (options.ReferenceHandling.ShouldReadPreservedReferences()) + { + HandleStartPreservedArray(ref state, options); + } + else + { + // We have bad JSON: enumerable element appeared without preceding StartArray token. + ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(type); + } + } + else + { + Type objType = state.Current.GetElementType(); + state.Push(); + state.Current.Initialize(objType, options); } } } 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 aa9563e942900..f7d4c5014d985 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 @@ -35,6 +35,15 @@ private static void HandlePropertyName( state.Current.JsonPropertyInfo = state.Current.JsonClassInfo.PolicyProperty; } + if (options.ReferenceHandling.ShouldReadPreservedReferences()) + { + ReadOnlySpan propertyName = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + MetadataPropertyName metadata = GetMetadataPropertyName(propertyName, ref state, ref reader); + ResolveMetadataOnDictionary(metadata, ref state); + + state.Current.LastSeenMetadataProperty = metadata; + } + state.Current.KeyName = reader.GetString(); } else @@ -44,61 +53,90 @@ private static void HandlePropertyName( state.Current.EndProperty(); ReadOnlySpan propertyName = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; - if (reader._stringHasEscaping) + if (options.ReferenceHandling.ShouldReadPreservedReferences()) { - int idx = propertyName.IndexOf(JsonConstants.BackSlash); - Debug.Assert(idx != -1); - propertyName = GetUnescapedString(propertyName, idx); - } + MetadataPropertyName metadata = GetMetadataPropertyName(propertyName, ref state, ref reader); - JsonPropertyInfo jsonPropertyInfo = state.Current.JsonClassInfo.GetProperty(propertyName, ref state.Current); - if (jsonPropertyInfo == JsonPropertyInfo.s_missingProperty) - { - JsonPropertyInfo? dataExtProperty = state.Current.JsonClassInfo!.DataExtensionProperty; - if (dataExtProperty == null) + if (metadata == MetadataPropertyName.NoMetadata) { - state.Current.JsonPropertyInfo = JsonPropertyInfo.s_missingProperty; + if (state.Current.IsPreservedArray) + { + ThrowHelper.ThrowJsonException_MetadataPreservedArrayInvalidProperty(in reader, ref state); + } + + HandlePropertyNameDefault(propertyName, ref state, ref reader, options); } else { - state.Current.JsonPropertyInfo = dataExtProperty; - state.Current.JsonPropertyName = propertyName.ToArray(); - state.Current.KeyName = JsonHelpers.Utf8GetString(propertyName); - state.Current.CollectionPropertyInitialized = true; - - CreateDataExtensionProperty(dataExtProperty, ref state); + ResolveMetadataOnObject(metadata, ref state); } + + state.Current.LastSeenMetadataProperty = metadata; } else { - // Support JsonException.Path. - Debug.Assert( - jsonPropertyInfo.JsonPropertyName == null || - options.PropertyNameCaseInsensitive || - propertyName.SequenceEqual(jsonPropertyInfo.JsonPropertyName)); + HandlePropertyNameDefault(propertyName, ref state, ref reader, options); + } + } + } - state.Current.JsonPropertyInfo = jsonPropertyInfo; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void HandlePropertyNameDefault(ReadOnlySpan propertyName, ref ReadStack state, ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader._stringHasEscaping) + { + int idx = propertyName.IndexOf(JsonConstants.BackSlash); + Debug.Assert(idx != -1); + propertyName = GetUnescapedString(propertyName, idx); + } - if (jsonPropertyInfo.JsonPropertyName == null) + JsonPropertyInfo jsonPropertyInfo = state.Current.JsonClassInfo!.GetProperty(propertyName, ref state.Current); + if (jsonPropertyInfo == JsonPropertyInfo.s_missingProperty) + { + JsonPropertyInfo? dataExtProperty = state.Current.JsonClassInfo!.DataExtensionProperty; + if (dataExtProperty == null) + { + state.Current.JsonPropertyInfo = JsonPropertyInfo.s_missingProperty; + } + else + { + state.Current.JsonPropertyInfo = dataExtProperty; + state.Current.JsonPropertyName = propertyName.ToArray(); + state.Current.KeyName = JsonHelpers.Utf8GetString(propertyName); + state.Current.CollectionPropertyInitialized = true; + + CreateDataExtensionProperty(dataExtProperty, ref state); + } + } + else + { + // Support JsonException.Path. + Debug.Assert( + jsonPropertyInfo.JsonPropertyName == null || + options.PropertyNameCaseInsensitive || + propertyName.SequenceEqual(jsonPropertyInfo.JsonPropertyName)); + + state.Current.JsonPropertyInfo = jsonPropertyInfo; + + if (jsonPropertyInfo.JsonPropertyName == null) + { + byte[] propertyNameArray = propertyName.ToArray(); + if (options.PropertyNameCaseInsensitive) { - byte[] propertyNameArray = propertyName.ToArray(); - if (options.PropertyNameCaseInsensitive) - { - // Each payload can have a different name here; remember the value on the temporary stack. - state.Current.JsonPropertyName = propertyNameArray; - } - else - { - // Prevent future allocs by caching globally on the JsonPropertyInfo which is specific to a Type+PropertyName - // so it will match the incoming payload except when case insensitivity is enabled (which is handled above). - state.Current.JsonPropertyInfo.JsonPropertyName = propertyNameArray; - } + // Each payload can have a different name here; remember the value on the temporary stack. + state.Current.JsonPropertyName = propertyNameArray; + } + else + { + // Prevent future allocs by caching globally on the JsonPropertyInfo which is specific to a Type+PropertyName + // so it will match the incoming payload except when case insensitivity is enabled (which is handled above). + state.Current.JsonPropertyInfo.JsonPropertyName = propertyNameArray; } } - - // Increment the PropertyIndex so JsonClassInfo.GetProperty() starts with the next property. - state.Current.PropertyIndex++; } + + // Increment the PropertyIndex so JsonClassInfo.GetProperty() starts with the next property. + state.Current.PropertyIndex++; } private static void CreateDataExtensionProperty( @@ -126,5 +164,70 @@ private static void CreateDataExtensionProperty( // We don't add the value to the dictionary here because we need to support the read-ahead functionality for Streams. } + + private static void ResolveMetadataOnDictionary(MetadataPropertyName metadata, ref ReadStack state) + { + if (metadata == MetadataPropertyName.Id) + { + // Check we are not parsing into an immutable dictionary. + if (state.Current.JsonPropertyInfo!.DictionaryConverter != null) + { + ThrowHelper.ThrowJsonException_MetadataCannotParsePreservedObjectIntoImmutable(state.Current.JsonPropertyInfo.DeclaredPropertyType); + } + + if (state.Current.KeyName != null) + { + ThrowHelper.ThrowJsonException_MetadataIdIsNotFirstProperty_Dictionary(ref state.Current); + } + } + else if (metadata == MetadataPropertyName.Ref) + { + if (state.Current.KeyName != null) + { + ThrowHelper.ThrowJsonException_MetadataReferenceObjectCannotContainOtherProperties_Dictionary(ref state.Current); + } + } + } + + private static void ResolveMetadataOnObject(MetadataPropertyName metadata, ref ReadStack state) + { + if (metadata == MetadataPropertyName.Id) + { + if (state.Current.PropertyIndex > 0 || state.Current.LastSeenMetadataProperty != MetadataPropertyName.NoMetadata) + { + ThrowHelper.ThrowJsonException_MetadataIdIsNotFirstProperty(); + } + + state.Current.JsonPropertyName = ReadStack.s_idMetadataPropertyName; + } + else if (metadata == MetadataPropertyName.Values) + { + JsonPropertyInfo info = GetValuesPropertyInfoFromJsonPreservableArrayRef(ref state.Current); + state.Current.JsonPropertyName = ReadStack.s_valuesMetadataPropertyName; + state.Current.JsonPropertyInfo = info; + + // Throw after setting JsonPropertyName to show the correct JSON Path. + if (state.Current.LastSeenMetadataProperty != MetadataPropertyName.Id) + { + ThrowHelper.ThrowJsonException_MetadataMissingIdBeforeValues(); + } + } + else + { + Debug.Assert(metadata == MetadataPropertyName.Ref); + + if (state.Current.JsonClassInfo!.Type.IsValueType) + { + ThrowHelper.ThrowJsonException_MetadataInvalidReferenceToValueType(state.Current.JsonClassInfo.Type); + } + + if (state.Current.PropertyIndex > 0 || state.Current.LastSeenMetadataProperty != MetadataPropertyName.NoMetadata) + { + ThrowHelper.ThrowJsonException_MetadataReferenceObjectCannotContainOtherProperties(); + } + + state.Current.JsonPropertyName = ReadStack.s_refMetadataPropertyName; + } + } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleValue.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleValue.cs index 38e25cbe6d02b..2650b838aabfe 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleValue.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.HandleValue.cs @@ -18,6 +18,14 @@ private static void HandleValue(JsonTokenType tokenType, JsonSerializerOptions o return; } + if (state.Current.LastSeenMetadataProperty == MetadataPropertyName.Id || state.Current.LastSeenMetadataProperty == MetadataPropertyName.Ref) + { + Debug.Assert(options.ReferenceHandling.ShouldReadPreservedReferences()); + + HandleMetadataPropertyValue(ref reader, ref state); + return; + } + JsonPropertyInfo? jsonPropertyInfo = state.Current.JsonPropertyInfo; Debug.Assert(state.Current.JsonClassInfo != null); if (jsonPropertyInfo == null) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Helpers.cs index 350946775e7db..0f2339ac9ed61 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Helpers.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; + namespace System.Text.Json { public static partial class JsonSerializer @@ -12,6 +14,10 @@ public static partial class JsonSerializer ref Utf8JsonReader reader) { ReadStack state = default; + if (options.ReferenceHandling.ShouldReadPreservedReferences()) + { + state.ReferenceResolver = new DefaultReferenceResolver(writing: false); + } state.Current.Initialize(returnType, options); ReadCore(options, ref reader, ref state); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Stream.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Stream.cs index 67151485123c1..008bef29aacff 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Stream.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Stream.cs @@ -4,8 +4,8 @@ using System.Buffers; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -85,6 +85,11 @@ private static async ValueTask ReadAsync( } ReadStack readStack = default; + if (options.ReferenceHandling.ShouldReadPreservedReferences()) + { + readStack.ReferenceResolver = new DefaultReferenceResolver(writing: false); + } + readStack.Current.Initialize(returnType, options); var readerState = new JsonReaderState(options.GetReaderOptions()); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Utf8JsonReader.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Utf8JsonReader.cs index fc84903a8c572..2c9d78845c772 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Utf8JsonReader.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.Utf8JsonReader.cs @@ -5,6 +5,7 @@ using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; namespace System.Text.Json { @@ -111,6 +112,10 @@ public static TValue Deserialize(ref Utf8JsonReader reader, JsonSerializ } ReadStack readStack = default; + if (options.ReferenceHandling.ShouldReadPreservedReferences()) + { + readStack.ReferenceResolver = new DefaultReferenceResolver(writing: false); + } readStack.Current.Initialize(returnType, options); ReadValueCore(options, ref reader, ref readStack); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.cs index 0090ac206a811..54683977ed648 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Read.cs @@ -43,6 +43,11 @@ private static void ReadCore( JsonTokenType tokenType = reader.TokenType; + if (options.ReferenceHandling.ShouldReadPreservedReferences()) + { + CheckValidTokenAfterMetadataValues(ref readStack, tokenType); + } + if (JsonHelpers.IsInRangeInclusive(tokenType, JsonTokenType.String, JsonTokenType.False)) { Debug.Assert(tokenType == JsonTokenType.String || tokenType == JsonTokenType.Number || tokenType == JsonTokenType.True || tokenType == JsonTokenType.False); @@ -87,6 +92,12 @@ private static void ReadCore( // A non-dictionary property can also have EndProperty() called when completed, although it is redundant. readStack.Current.EndProperty(); } + else if (readStack.Current.ReferenceId != null) + { + Debug.Assert(options.ReferenceHandling.ShouldReadPreservedReferences()); + + HandleReference(ref readStack); + } else if (readStack.Current.IsProcessingDictionary()) { HandleEndDictionary(options, ref readStack); @@ -202,5 +213,18 @@ private static ReadOnlySpan GetUnescapedString(ReadOnlySpan utf8Sour return propertyName; } + + private static void CheckValidTokenAfterMetadataValues(ref ReadStack state, JsonTokenType tokenType) + { + if (state.Current.LastSeenMetadataProperty == MetadataPropertyName.Values) + { + if (tokenType != JsonTokenType.StartArray) + { + ThrowHelper.ThrowJsonException_MetadataValuesInvalidToken(tokenType); + } + + state.Current.LastSeenMetadataProperty = MetadataPropertyName.NoMetadata; + } + } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleDictionary.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleDictionary.cs index 24bda6dadacdb..85622ba8b6272 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleDictionary.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleDictionary.cs @@ -39,16 +39,26 @@ private static bool HandleDictionary( return true; } + if (state.Current.ExtensionDataStatus != ExtensionDataWriteStatus.Writing) + { + if (options.ReferenceHandling.ShouldWritePreservedReferences()) + { + if (WriteReference(ref state, writer, options, ClassType.Dictionary, enumerable)) + { + return WriteEndDictionary(ref state); + } + } + else + { + state.Current.WriteObjectOrArrayStart(ClassType.Dictionary, writer, options); + } + } + // Let the dictionary return the default IEnumerator from its IEnumerable.GetEnumerator(). // For IDictionary-derived classes this is normally be IDictionaryEnumerator. // For IDictionary-derived classes this is normally IDictionaryEnumerator as well // but may be IEnumerable> if the dictionary only supports generics. state.Current.CollectionEnumerator = enumerable.GetEnumerator(); - - if (state.Current.ExtensionDataStatus != ExtensionDataWriteStatus.Writing) - { - state.Current.WriteObjectOrArrayStart(ClassType.Dictionary, writer, options); - } } if (state.Current.CollectionEnumerator.MoveNext()) @@ -108,6 +118,11 @@ private static bool HandleDictionary( writer.WriteEndObject(); } + return WriteEndDictionary(ref state); + } + + private static bool WriteEndDictionary(ref WriteStack state) + { if (state.Current.PopStackOnEndCollection) { state.Pop(); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleEnumerable.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleEnumerable.cs index 2fb2973140f3a..d01d44727b42b 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleEnumerable.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleEnumerable.cs @@ -39,9 +39,19 @@ private static bool HandleEnumerable( return true; } - state.Current.CollectionEnumerator = enumerable.GetEnumerator(); + if (options.ReferenceHandling.ShouldWritePreservedReferences()) + { + if (WriteReference(ref state, writer, options, ClassType.Enumerable, enumerable)) + { + return WriteEndArray(ref state); + } + } + else + { + state.Current.WriteObjectOrArrayStart(ClassType.Enumerable, writer, options); + } - state.Current.WriteObjectOrArrayStart(ClassType.Enumerable, writer, options); + state.Current.CollectionEnumerator = enumerable.GetEnumerator(); } if (state.Current.CollectionEnumerator.MoveNext()) @@ -75,6 +85,17 @@ private static bool HandleEnumerable( // We are done enumerating. writer.WriteEndArray(); + // Used for ReferenceHandling.Preserve + if (state.Current.WriteWrappingBraceOnEndPreservedArray) + { + writer.WriteEndObject(); + } + + return WriteEndArray(ref state); + } + + private static bool WriteEndArray(ref WriteStack state) + { if (state.Current.PopStackOnEndCollection) { state.Pop(); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleObject.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleObject.cs index 62b06e776c284..dd42b6ddc7b2a 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleObject.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleObject.cs @@ -28,7 +28,18 @@ private static bool WriteObject( return WriteEndObject(ref state); } - state.Current.WriteObjectOrArrayStart(ClassType.Object, writer, options); + if (options.ReferenceHandling.ShouldWritePreservedReferences()) + { + if (WriteReference(ref state, writer, options, ClassType.Object, state.Current.CurrentValue)) + { + return WriteEndObject(ref state); + } + } + else + { + state.Current.WriteObjectOrArrayStart(ClassType.Object, writer, options); + } + state.Current.MoveToNextProperty = true; } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Helpers.cs index 56d4be68b924f..696b773009c9a 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Helpers.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Diagnostics; +using System.Text.Json.Serialization; namespace System.Text.Json { @@ -128,6 +129,10 @@ private static void WriteCore(Utf8JsonWriter writer, object? value, Type type, J } WriteStack state = default; + if (options.ReferenceHandling.ShouldWritePreservedReferences()) + { + state.ReferenceResolver = new DefaultReferenceResolver(writing: true); + } Debug.Assert(type != null); state.Current.Initialize(type, options); state.Current.CurrentValue = value; @@ -137,5 +142,29 @@ private static void WriteCore(Utf8JsonWriter writer, object? value, Type type, J writer.Flush(); } + + private static bool WriteReference(ref WriteStack state, Utf8JsonWriter writer, JsonSerializerOptions options, ClassType classType, object currentValue) + { + // Avoid emitting metadata for value types. + Type currentType = state.Current.JsonPropertyInfo?.DeclaredPropertyType ?? state.Current.JsonClassInfo!.Type; + if (currentType.IsValueType) + { + // Value type, fallback on regular Write method. + state.Current.WriteObjectOrArrayStart(classType, writer, options); + return false; + } + + if (state.ReferenceResolver.TryGetOrAddReferenceOnSerialize(currentValue, out string referenceId)) + { + // Object written before, write { "$ref": "#" } and jump to the next property/element. + state.Current.WriteReferenceObject(writer, options, referenceId); + return true; + } + + // New object reference, write start and append $id. + // OR New array reference, write as object and append $id and $values; at the end writes EndObject token using WriteWrappingBraceOnEndCollection. + state.Current.WritePreservedObjectOrArrayStart(classType, writer, options, referenceId); + return false; + } } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Stream.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Stream.cs index d7c1b24500cdb..4677e032a4843 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Stream.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Stream.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.IO; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -70,6 +71,10 @@ private static async Task WriteAsyncCore(Stream utf8Json, object? value, Type in } WriteStack state = default; + if (options.ReferenceHandling.ShouldWritePreservedReferences()) + { + state.ReferenceResolver = new DefaultReferenceResolver(writing: true); + } state.Current.Initialize(inputType, options); state.Current.CurrentValue = value; 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 edbde98fbe392..460cd54acbfac 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 @@ -25,6 +25,7 @@ public sealed partial class JsonSerializerOptions private JsonNamingPolicy? _dictionayKeyPolicy; private JsonNamingPolicy? _jsonPropertyNamingPolicy; private JsonCommentHandling _readCommentHandling; + private ReferenceHandling _referenceHandling = ReferenceHandling.Default; private JavaScriptEncoder? _encoder; private int _defaultBufferSize = BufferSizeDefault; private int _maxDepth; @@ -297,6 +298,19 @@ public bool WriteIndented } } + /// + /// Defines how references are treated when reading and writing JSON, this is convenient to deal with circularity. + /// + public ReferenceHandling ReferenceHandling + { + get => _referenceHandling; + set + { + VerifyMutable(); + _referenceHandling = value ?? throw new ArgumentNullException(nameof(value)); + } + } + internal MemberAccessor MemberAccessorStrategy { get diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/MetadataPropertyName.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/MetadataPropertyName.cs new file mode 100644 index 0000000000000..1366425397c4b --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/MetadataPropertyName.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace System.Text.Json +{ + internal enum MetadataPropertyName + { + NoMetadata, + Values, + Id, + Ref, + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStack.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStack.cs index 1063ffd7d5177..8303e65ec81cf 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStack.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStack.cs @@ -5,6 +5,7 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics; +using System.Text.Json.Serialization; namespace System.Text.Json { @@ -13,12 +14,20 @@ internal struct ReadStack { internal static readonly char[] SpecialCharacters = { '.', ' ', '\'', '/', '"', '[', ']', '(', ')', '\t', '\n', '\r', '\f', '\b', '\\', '\u0085', '\u2028', '\u2029' }; + internal static byte[] s_idMetadataPropertyName = { (byte)'$', (byte)'i', (byte)'d' }; + internal static byte[] s_refMetadataPropertyName = { (byte)'$', (byte)'r', (byte)'e', (byte)'f' }; + internal static byte[] s_valuesMetadataPropertyName = { (byte)'$', (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', (byte)'s' }; + // A field is used instead of a property to avoid value semantics. public ReadStackFrame Current; private List _previous; public int _index; + // The bag of preservable references. It needs to be kept in the state and never in JsonSerializerOptions because + // the options should not have any per-serialization state since every serialization shares the same immutable state on the options. + public DefaultReferenceResolver ReferenceResolver; + public void Push() { if (_previous == null) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStackFrame.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStackFrame.cs index 76844bd608ea2..84f6638c11ebd 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStackFrame.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReadStackFrame.cs @@ -37,6 +37,12 @@ internal struct ReadStackFrame // The current JSON data for a property does not match a given POCO, so ignore the property (recursively). public bool Drain; + // Preserve Reference + public bool IsPreservedArray; + public bool IsNestedPreservedArray; + public MetadataPropertyName LastSeenMetadataProperty; + public string? ReferenceId; + // Support IDictionary constructible types, i.e. types that we // support by passing and IDictionary to their constructors: // immutable dictionaries, Hashtable, SortedList @@ -165,6 +171,8 @@ public void Reset() JsonClassInfo = null; PropertyRefCache = null; ReturnValue = null; + IsPreservedArray = false; + IsNestedPreservedArray = false; EndObject(); } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReferenceEqualsEqualityComparer.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReferenceEqualsEqualityComparer.cs new file mode 100644 index 0000000000000..9fd6ececc633a --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReferenceEqualsEqualityComparer.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. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace System.Text.Json.Serialization +{ + /// + /// Passed to the meant for serialization. + /// It forces the dictionary to do a ReferenceEquals comparison when comparing the TKey object. + /// + internal sealed class ReferenceEqualsEqualityComparer : IEqualityComparer + { + public static ReferenceEqualsEqualityComparer Comparer = new ReferenceEqualsEqualityComparer(); + + bool IEqualityComparer.Equals(T x, T y) + { + return ReferenceEquals(x, y); + } + + int IEqualityComparer.GetHashCode(T obj) + { + return obj!.GetHashCode(); + } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReferenceHandling.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReferenceHandling.cs new file mode 100644 index 0000000000000..2928b0f275814 --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/ReferenceHandling.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace System.Text.Json.Serialization +{ + /// + /// This class defines how the deals with references on serialization and deserialization. + /// + public sealed class ReferenceHandling + { + /// + /// Serialization does not support objects with cycles and does not preserve duplicate references. Metadata properties will not be written when serializing reference types and will be treated as regular properties on deserialize. + /// + /// + /// * On Serialize: + /// Treats duplicate object references as if they were unique and writes all their properties. + /// The serializer throws a if an object contains a cycle. + /// * On Deserialize: + /// Metadata properties (`$id`, `$values`, and `$ref`) will not be consumed and therefore will be treated as regular JSON properties. + /// The metadata properties can map to a real property on the returned object if the property names match, or will be added to the overflow dictionary, if one exists; otherwise, they are ignored. + /// + public static ReferenceHandling Default { get; } = new ReferenceHandling(PreserveReferencesHandling.None); + + /// + /// Metadata properties will be honored when deserializing JSON objects and arrays into reference types and written when serializing reference types. This is necessary to create round-trippable JSON from objects that contain cycles or duplicate references. + /// + /// + /// * On Serialize: + /// When writing complex reference types, the serializer also writes metadata properties (`$id`, `$values`, and `$ref`) within them. + /// The output JSON will contain an extra `$id` property for every object, and for every enumerable type the JSON array emitted will be nested within a JSON object containing an `$id` and `$values` property. + /// is used to determine whether objects are identical. + /// When an object is identical to a previously serialized one, a pointer (`$ref`) to the identifier (`$id`) of such object is written instead. + /// No metadata properties are written for value types. + /// * On Deserialize: + /// The metadata properties within the JSON that are used to preserve duplicated references and cycles will be honored as long as they are well-formed**. + /// For JSON objects that don't contain any metadata properties, the deserialization behavior is identical to . + /// For value types: + /// * The `$id` metadata property is ignored. + /// * A is thrown if a `$ref` metadata property is found within the JSON object. + /// * For enumerable value types, the `$values` metadata property is ignored. + /// ** For the metadata properties within the JSON to be considered well-formed, they must follow these rules: + /// 1) The `$id` metadata property must be the first property in the JSON object. + /// 2) A JSON object that contains a `$ref` metadata property must not contain any other properties. + /// 3) The value of the `$ref` metadata property must refer to an `$id` that has appeared earlier in the JSON. + /// 4) The value of the `$id` and `$ref` metadata properties must be a JSON string. + /// 5) For enumerable types, such as , the JSON array must be nested within a JSON object containing an `$id` and `$values` metadata property, in that order. + /// 6) For enumerable types, the `$values` metadata property must be a JSON array. + /// 7) The `$values` metadata property is only valid when referring to enumerable types. + /// If the JSON is not well-formed, a is thrown. + /// + public static ReferenceHandling Preserve { get; } = new ReferenceHandling(PreserveReferencesHandling.All); + + private readonly PreserveReferencesHandling _preserveHandlingOnSerialize; + private readonly PreserveReferencesHandling _preserveHandlingOnDeserialize; + + /// + /// Creates a new instance of using the specified + /// + /// The specified behavior for write/read preserved references. + private ReferenceHandling(PreserveReferencesHandling handling) : this(handling, handling) { } + + // For future, someone may want to define their own custom Handler with different behaviors of PreserveReferenceHandling on Serialize vs Deserialize. + private ReferenceHandling(PreserveReferencesHandling preserveHandlingOnSerialize, PreserveReferencesHandling preserveHandlingOnDeserialize) + { + _preserveHandlingOnSerialize = preserveHandlingOnSerialize; + _preserveHandlingOnDeserialize = preserveHandlingOnDeserialize; + } + + internal bool ShouldReadPreservedReferences() + { + return _preserveHandlingOnDeserialize == PreserveReferencesHandling.All; + } + + internal bool ShouldWritePreservedReferences() + { + return _preserveHandlingOnSerialize == PreserveReferencesHandling.All; + } + } + + /// + /// Defines behaviors to preserve references of JSON complex types. + /// + internal enum PreserveReferencesHandling + { + /// + /// Preserved objects and arrays will not be written/read. + /// + None = 0, + /// + /// Preserved objects and arrays will be written/read. + /// + All = 1, + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs index b35aa09baa555..1995ac01ca5e8 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStack.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; +using System.Text.Json.Serialization; namespace System.Text.Json { @@ -15,6 +16,10 @@ internal struct WriteStack private List _previous; private int _index; + // The bag of preservable references. It needs to be kept in the state and never in JsonSerializerOptions because + // the options should not have any per-serialization state since every serialization shares the same immutable state on the options. + public DefaultReferenceResolver ReferenceResolver; + public void Push() { if (_previous == null) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs index 3a5adcea79dc1..75354126a7d2a 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/WriteStackFrame.cs @@ -28,11 +28,18 @@ internal struct WriteStackFrame public bool StartObjectWritten; public bool MoveToNextProperty; + public bool WriteWrappingBraceOnEndPreservedArray; + // The current property. public int PropertyEnumeratorIndex; public ExtensionDataWriteStatus ExtensionDataStatus; public JsonPropertyInfo? JsonPropertyInfo; + // Pre-encoded metadata properties. + private static readonly JsonEncodedText s_metadataId = JsonEncodedText.Encode("$id", encoder: null); + private static readonly JsonEncodedText s_metadataRef = JsonEncodedText.Encode("$ref", encoder: null); + private static readonly JsonEncodedText s_metadataValues = JsonEncodedText.Encode("$values", encoder: null); + public void Initialize(Type type, JsonSerializerOptions options) { JsonClassInfo = options.GetOrAddClass(type); @@ -89,6 +96,56 @@ private void WriteObjectOrArrayStart(ClassType classType, JsonEncodedText proper } } + public void WritePreservedObjectOrArrayStart(ClassType classType, Utf8JsonWriter writer, JsonSerializerOptions options, string referenceId) + { + if (JsonPropertyInfo?.EscapedName.HasValue == true) + { + writer.WriteStartObject(JsonPropertyInfo.EscapedName!.Value); + } + else if (KeyName != null) + { + writer.WriteStartObject(KeyName); + } + else + { + writer.WriteStartObject(); + } + + + writer.WriteString(s_metadataId, referenceId); + + if ((classType & (ClassType.Object | ClassType.Dictionary)) != 0) + { + StartObjectWritten = true; + } + else + { + // Wrap array into an object with $id and $values metadata properties. + Debug.Assert(classType == ClassType.Enumerable); + writer.WriteStartArray(s_metadataValues); + WriteWrappingBraceOnEndPreservedArray = true; + } + } + + public void WriteReferenceObject(Utf8JsonWriter writer, JsonSerializerOptions options, string referenceId) + { + if (JsonPropertyInfo?.EscapedName.HasValue == true) + { + writer.WriteStartObject(JsonPropertyInfo.EscapedName!.Value); + } + else if (KeyName != null) + { + writer.WriteStartObject(KeyName); + } + else + { + writer.WriteStartObject(); + } + + writer.WriteString(s_metadataRef, referenceId); + writer.WriteEndObject(); + } + public void Reset() { CurrentValue = null; @@ -109,6 +166,7 @@ public void EndProperty() JsonPropertyInfo = null; KeyName = null; MoveToNextProperty = false; + WriteWrappingBraceOnEndPreservedArray = false; } public void EndDictionary() 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 4e9bca3fa3c9c..a03e0c3e233d3 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 @@ -81,9 +81,20 @@ public static void ThrowJsonException_SerializationConverterWrite(JsonConverter? [DoesNotReturn] [MethodImpl(MethodImplOptions.NoInlining)] - public static void ThrowJsonException() + public static void ThrowJsonException(string? message = null) { - throw new JsonException(); + JsonException ex; + if (string.IsNullOrEmpty(message)) + { + ex = new JsonException(); + } + else + { + ex = new JsonException(message); + ex.AppendPathInformation = true; + } + + throw ex; } [DoesNotReturn] @@ -280,5 +291,122 @@ public static void ThrowNotSupportedException_DeserializeCreateObjectDelegateIsN throw new NotSupportedException(SR.Format(SR.DeserializeMissingParameterlessConstructor, invalidType)); } } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataValuesInvalidToken(JsonTokenType tokenType) + { + ThrowJsonException(SR.Format(SR.MetadataInvalidTokenAfterValues, tokenType)); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataReferenceNotFound(string id) + { + ThrowJsonException(SR.Format(SR.MetadataReferenceNotFound, id)); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataValueWasNotString(JsonTokenType tokenType) + { + ThrowJsonException(SR.Format(SR.MetadataValueWasNotString, tokenType)); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataReferenceObjectCannotContainOtherProperties() + { + ThrowJsonException(SR.MetadataReferenceCannotContainOtherProperties); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataReferenceObjectCannotContainOtherProperties_Dictionary(ref ReadStackFrame current) + { + current.KeyName = null; + ThrowJsonException_MetadataReferenceObjectCannotContainOtherProperties(); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataIdIsNotFirstProperty() + { + ThrowJsonException(SR.MetadataIdIsNotFirstProperty); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataIdIsNotFirstProperty_Dictionary(ref ReadStackFrame current) + { + current.KeyName = null; + ThrowJsonException_MetadataIdIsNotFirstProperty(); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataMissingIdBeforeValues() + { + ThrowJsonException(SR.MetadataPreservedArrayPropertyNotFound); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataInvalidPropertyWithLeadingDollarSign(ReadOnlySpan propertyName, ref ReadStack state, in Utf8JsonReader reader) + { + // Set PropertyInfo or KeyName to write down the conflicting property name in JsonException.Path + if (state.Current.IsProcessingDictionary()) + { + state.Current.KeyName = reader.GetString(); + } + else + { + state.Current.JsonPropertyName = propertyName.ToArray(); + } + + ThrowJsonException(SR.MetadataInvalidPropertyWithLeadingDollarSign); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataDuplicateIdFound(string id) + { + ThrowJsonException(SR.Format(SR.MetadataDuplicateIdFound, id)); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataInvalidReferenceToValueType(Type propertyType) + { + ThrowJsonException(SR.Format(SR.MetadataInvalidReferenceToValueType, propertyType)); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataPreservedArrayInvalidProperty(in Utf8JsonReader reader, ref ReadStack state) + { + string propertyName = reader.GetString()!; + Type propertyType = JsonSerializer.GetValuesPropertyInfoFromJsonPreservableArrayRef(ref state.Current).DeclaredPropertyType; + + ThrowJsonException(SR.Format(SR.MetadataPreservedArrayFailed, + SR.Format(SR.MetadataPreservedArrayInvalidProperty, propertyName), + SR.Format(SR.DeserializeUnableToConvertValue, propertyType))); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataPreservedArrayValuesNotFound(Type propertyType) + { + ThrowJsonException(SR.Format(SR.MetadataPreservedArrayFailed, + SR.MetadataPreservedArrayPropertyNotFound, + SR.Format(SR.DeserializeUnableToConvertValue, propertyType))); + } + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowJsonException_MetadataCannotParsePreservedObjectIntoImmutable(Type propertyType) + { + ThrowJsonException(SR.Format(SR.MetadataCannotParsePreservedObjectToImmutable, propertyType)); + } } } diff --git a/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.Deserialize.cs b/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.Deserialize.cs new file mode 100644 index 0000000000000..ff14e2d4c810b --- /dev/null +++ b/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.Deserialize.cs @@ -0,0 +1,1429 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using Xunit; + +namespace System.Text.Json.Serialization.Tests +{ + public static partial class ReferenceHandlingTests + { + private static readonly JsonSerializerOptions s_deserializerOptionsPreserve = new JsonSerializerOptions { ReferenceHandling = ReferenceHandling.Preserve }; + + private class EmployeeWithContacts + { + public string Name { get; set; } + public EmployeeWithContacts Manager { get; set; } + public List Subordinates { get; set; } + public Dictionary Contacts { get; set; } + } + + #region Root Object + [Fact] //Employee list as a property and then use reference to itself on nested Employee. + public static void ObjectReferenceLoop() + { + string json = + @"{ + ""$id"": ""1"", + ""Name"": ""Angela"", + ""Manager"": { + ""$ref"": ""1"" + } + }"; + + Employee angela = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Same(angela, angela.Manager); + } + + [Fact] // Employee whose subordinates is a preserved list. EmployeeListEmployee + public static void ObjectReferenceLoopInList() + { + string json = + @"{ + ""$id"": ""1"", + ""Subordinates"": { + ""$id"": ""2"", + ""$values"": [ + { + ""$ref"": ""1"" + } + ] + } + }"; + + Employee employee = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Equal(1, employee.Subordinates.Count); + Assert.Same(employee, employee.Subordinates[0]); + } + + [Fact] // Employee whose subordinates is a preserved list. EmployeeListEmployee + public static void ObjectReferenceLoopInDictionary() + { + string json = + @"{ + ""$id"": ""1"", + ""Contacts"":{ + ""$id"": ""2"", + ""Angela"":{ + ""$ref"": ""1"" + } + } + }"; + + EmployeeWithContacts employee = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Same(employee, employee.Contacts["Angela"]); + } + + [Fact] //Employee list as a property and then use reference to itself on nested Employee. + public static void ObjectWithArrayReferenceDeeper() + { + string json = + @"{ + ""$id"": ""1"", + ""Subordinates"": { + ""$id"": ""2"", + ""$values"": [ + { + ""$id"": ""3"", + ""Name"": ""Angela"", + ""Subordinates"":{ + ""$ref"": ""2"" + } + } + ] + } + }"; + + Employee employee = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Same(employee.Subordinates, employee.Subordinates[0].Subordinates); + } + + [Fact] + public static void ObjectWithDictionaryReferenceDeeper() + { + string json = + @"{ + ""$id"": ""1"", + ""Contacts"": { + ""$id"": ""2"", + ""Angela"": { + ""$id"": ""3"", + ""Name"": ""Angela"", + ""Contacts"": { + ""$ref"": ""2"" + } + } + } + }"; + + EmployeeWithContacts employee = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Same(employee.Contacts, employee.Contacts["Angela"].Contacts); + } + + private class ClassWithSubsequentListProperties + { + public List MyList { get; set; } + public List MyListCopy { get; set; } + } + + [Fact] + public static void PreservedArrayIntoArrayProperty() + { + string json = @" + { + ""MyList"": { + ""$id"": ""1"", + ""$values"": [ + 10, + 20, + 30, + 40 + ] + }, + ""MyListCopy"": { ""$ref"": ""1"" } + }"; + + ClassWithSubsequentListProperties instance = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Equal(4, instance.MyList.Count); + Assert.Same(instance.MyList, instance.MyListCopy); + } + + [Fact] + public static void PreservedArrayIntoInitializedProperty() + { + string json = @"{ + ""$id"": ""1"", + ""SubordinatesString"": { + ""$id"": ""2"", + ""$values"": [ + ] + }, + ""Manager"": { + ""SubordinatesString"":{ + ""$ref"": ""2"" + } + } + }"; + + Employee employee = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + // presereved array. + Assert.Empty(employee.SubordinatesString); + // reference to preserved array. + Assert.Empty(employee.Manager.SubordinatesString); + Assert.Same(employee.Manager.SubordinatesString, employee.SubordinatesString); + } + + [Fact] // Verify ReadStackFrame.DictionaryPropertyIsPreserved is being reset properly. + public static void DictionaryPropertyOneAfterAnother() + { + string json = @"{ + ""$id"": ""1"", + ""Contacts"": { + ""$id"": ""2"" + }, + ""Contacts2"": { + ""$ref"": ""2"" + } + }"; + + Employee employee = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Same(employee.Contacts, employee.Contacts2); + + json = @"{ + ""$id"": ""1"", + ""Contacts"": { + ""$id"": ""2"" + }, + ""Contacts2"": { + ""$id"": ""3"" + } + }"; + + employee = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Equal(0, employee.Contacts.Count); + Assert.Equal(0, employee.Contacts2.Count); + } + + [Fact] + public static void ObjectPropertyLengthZero() + { + string json = @"{ + """": 1 + }"; + + ClassWithZeroLengthProperty root = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.Equal(1, root.ZeroLengthProperty); + } + + [Fact] + public static void TestJsonPathDoesNotFailOnMultiThreads() + { + const int ThreadCount = 8; + const int ConcurrentTestsCount = 4; + Task[] tasks = new Task[ThreadCount * ConcurrentTestsCount]; + + for (int i = 0; i < tasks.Length; i++) + { + tasks[i++] = Task.Run(() => TestIdTask()); + tasks[i++] = Task.Run(() => TestRefTask()); + tasks[i++] = Task.Run(() => TestIdTask()); + tasks[i] = Task.Run(() => TestRefTask()); + } + + Task.WaitAll(tasks); + } + + private static void TestIdTask() + { + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(@"{""$id"":1}", s_deserializerOptionsPreserve)); + Assert.Equal("$.$id", ex.Path); + } + + private static void TestRefTask() + { + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(@"{""$ref"":1}", s_deserializerOptionsPreserve)); + Assert.Equal("$.$ref", ex.Path); + } + #endregion + + #region Root Dictionary + [Fact] //Employee list as a property and then use reference to itself on nested Employee. + public static void DictionaryReferenceLoop() + { + string json = + @"{ + ""$id"": ""1"", + ""Angela"": { + ""$id"": ""2"", + ""Name"": ""Angela"", + ""Contacts"": { + ""$ref"": ""1"" + } + } + }"; + + Dictionary dictionary = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + + Assert.Same(dictionary, dictionary["Angela"].Contacts); + } + + [Fact] + public static void DictionaryReferenceLoopInList() + { + string json = + @"{ + ""$id"": ""1"", + ""Angela"": { + ""$id"": ""2"", + ""Name"": ""Angela"", + ""Subordinates"": { + ""$id"": ""3"", + ""$values"": [ + { + ""$id"": ""4"", + ""Name"": ""Bob"", + ""Contacts"": { + ""$ref"": ""1"" + } + } + ] + } + } + }"; + + Dictionary dictionary = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.Same(dictionary, dictionary["Angela"].Subordinates[0].Contacts); + } + + [Fact] + public static void DictionaryDuplicatedObject() + { + string json = + @"{ + ""555"": { ""$id"": ""1"", ""Name"": ""Angela"" }, + ""556"": { ""Name"": ""Bob"" }, + ""557"": { ""$ref"": ""1"" } + }"; + + Dictionary directory = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.Same(directory["555"], directory["557"]); + } + + [Fact] + public static void DictionaryOfArrays() + { + string json = + @"{ + ""$id"": ""1"", + ""Array1"": { + ""$id"": ""2"", + ""$values"": [] + }, + ""Array2"": { + ""$ref"": ""2"" + } + }"; + + Dictionary> dict = JsonSerializer.Deserialize>>(json, s_deserializerOptionsPreserve); + Assert.Same(dict["Array1"], dict["Array2"]); + } + + [Fact] + public static void DictionaryOfDictionaries() + { + string json = @"{ + ""$id"": ""1"", + ""Dictionary1"": { + ""$id"": ""2"", + ""value1"": 1, + ""value2"": 2, + ""value3"": 3 + }, + ""Dictionary2"": { + ""$ref"": ""2"" + } + }"; + + Dictionary> root = JsonSerializer.Deserialize>>(json, s_deserializerOptionsPreserve); + Assert.Same(root["Dictionary1"], root["Dictionary2"]); + } + + [Fact] + public static void DictionaryKeyLengthZero() + { + string json = @"{ + """": 1 + }"; + + Dictionary root = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.Equal(1, root[""]); + } + #endregion + + #region Root Array + [Fact] + public static void PreservedArrayIntoRootArray() + { + string json = @" + { + ""$id"": ""1"", + ""$values"": [ + 10, + 20, + 30, + 40 + ] + }"; + + List myList = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.Equal(4, myList.Count); + } + + [Fact] // Preserved list that contains an employee whose subordinates is a reference to the root list. + public static void ArrayNestedArray() + { + string json = + @"{ + ""$id"": ""1"", + ""$values"":[ + { + ""$id"":""2"", + ""Name"": ""Angela"", + ""Subordinates"": { + ""$ref"": ""1"" + } + } + ] + }"; + + List employees = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + + Assert.Same(employees, employees[0].Subordinates); + } + + [Fact] + public static void EmptyArray() + { + string json = + @"{ + ""$id"": ""1"", + ""Subordinates"": { + ""$id"": ""2"", + ""$values"": [] + }, + ""Name"": ""Angela"" + }"; + + Employee angela = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + + Assert.NotNull(angela); + Assert.NotNull(angela.Subordinates); + Assert.Equal(0, angela.Subordinates.Count); + } + + [Fact] + public static void ArrayWithDuplicates() //Make sure the serializer can understand lists that were wrapped in braces. + { + string json = + @"{ + ""$id"": ""1"", + ""$values"":[ + { + ""$id"": ""2"", + ""Name"": ""Angela"" + }, + { + ""$id"": ""3"", + ""Name"": ""Bob"" + }, + { + ""$ref"": ""2"" + }, + { + ""$ref"": ""3"" + }, + { + ""$id"": ""4"" + }, + { + ""$ref"": ""4"" + } + ] + }"; + + List employees = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.Equal(6, employees.Count); + Assert.Same(employees[0], employees[2]); + Assert.Same(employees[1], employees[3]); + Assert.Same(employees[4], employees[5]); + } + + [Fact] + public static void ArrayNotPreservedWithDuplicates() //Make sure the serializer can understand lists that were wrapped in braces. + { + string json = + @"[ + { + ""$id"": ""2"", + ""Name"": ""Angela"" + }, + { + ""$id"": ""3"", + ""Name"": ""Bob"" + }, + { + ""$ref"": ""2"" + }, + { + ""$ref"": ""3"" + }, + { + ""$id"": ""4"" + }, + { + ""$ref"": ""4"" + } + ]"; + + Employee[] employees = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Equal(6, employees.Length); + Assert.Same(employees[0], employees[2]); + Assert.Same(employees[1], employees[3]); + Assert.Same(employees[4], employees[5]); + } + + [Fact] + public static void ArrayWithNestedPreservedArray() + { + string json = @"{ + ""$id"": ""1"", + ""$values"": [ + { + ""$id"": ""2"", + ""$values"": [ 1, 2, 3 ] + } + ] + }"; + + List> root = JsonSerializer.Deserialize>>(json, s_deserializerOptionsPreserve); + Assert.Equal(1, root.Count); + Assert.Equal(3, root[0].Count); + } + + [Fact] + public static void ArrayWithNestedPreservedArrayAndReference() + { + string json = @"{ + ""$id"": ""1"", + ""$values"": [ + { + ""$id"": ""2"", + ""$values"": [ 1, 2, 3 ] + }, + { ""$ref"": ""2"" } + ] + }"; + + List> root = JsonSerializer.Deserialize>>(json, s_deserializerOptionsPreserve); + Assert.Equal(2, root.Count); + Assert.Equal(3, root[0].Count); + Assert.Same(root[0], root[1]); + } + + private class ListWrapper + { + public List> NestedList { get; set; } = new List> { new List { 1 } }; + } + + [Fact] + public static void ArrayWithNestedPreservedArrayAndDefaultValues() + { + string json = @"{ + ""$id"": ""1"", + ""NestedList"": { + ""$id"": ""2"", + ""$values"": [ + { + ""$id"": ""3"", + ""$values"": [ + 1, + 2, + 3 + ] + } + ] + } + }"; + + ListWrapper root = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Equal(1, root.NestedList.Count); + Assert.Equal(3, root.NestedList[0].Count); + } + + [Fact] + public static void ArrayWithMetadataWithinArray_UsingPreserve() + { + const string json = + @"[ + { + ""$id"": ""1"", + ""$values"": [] + } + ]"; + + List> root = JsonSerializer.Deserialize>>(json, s_serializerOptionsPreserve); + Assert.Equal(1, root.Count); + Assert.Equal(0, root[0].Count); + } + + [Fact] + public static void ObjectWithinArray_UsingDefault() + { + const string json = + @"[ + { + ""$id"": ""1"", + ""$values"": [] + } + ]"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>>(json)); + Assert.Equal("$[0]", ex.Path); + } + #endregion + + #region Converter + [Fact] //This only demonstrates that behavior with converters remain the same. + public static void DeserializeWithListConverter() + { + string json = + @"{ + ""$id"": ""1"", + ""Subordinates"": { + ""$id"": ""2"", + ""$values"": [ + { + ""$ref"": ""1"" + } + ] + }, + ""Name"": ""Angela"", + ""Manager"": { + ""Subordinates"": { + ""$ref"": ""2"" + } + } + }"; + + var options = new JsonSerializerOptions + { + ReferenceHandling = ReferenceHandling.Preserve, + Converters = { new ListOfEmployeeConverter() } + }; + Employee angela = JsonSerializer.Deserialize(json, options); + Assert.Equal(0, angela.Subordinates.Count); + Assert.Equal(0, angela.Manager.Subordinates.Count); + } + + //NOTE: If you implement a converter, you are on your own when handling metadata properties and therefore references.Newtonsoft does the same. + //However; is there a way to recall preserved references previously found in the payload and to store new ones found in the converter's payload? that would be a cool enhancement. + private class ListOfEmployeeConverter : JsonConverter> + { + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + int startObjectCount = 0; + int endObjectCount = 0; + + while (true) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + startObjectCount++; break; + case JsonTokenType.EndObject: + endObjectCount++; break; + } + + if (startObjectCount == endObjectCount) + { + break; + } + + reader.Read(); + } + + return new List(); + } + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + } + #endregion + + #region Null/non-existent reference + [Fact] + public static void ObjectNull() + { + string json = + @"{ + ""$ref"": ""1"" + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$ref", ex.Path); + } + + [Fact()] + public static void ArrayNull() + { + string json = + @"{ + ""$ref"": ""1"" + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$ref", ex.Path); + } + + [Fact] + public static void DictionaryNull() + { + string json = + @"{ + ""$ref"": ""1"" + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$ref", ex.Path); + } + + [Fact] + public static void ObjectPropertyNull() + { + string json = + @"{ + ""Manager"": { + ""$ref"": ""1"" + } + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Manager.$ref", ex.Path); + } + + [Fact] + public static void ArrayPropertyNull() + { + string json = + @"{ + ""Subordinates"": { + ""$ref"": ""1"" + } + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Subordinates.$ref", ex.Path); + } + + [Fact] + public static void DictionaryPropertyNull() + { + string json = + @"{ + ""Contacts"": { + ""$ref"": ""1"" + } + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Contacts.$ref", ex.Path); + } + + #endregion + + #region Throw cases + [Fact] + public static void JsonPath() + { + string json = @"[0"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$[0]", ex.Path); + } + + [Fact] + public static void JsonPathObject() + { + string json = @"{ ""Name"": ""A"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.Name", ex.Path); + } + + [Fact] + public static void JsonPathImcompletePropertyAfterMetadata() + { + string json = + @"{ + ""$id"": ""1"", + ""Nam"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$id", ex.Path); + } + + [Fact] + public static void JsonPathIncompleteMetadataAfterProperty() + { + string json = + @"{ + ""Name"": ""Angela"", + ""$i"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.Name", ex.Path); + } + + [Fact] + public static void JsonPathCompleteMetadataButNotValue() + { + string json = + @"{ + ""$id"":"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$id", ex.Path); + } + + [Fact] + public static void JsonPathIncompleteMetadataValue() + { + string json = + @"{ + ""$id"": ""1"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$id", ex.Path); + } + + [Fact] + public static void JsonPathNestedObject() + { + string json = @"{ ""Name"": ""A"", ""Manager"": { ""Name"": ""B"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.Manager.Name", ex.Path); + } + + [Fact] + public static void JsonPathNestedArray() + { + string json = @"{ ""Subordinates"":"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.Subordinates", ex.Path); + } + + [Fact] + public static void JsonPathPreservedArray() + { + string json = + @"{ + ""$id"": ""1"", + ""$values"":[ + 1"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$values[0]", ex.Path); + } + + [Fact] + public static void JsonPathIncompleteArrayId() + { + string json = + @"{ + ""$id"": ""1"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$id", ex.Path); + } + + [Fact] + public static void JsonPathIncompleteArrayValues() + { + string json = + @"{ + ""$id"": ""1"", + ""$values"":"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$values", ex.Path); + } + + [Fact] + public static void JsonPathCurlyBraceOnArray() + { + string json = "{"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$", ex.Path); + } + + [Fact] + public static void ThrowOnStructWithReference() + { + string json = + @"[ + { + ""$id"": ""1"", + ""Name"": ""Angela"" + }, + { + ""$ref"": ""1"" + } + ]"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + } + #endregion + + #region Throw on immutables + private class EmployeeWithImmutable + { + public ImmutableList Subordinates { get; set; } + public EmployeeWithImmutable[] SubordinatesArray { get; set; } + public ImmutableDictionary Contacts { get; set; } + } + + [Fact] + public static void ImmutableEnumerableAsRoot() + { + string json = + @"{ + ""$id"": ""1"", + ""$values"": [] + }"; + + JsonException ex; + + ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$", ex.Path); + Assert.Contains($"'{typeof(ImmutableList)}'", ex.Message); + + ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$", ex.Path); + Assert.Contains($"'{typeof(EmployeeWithImmutable[])}'", ex.Message); + } + + [Fact] + public static void ImmutableDictionaryAsRoot() + { + string json = + @"{ + ""$id"": ""1"", + ""Employee1"": {} + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$", ex.Path); + Assert.Contains($"'{typeof(ImmutableDictionary)}'", ex.Message); + } + + [Fact] + public static void ImmutableEnumerableAsProperty() + { + string json = + @"{ + ""$id"": ""1"", + ""Subordinates"": { + ""$id"": ""2"", + ""$values"": [] + } + }"; + + JsonException ex; + + ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Subordinates", ex.Path); + Assert.Contains($"'{typeof(ImmutableList)}'", ex.Message); + + json = + @"{ + ""$id"": ""1"", + ""SubordinatesArray"": { + ""$id"": ""2"", + ""$values"": [] + } + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.SubordinatesArray", ex.Path); + Assert.Contains($"'{typeof(EmployeeWithImmutable[])}'", ex.Message); + } + + [Fact] + public static void ImmutableDictionaryAsProperty() + { + string json = + @"{ + ""$id"": ""1"", + ""Contacts"": { + ""$id"": ""2"", + ""Employee1"": {} + } + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Contacts", ex.Path); + Assert.Contains($"'{typeof(ImmutableDictionary)}'", ex.Message); + } + + [Fact] + public static void ImmutableDictionaryPreserveNestedObjects() + { + string json = + @"{ + ""Angela"": { + ""$id"": ""1"", + ""Name"": ""Angela"", + ""Subordinates"": { + ""$id"": ""2"", + ""$values"": [ + { + ""$id"": ""3"", + ""Name"": ""Carlos"", + ""Manager"": { + ""$ref"": ""1"" + } + } + ] + } + }, + ""Bob"": { + ""$id"": ""4"", + ""Name"": ""Bob"" + }, + ""Carlos"": { + ""$ref"": ""3"" + } + }"; + + // Must not throw since the references are to nested objects, not the immutable dictionary itself. + ImmutableDictionary dictionary = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.Same(dictionary["Angela"], dictionary["Angela"].Subordinates[0].Manager); + Assert.Same(dictionary["Carlos"], dictionary["Angela"].Subordinates[0]); + } + + [Theory] + [ActiveIssue("https://github.com/dotnet/runtime/issues/1902")] + [InlineData(@"{""$id"": {}}", "$.$id")] + [InlineData(@"{""$id"": }", "$.$id")] + [InlineData(@"{""$id"": []}", "$.$id")] + [InlineData(@"{""$id"": ]", "$.$id")] + [InlineData(@"{""$id"": null}", "$.$id")] + [InlineData(@"{""$id"": true}", "$.$id")] + [InlineData(@"{""$id"": false}", "$.$id")] + [InlineData(@"{""$id"": 10}", "$.$id")] + [InlineData(@"{""$ref"": {}}", "$.$ref")] + [InlineData(@"{""$ref"": }", "$.$ref")] + [InlineData(@"{""$ref"": []}", "$.$ref")] + [InlineData(@"{""$ref"": ]", "$.$ref")] + [InlineData(@"{""$ref"": null}", "$.$ref")] + [InlineData(@"{""$ref"": true}", "$.$ref")] + [InlineData(@"{""$ref"": false}", "$.$ref")] + [InlineData(@"{""$ref"": 10}", "$.$ref")] + public static void IdAndRefContainInvalidToken(string json, string expectedPath) + { + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal(expectedPath, ex.Path); + } + #endregion + + #region Ground Rules/Corner cases + + private class Order + { + public int ProductId { get; set; } + public int Quantity { get; set; } + } + + [Fact] + public static void OnlyStringTypeIsAllowed() + { + string json = @"{ + ""$id"": 1, + ""ProductId"": 1, + ""Quantity"": 10 + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$id", ex.Path); + + json = @"[ + { + ""$id"": ""1"", + ""ProductId"": 1, + ""Quantity"": 10 + }, + { + ""$ref"": 1 + } + ]"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$[1].$ref", ex.Path); + } + + #region Reference objects ($ref) + [Fact] + public static void ReferenceObjectsShouldNotContainMoreProperties() + { + //Regular property before $ref + string json = @"{ + ""$id"": ""1"", + ""Name"": ""Angela"", + ""Manager"": { + ""Name"": ""Bob"", + ""$ref"": ""1"" + } + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Manager", ex.Path); + + //Regular dictionary key before $ref + json = @"{ + ""Angela"": { + ""Name"": ""Angela"", + ""$ref"": ""1"" + } + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize>>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Angela", ex.Path); + + //Regular property after $ref + json = @"{ + ""$id"": ""1"", + ""Name"": ""Angela"", + ""Manager"": { + ""$ref"": ""1"", + ""Name"": ""Bob"" + } + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Manager", ex.Path); + + //Metadata property before $ref + json = @"{ + ""$id"": ""1"", + ""Name"": ""Angela"", + ""Manager"": { + ""$id"": ""2"", + ""$ref"": ""1"" + } + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Manager", ex.Path); + + //Metadata property after $ref + json = @"{ + ""$id"": ""1"", + ""Name"": ""Angela"", + ""Manager"": { + ""$ref"": ""1"", + ""$id"": ""2"" + } + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.Manager", ex.Path); + } + + [Fact] + public static void ReferenceObjectBeforePreservedObject() + { + string json = @"[ + { + ""$ref"": ""1"" + }, + { + ""$id"": ""1"", + ""Name"": ""Angela"" + } + ]"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Contains("'1'", ex.Message); + Assert.Equal("$[0].$ref", ex.Path); + } + #endregion + + #region Preserved objects ($id) + [Fact] + public static void MoreThanOneId() + { + string json = @"{ + ""$id"": ""1"", + ""$id"": ""2"", + ""Name"": ""Angela"", + ""Manager"": { + ""$ref"": ""1"" + } + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$", ex.Path); + } + + [Fact] + public static void IdIsNotFirstProperty() + { + string json = @"{ + ""Name"": ""Angela"", + ""$id"": ""1"", + ""Manager"": { + ""$ref"": ""1"" + } + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$", ex.Path); + + json = @"{ + ""Name"": ""Angela"", + ""$id"": ""1"" + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$", ex.Path); + } + + [Fact] + public static void DuplicatedId() + { + string json = @"[ + { + ""$id"": ""1"", + ""Name"": ""Angela"" + }, + { + ""$id"": ""1"", + ""Name"": ""Bob"" + } + ]"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$[1].$id", ex.Path); + Assert.Contains("'1'", ex.Message); + } + + [Theory] + [InlineData(@"{""$id"":""A"", ""Manager"":{""$ref"":""A""}}")] + [InlineData(@"{""$id"":""00000000-0000-0000-0000-000000000000"", ""Manager"":{""$ref"":""00000000-0000-0000-0000-000000000000""}}")] + [InlineData("{\"$id\":\"A\u0467\", \"Manager\":{\"$ref\":\"A\u0467\"}}")] + public static void TestOddStringsInMetadata(string json) + { + Employee root = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.NotNull(root); + Assert.Same(root, root.Manager); + } + #endregion + + #region Preserved arrays ($id and $values) + [Fact] + public static void PreservedArrayWithoutMetadata() + { + string json = "{}"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$", ex.Path); + Assert.Contains(typeof(List).ToString(), ex.Message); + } + + [Fact] + public static void PreservedArrayWithoutValues() + { + string json = @"{ + ""$id"": ""1"" + }"; + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + // Not sure if is ok for Path to have this value. + Assert.Equal("$.$id", ex.Path); + Assert.Contains(typeof(List).ToString(), ex.Message); + } + + [Fact] + public static void PreservedArrayWithoutId() + { + string json = @"{ + ""$values"": [] + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$values", ex.Path); + } + + [Fact] + public static void PreservedArrayValuesContainsNull() + { + string json = @"{ + ""$id"": ""1"", + ""$values"": null + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$values", ex.Path); + } + + [Fact] + public static void PreservedArrayValuesContainsValue() + { + string json = @"{ + ""$id"": ""1"", + ""$values"": 1 + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$values", ex.Path); + } + + [Fact] + public static void PreservedArrayValuesContainsObject() + { + string json = @"{ + ""$id"": ""1"", + ""$values"": {} + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$.$values", ex.Path); + } + + [Fact] + public static void PreservedArrayExtraProperties() + { + string json = @"{ + ""LeadingProperty"": 0 + ""$id"": ""1"", + ""$values"": [] + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$", ex.Path); + Assert.Contains(typeof(List).ToString(), ex.Message); + + json = @"{ + ""$id"": ""1"", + ""$values"": [], + ""TrailingProperty"": 0 + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + + Assert.Equal("$", ex.Path); + Assert.Contains(typeof(List).ToString(), ex.Message); + Assert.Contains("TrailingProperty", ex.Message); + } + #endregion + + #region JSON Objects if not collection + private class EmployeeExtensionData : Employee + { + [JsonExtensionData] + [Newtonsoft.Json.JsonExtensionData] + public IDictionary ExtensionData { get; set; } + } + + [Fact] + public static void JsonObjectNonCollectionTest() + { + // $values Not Valid + string json = @"{ + ""$id"": ""1"", + ""$values"": ""test"" + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$values", ex.Path); + + ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$values", ex.Path); + + // $.* Not valid (i.e: $test) + json = @"{ + ""$id"": ""1"", + ""$test"": ""test"" + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$test", ex.Path); + + ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$test", ex.Path); + + json = @"{ + ""$id"": ""1"", + ""\u0024test"": ""test"" + }"; + + // \u0024.* Valid (i.e: \u0024test) + EmployeeExtensionData employee = JsonSerializer.Deserialize(json, s_deserializerOptionsPreserve); + Assert.Equal("test", ((JsonElement)employee.ExtensionData["$test"]).GetString()); + + Dictionary dictionary = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.Equal("test", dictionary["$test"]); + } + #endregion + + #region JSON Objects if collection + [Fact] + public static void JsonObjectCollectionTest() + { + + // $ref Valid under conditions: must be the only property in the object. + string json = @"{ + ""$ref"": ""1"" + }"; + + JsonException ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + Assert.Equal("$.$ref", ex.Path); + + // $id Valid under conditions: must be the first property in the object. + // $values Valid under conditions: must be after $id. + json = @"{ + ""$id"": ""1"", + ""$values"": [] + }"; + + List root = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + Assert.NotNull(root); + Assert.Equal(0, root.Count); + + // $.* Not valid (i.e: $test) + json = @"{ + ""$id"": ""1"", + ""$test"": ""test"" + }"; + + ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve)); + } + #endregion + #endregion + } +} diff --git a/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.Serialize.cs b/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.Serialize.cs new file mode 100644 index 0000000000000..ab09f3c399c19 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.Serialize.cs @@ -0,0 +1,228 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using Newtonsoft.Json; +using Xunit; + +namespace System.Text.Json.Serialization.Tests +{ + public static partial class ReferenceHandlingTests + { + private static readonly JsonSerializerOptions s_serializerOptionsPreserve = new JsonSerializerOptions { ReferenceHandling = ReferenceHandling.Preserve }; + private static readonly JsonSerializerSettings s_newtonsoftSerializerSettingsPreserve = new JsonSerializerSettings { PreserveReferencesHandling = PreserveReferencesHandling.All, ReferenceLoopHandling = ReferenceLoopHandling.Serialize }; + + private class Employee + { + public string Name { get; set; } + public Employee Manager { get; set; } + public Employee Manager2 { get; set; } + public List Subordinates { get; set; } + public List Subordinates2 { get; set; } + public Dictionary Contacts { get; set; } + public Dictionary Contacts2 { get; set; } + + //Properties with default value to verify they get overwritten when deserializing into them. + public List SubordinatesString { get; set; } = new List { "Bob" }; + public Dictionary ContactsString { get; set; } = new Dictionary() { { "Bob", "555-5555" } }; + } + + [Fact] + public static void ExtensionDataDictionaryHandlesPreserveReferences() + { + Employee bob = new Employee { Name = "Bob" }; + + EmployeeExtensionData angela = new EmployeeExtensionData + { + Name = "Angela", + + Manager = bob + }; + bob.Subordinates = new List { angela }; + + var extensionData = new Dictionary + { + ["extString"] = "string value", + ["extNumber"] = 100, + ["extObject"] = bob, + ["extArray"] = bob.Subordinates + }; + + angela.ExtensionData = extensionData; + + string expected = JsonConvert.SerializeObject(angela, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(angela, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + } + + #region struct tests + private struct EmployeeStruct + { + public string Name { get; set; } + public JobStruct Job { get; set; } + public ImmutableArray Roles { get; set; } + } + + private struct JobStruct + { + public string Title { get; set; } + } + + private struct RoleStruct + { + public string Description { get; set; } + } + + [Fact] + public static void ValueTypesShouldNotContainId() + { + //Struct as root. + EmployeeStruct employee = new EmployeeStruct + { + Name = "Angela", + //Struct as property. + Job = new JobStruct + { + Title = "Software Engineer" + }, + //ImmutableArray as property. + Roles = + ImmutableArray.Create( + new RoleStruct + { + Description = "Contributor" + }, + new RoleStruct + { + Description = "Infrastructure" + }) + }; + + //ImmutableArray as root. + ImmutableArray array = + //Struct as array element (same as struct being root). + ImmutableArray.Create(employee); + + // Regardless of using preserve, do not emit $id to value types; that is why we compare against default. + string actual = JsonSerializer.Serialize(array, s_serializerOptionsPreserve); + string expected = JsonSerializer.Serialize(array); + + Assert.Equal(expected, actual); + } + #endregion struct tests + + #region Encode JSON property with leading '$' + private class ClassWithExtensionData + { + public string Hello { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } + } + + [Fact] + [ActiveIssue("https://github.com/dotnet/runtime/issues/1780")] + public static void DictionaryKeyContainingLeadingDollarSignShouldBeEncoded() + { + //$ Key in dictionary holding primitive type. + Dictionary dictionary = new Dictionary + { + ["$string"] = "Hello world" + }; + string json = JsonSerializer.Serialize(dictionary, s_serializerOptionsPreserve); + Assert.Equal(@"{""$id"":""1"",""\u0024string"":""Hello world""}", json); + + //$ Key in dictionary holding complex type. + dictionary = new Dictionary + { + ["$object"] = new ClassWithExtensionData { Hello = "World" } + }; + json = JsonSerializer.Serialize(dictionary, s_serializerOptionsPreserve); + Assert.Equal(@"{""$id"":""1"",""\u0024object"":{""$id"":""2"",""Hello"":""World""}}", json); + + //$ Key in ExtensionData dictionary + var poco = new ClassWithExtensionData + { + ExtensionData = + { + ["$string"] = "Hello world", + ["$object"] = new ClassWithExtensionData + { + Hello = "World" + } + } + }; + json = JsonSerializer.Serialize(poco, s_serializerOptionsPreserve); + Assert.Equal(@"{""$id"":""1"",""\u0024string"":""Hello world"",""\u0024object"":{""$id"":""2"",""Hello"":""World""}}", json); + + //TODO: + //Extend the scenarios to also cover CLR and F# properties with a leading $. + //Also add scenarios where a NamingPolicy (DictionaryKey or Property) appends the leading $. + } + #endregion + + private class ClassWithListAndImmutableArray + { + public List PreservableList { get; set; } + public ImmutableArray NonProservableArray { get; set; } + } + + [Fact] + public static void WriteWrappingBraceResetsCorrectly() + { + List list = new List { 10, 20, 30 }; + ImmutableArray immutableArr = list.ToImmutableArray(); + + var root = new ClassWithListAndImmutableArray + { + PreservableList = list, + // Do not write any curly braces for ImmutableArray since is a value type. + NonProservableArray = immutableArr + }; + JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + ImmutableArray> immutablArraytOfLists = new List> { list }.ToImmutableArray(); + JsonSerializer.Serialize(immutablArraytOfLists, s_serializerOptionsPreserve); + + List> listOfImmutableArrays = new List> { immutableArr }; + JsonSerializer.Serialize(listOfImmutableArrays, s_serializerOptionsPreserve); + + List mixedListOfLists = new List { list, immutableArr, list, immutableArr }; + JsonSerializer.Serialize(mixedListOfLists, s_serializerOptionsPreserve); + } + + private class ClassIncorrectHashCode + { + private static int s_index = 0; + + public override int GetHashCode() + { + s_index++; + return s_index; + } + }; + + [Fact] + public static void CustomHashCode() + { + // Test that POCO's implementation of GetHashCode is always used. + ClassIncorrectHashCode elem = new ClassIncorrectHashCode(); + List list = new List() + { + elem, + elem, + }; + + string json = JsonSerializer.Serialize(list, s_serializerOptionsPreserve); + Assert.Equal(@"{""$id"":""1"",""$values"":[{""$id"":""2""},{""$id"":""3""}]}", json); + + List listCopy = JsonSerializer.Deserialize>(json, s_serializerOptionsPreserve); + // When a GetHashCode method is implemented incorrectly, round-tripping breaks, + // that is a user error and this validates that we are always calling user's GetHashCode. + Assert.NotSame(listCopy[0], listCopy[1]); + } + } +} diff --git a/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.cs b/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.cs new file mode 100644 index 0000000000000..cc01df11eb493 --- /dev/null +++ b/src/libraries/System.Text.Json/tests/Serialization/ReferenceHandlingTests.cs @@ -0,0 +1,540 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Encodings.Web; +using Newtonsoft.Json; +using Xunit; + +namespace System.Text.Json.Serialization.Tests +{ + public static partial class ReferenceHandlingTests + { + + [Fact] + public static void ThrowByDefaultOnLoop() + { + Employee a = new Employee(); + a.Manager = a; + + JsonException ex = Assert.Throws(() => JsonSerializer.Serialize(a)); + } + + [Fact] + public static void ThrowWhenPassingNullToReferenceHandling() + { + Assert.Throws(() => new JsonSerializerOptions { ReferenceHandling = null }); + } + + #region Root Object + [Fact] + public static void ObjectLoop() + { + Employee angela = new Employee(); + angela.Manager = angela; + + // Compare parity with Newtonsoft.Json + string expected = JsonConvert.SerializeObject(angela, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(angela, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + // Ensure round-trip + Employee angelaCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(angelaCopy.Manager, angelaCopy); + } + + [Fact] + public static void ObjectArrayLoop() + { + Employee angela = new Employee(); + angela.Subordinates = new List { angela }; + + string expected = JsonConvert.SerializeObject(angela, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(angela, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + Employee angelaCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(angelaCopy.Subordinates[0], angelaCopy); + } + + [Fact] + public static void ObjectDictionaryLoop() + { + Employee angela = new Employee(); + angela.Contacts = new Dictionary { { "555-5555", angela } }; + + string expected = JsonConvert.SerializeObject(angela, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(angela, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + Employee angelaCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(angelaCopy.Contacts["555-5555"], angelaCopy); + } + + [Fact] + public static void ObjectPreserveDuplicateObjects() + { + Employee angela = new Employee + { + Manager = new Employee { Name = "Bob" } + }; + angela.Manager2 = angela.Manager; + + string expected = JsonConvert.SerializeObject(angela, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(angela, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + Employee angelaCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(angelaCopy.Manager, angelaCopy.Manager2); + } + + [Fact] + public static void ObjectPreserveDuplicateDictionaries() + { + Employee angela = new Employee + { + Contacts = new Dictionary { { "444-4444", new Employee { Name = "Bob" } } } + }; + angela.Contacts2 = angela.Contacts; + + string expected = JsonConvert.SerializeObject(angela, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(angela, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + Employee angelaCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(angelaCopy.Contacts, angelaCopy.Contacts2); + } + + [Fact] + public static void ObjectPreserveDuplicateArrays() + { + Employee angela = new Employee + { + Subordinates = new List { new Employee { Name = "Bob" } } + }; + angela.Subordinates2 = angela.Subordinates; + + string expected = JsonConvert.SerializeObject(angela, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(angela, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + Employee angelaCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(angelaCopy.Subordinates, angelaCopy.Subordinates2); + } + + [Fact] + public static void KeyValuePairTest() + { + var kvp = new KeyValuePair("key", "value"); + string json = JsonSerializer.Serialize(kvp, s_deserializerOptionsPreserve); + KeyValuePair kvp2 = JsonSerializer.Deserialize>(json, s_deserializerOptionsPreserve); + + Assert.Equal(kvp.Key, kvp2.Key); + Assert.Equal(kvp.Value, kvp2.Value); + } + + private class ClassWithZeroLengthProperty + { + [JsonPropertyName("")] + public TValue ZeroLengthProperty { get; set; } + } + + [Fact] + public static void OjectZeroLengthProperty() + { + // Default + + ClassWithZeroLengthProperty rootValue = RoundTripZeroLengthProperty(new ClassWithZeroLengthProperty(), 10); + Assert.Equal(10, rootValue.ZeroLengthProperty); + + ClassWithZeroLengthProperty rootObject = RoundTripZeroLengthProperty(new ClassWithZeroLengthProperty(), new Employee { Name = "Test" }); + Assert.Equal("Test", rootObject.ZeroLengthProperty.Name); + + ClassWithZeroLengthProperty> rootArray = RoundTripZeroLengthProperty(new ClassWithZeroLengthProperty>(), new List()); + Assert.Equal(0, rootArray.ZeroLengthProperty.Count); + + // Preserve + + ClassWithZeroLengthProperty rootValue2 = RoundTripZeroLengthProperty(new ClassWithZeroLengthProperty(), 10, s_deserializerOptionsPreserve); + Assert.Equal(10, rootValue2.ZeroLengthProperty); + + ClassWithZeroLengthProperty rootObject2 = RoundTripZeroLengthProperty(new ClassWithZeroLengthProperty(), new Employee { Name = "Test" }, s_deserializerOptionsPreserve); + Assert.Equal("Test", rootObject2.ZeroLengthProperty.Name); + + ClassWithZeroLengthProperty> rootArray2 = RoundTripZeroLengthProperty(new ClassWithZeroLengthProperty>(), new List(), s_deserializerOptionsPreserve); + Assert.Equal(0, rootArray2.ZeroLengthProperty.Count); + } + + private static ClassWithZeroLengthProperty RoundTripZeroLengthProperty(ClassWithZeroLengthProperty obj, TValue value, JsonSerializerOptions opts = null) + { + obj.ZeroLengthProperty = value; + string json = JsonSerializer.Serialize(obj, opts); + Assert.Contains("\"\":", json); + + return JsonSerializer.Deserialize>(json, opts); + } + + [Fact] + public static void UnicodePropertyNames() + { + ClassWithUnicodeProperty obj = new ClassWithUnicodeProperty + { + A\u0467 = 1 + }; + + // Verify the name is escaped after serialize. + string json = JsonSerializer.Serialize(obj, s_serializerOptionsPreserve); + Assert.StartsWith("{\"$id\":\"1\",", json); + Assert.Contains(@"""A\u0467"":1", json); + + // Round-trip + ClassWithUnicodeProperty objCopy = JsonSerializer.Deserialize(json, s_serializerOptionsPreserve); + Assert.Equal(1, objCopy.A\u0467); + + // With custom escaper + // Specifying encoder on options does not impact deserialize. + var optionsWithEncoder = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + ReferenceHandling = ReferenceHandling.Preserve + }; + json = JsonSerializer.Serialize(obj, optionsWithEncoder); + Assert.StartsWith("{\"$id\":\"1\",", json); + Assert.Contains("\"A\u0467\":1", json); + + // Round-trip + objCopy = JsonSerializer.Deserialize(json, optionsWithEncoder); + Assert.Equal(1, objCopy.A\u0467); + + // We want to go over StackallocThreshold=256 to force a pooled allocation, so this property is 400 chars and 401 bytes. + obj = new ClassWithUnicodeProperty + { + A\u046734567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 = 1 + }; + + // Verify the name is escaped after serialize. + json = JsonSerializer.Serialize(obj, s_serializerOptionsPreserve); + Assert.StartsWith("{\"$id\":\"1\",", json); + Assert.Contains(@"""A\u046734567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"":1", json); + + // Round-trip + objCopy = JsonSerializer.Deserialize(json, s_serializerOptionsPreserve); + Assert.Equal(1, objCopy.A\u046734567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890); + + // With custom escaper + json = JsonSerializer.Serialize(obj, optionsWithEncoder); + Assert.StartsWith("{\"$id\":\"1\",", json); + Assert.Contains("\"A\u046734567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890\":1", json); + + // Round-trip + objCopy = JsonSerializer.Deserialize(json, optionsWithEncoder); + Assert.Equal(1, objCopy.A\u046734567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890); + } + #endregion Root Object + + #region Root Dictionary + private class DictionaryWithGenericCycle : Dictionary { } + + [Fact] + public static void DictionaryLoop() + { + DictionaryWithGenericCycle root = new DictionaryWithGenericCycle(); + root["Self"] = root; + root["Other"] = new DictionaryWithGenericCycle(); + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + DictionaryWithGenericCycle rootCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy["Self"]); + } + + [Fact] + public static void DictionaryPreserveDuplicateDictionaries() + { + DictionaryWithGenericCycle root = new DictionaryWithGenericCycle(); + root["Self1"] = root; + root["Self2"] = root; + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + DictionaryWithGenericCycle rootCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy["Self1"]); + Assert.Same(rootCopy, rootCopy["Self2"]); + } + + [Fact] + public static void DictionaryObjectLoop() + { + Dictionary root = new Dictionary(); + root["Angela"] = new Employee() { Name = "Angela", Contacts = root }; + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + Dictionary rootCopy = JsonSerializer.Deserialize>(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy["Angela"].Contacts); + } + + private class DictionaryWithGenericCycleWithinList : Dictionary> { } + + [Fact] + public static void DictionaryArrayLoop() + { + DictionaryWithGenericCycleWithinList root = new DictionaryWithGenericCycleWithinList(); + root["ArrayWithSelf"] = new List { root }; + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + DictionaryWithGenericCycleWithinList rootCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy["ArrayWithSelf"][0]); + } + + [Fact] + public static void DictionaryPreserveDuplicateArrays() + { + DictionaryWithGenericCycleWithinList root = new DictionaryWithGenericCycleWithinList(); + root["Array1"] = new List { root }; + root["Array2"] = root["Array1"]; + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + DictionaryWithGenericCycleWithinList rootCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy["Array1"][0]); + Assert.Same(rootCopy["Array2"], rootCopy["Array1"]); + } + + [Fact] + public static void DictionaryPreserveDuplicateObjects() + { + Dictionary root = new Dictionary + { + ["Employee1"] = new Employee { Name = "Angela" } + }; + root["Employee2"] = root["Employee1"]; + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + Dictionary rootCopy = JsonSerializer.Deserialize>(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy["Employee1"], rootCopy["Employee2"]); + } + + [Fact] + public static void DictionaryZeroLengthKey() + { + // Default + + Dictionary rootValue = RoundTripDictionaryZeroLengthKey(new Dictionary(), 10); + Assert.Equal(10, rootValue[string.Empty]); + + Dictionary rootObject = RoundTripDictionaryZeroLengthKey(new Dictionary(), new Employee { Name = "Test" }); + Assert.Equal("Test", rootObject[string.Empty].Name); + + Dictionary> rootArray = RoundTripDictionaryZeroLengthKey(new Dictionary>(), new List()); + Assert.Equal(0, rootArray[string.Empty].Count); + + // Preserve + + Dictionary rootValue2 = RoundTripDictionaryZeroLengthKey(new Dictionary(), 10, s_deserializerOptionsPreserve); + Assert.Equal(10, rootValue2[string.Empty]); + + Dictionary rootObject2 = RoundTripDictionaryZeroLengthKey(new Dictionary(), new Employee { Name = "Test" }, s_deserializerOptionsPreserve); + Assert.Equal("Test", rootObject2[string.Empty].Name); + + Dictionary> rootArray2 = RoundTripDictionaryZeroLengthKey(new Dictionary>(), new List(), s_deserializerOptionsPreserve); + Assert.Equal(0, rootArray2[string.Empty].Count); + } + + private static Dictionary RoundTripDictionaryZeroLengthKey(Dictionary dictionary, TValue value, JsonSerializerOptions opts = null) + { + dictionary[string.Empty] = value; + string json = JsonSerializer.Serialize(dictionary, opts); + Assert.Contains("\"\":", json); + + return JsonSerializer.Deserialize>(json, opts); + } + + [Fact] + public static void UnicodeDictionaryKeys() + { + Dictionary obj = new Dictionary { { "A\u0467", 1 } }; + // Verify the name is escaped after serialize. + string json = JsonSerializer.Serialize(obj, s_serializerOptionsPreserve); + Assert.Equal(@"{""$id"":""1"",""A\u0467"":1}", json); + + // Round-trip + Dictionary objCopy = JsonSerializer.Deserialize>(json, s_serializerOptionsPreserve); + Assert.Equal(1, objCopy["A\u0467"]); + + // Verify with encoder. + var optionsWithEncoder = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + ReferenceHandling = ReferenceHandling.Preserve + }; + json = JsonSerializer.Serialize(obj, optionsWithEncoder); + Assert.Equal("{\"$id\":\"1\",\"A\u0467\":1}", json); + + // Round-trip + objCopy = JsonSerializer.Deserialize>(json, optionsWithEncoder); + Assert.Equal(1, objCopy["A\u0467"]); + + // We want to go over StackallocThreshold=256 to force a pooled allocation, so this property is 200 chars and 400 bytes. + const int charsInProperty = 200; + string longPropertyName = new string('\u0467', charsInProperty); + obj = new Dictionary { { $"{longPropertyName}", 1 } }; + Assert.Equal(1, obj[longPropertyName]); + + // Verify the name is escaped after serialize. + json = JsonSerializer.Serialize(obj, s_serializerOptionsPreserve); + + // Duplicate the unicode character 'charsInProperty' times. + string longPropertyNameEscaped = new StringBuilder().Insert(0, @"\u0467", charsInProperty).ToString(); + string expectedJson = $"{{\"$id\":\"1\",\"{longPropertyNameEscaped}\":1}}"; + Assert.Equal(expectedJson, json); + + // Round-trip + objCopy = JsonSerializer.Deserialize>(json, s_serializerOptionsPreserve); + Assert.Equal(1, objCopy[longPropertyName]); + + // Verify the name is escaped after serialize. + json = JsonSerializer.Serialize(obj, optionsWithEncoder); + + // Duplicate the unicode character 'charsInProperty' times. + longPropertyNameEscaped = new StringBuilder().Insert(0, "\u0467", charsInProperty).ToString(); + expectedJson = $"{{\"$id\":\"1\",\"{longPropertyNameEscaped}\":1}}"; + Assert.Equal(expectedJson, json); + + // Round-trip + objCopy = JsonSerializer.Deserialize>(json, optionsWithEncoder); + Assert.Equal(1, objCopy[longPropertyName]); + } + #endregion + + #region Root Array + private class ListWithGenericCycle : List { } + + [Fact] + public static void ArrayLoop() + { + ListWithGenericCycle root = new ListWithGenericCycle(); + root.Add(root); + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + ListWithGenericCycle rootCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy[0]); + + // Duplicate reference + root = new ListWithGenericCycle(); + root.Add(root); + root.Add(root); + root.Add(root); + + expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + rootCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy[0]); + Assert.Same(rootCopy, rootCopy[1]); + Assert.Same(rootCopy, rootCopy[2]); + } + + [Fact] + public static void ArrayObjectLoop() + { + List root = new List(); + root.Add(new Employee() { Name = "Angela", Subordinates = root }); + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + List rootCopy = JsonSerializer.Deserialize>(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy[0].Subordinates); + } + + [Fact] + public static void ArrayPreserveDuplicateObjects() + { + List root = new List + { + new Employee { Name = "Angela" } + }; + root.Add(root[0]); + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + List rootCopy = JsonSerializer.Deserialize>(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy[0], rootCopy[1]); + } + + private class ListWithGenericCycleWithinDictionary : List> { } + + [Fact] + public static void ArrayDictionaryLoop() + { + ListWithGenericCycleWithinDictionary root = new ListWithGenericCycleWithinDictionary(); + root.Add(new Dictionary { { "Root", root } }); + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + ListWithGenericCycleWithinDictionary rootCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy, rootCopy[0]["Root"]); + } + + [Fact] + public static void ArrayPreserveDuplicateDictionaries() + { + ListWithGenericCycleWithinDictionary root = new ListWithGenericCycleWithinDictionary + { + new Dictionary() + }; + root.Add(root[0]); + + string expected = JsonConvert.SerializeObject(root, s_newtonsoftSerializerSettingsPreserve); + string actual = JsonSerializer.Serialize(root, s_serializerOptionsPreserve); + + Assert.Equal(expected, actual); + + ListWithGenericCycleWithinDictionary rootCopy = JsonSerializer.Deserialize(actual, s_serializerOptionsPreserve); + Assert.Same(rootCopy[0], rootCopy[1]); + } + #endregion + } +} diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests.csproj b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests.csproj index 33cb83402e98d..a2448fa8b8f55 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests.csproj +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests.csproj @@ -74,6 +74,9 @@ + + +