diff --git a/ReflectorNet.Tests/src/RecursionTests.cs b/ReflectorNet.Tests/src/RecursionTests.cs new file mode 100644 index 00000000..c72f8f14 --- /dev/null +++ b/ReflectorNet.Tests/src/RecursionTests.cs @@ -0,0 +1,368 @@ +using System.Collections.Generic; +using System.Text.Json; +using com.IvanMurzak.ReflectorNet.Model; +using JsonSchema = com.IvanMurzak.ReflectorNet.Utils.JsonSchema; +using Xunit; +using Xunit.Abstractions; + +namespace com.IvanMurzak.ReflectorNet.Tests +{ + public class RecursionTests + { + private readonly ITestOutputHelper _output; + + public RecursionTests(ITestOutputHelper output) + { + _output = output; + } + + class RecursiveNode + { + public string? Name { get; set; } + public RecursiveNode? Child { get; set; } + } + + class RecursiveWrapper + { + public RecursiveContainer? Container { get; set; } + } + class RecursiveContainer + { + public List Items { get; set; } = new List(); + } + + [Fact] + public void Serialize_RecursiveObject_ShouldReturnReference() + { + var node1 = new RecursiveNode { Name = "Node1" }; + var node2 = new RecursiveNode { Name = "Node2" }; + node1.Child = node2; + node2.Child = node1; // Cycle + + var reflector = new Reflector(); + var result = reflector.Serialize(node1); + + _output.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + + // Check structure + // Node1 -> Child (Node2) -> Child (Reference to Node1) + + Assert.NotNull(result); + Assert.NotNull(result.props); + Assert.Equal(node1.Name, result.props.GetField(nameof(RecursiveNode.Name))?.valueJsonElement?.GetString()); + + var child = result.props.GetField(nameof(RecursiveNode.Child)); + Assert.NotNull(child); + Assert.NotNull(child.props); + Assert.Equal(node2.Name, child.props.GetField(nameof(RecursiveNode.Name))?.valueJsonElement?.GetString()); + + var grandChild = child.props.GetField(nameof(RecursiveNode.Child)); + Assert.NotNull(grandChild); + Assert.Equal(JsonSchema.Reference, grandChild.typeName); + + var refValue = grandChild.valueJsonElement?.GetProperty(JsonSchema.Ref).GetString(); + Assert.Equal("#", refValue); // Should point to root + } + + class SelfRecursive + { + public SelfRecursive? Self { get; set; } + } + + [Fact] + public void Serialize_SelfRecursive_ShouldReturnReference() + { + var obj = new SelfRecursive(); + obj.Self = obj; + + var reflector = new Reflector(); + var result = reflector.Serialize(obj); + + _output.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + + Assert.NotNull(result); + Assert.NotNull(result.props); + var self = result.props.GetField(nameof(SelfRecursive.Self)); + Assert.NotNull(self); + Assert.Equal(JsonSchema.Reference, self.typeName); + Assert.Equal("#", self.valueJsonElement?.GetProperty(JsonSchema.Ref).GetString()); + } + + [Fact] + public void Serialize_RecursiveList_ShouldReturnReference() + { + var wrapper = new RecursiveWrapper(); + var container = new RecursiveContainer(); + + wrapper.Container = container; + container.Items.Add(wrapper); + + var reflector = new Reflector(); + var result = reflector.Serialize(wrapper); + + _output.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + + Assert.NotNull(result); + Assert.NotNull(result.props); + + var containerProp = result.props.GetField(nameof(RecursiveWrapper.Container)); + Assert.NotNull(containerProp); + + var itemsProp = containerProp.props?.GetField(nameof(RecursiveContainer.Items)); + Assert.NotNull(itemsProp); + + var itemsArray = itemsProp.valueJsonElement; + Assert.NotNull(itemsArray); + Assert.Equal(JsonValueKind.Array, itemsArray.Value.ValueKind); + + var firstItem = itemsArray.Value[0]; + Assert.Equal(JsonSchema.Reference, firstItem.GetProperty(nameof(SerializedMember.typeName)).GetString()); + var refValue = firstItem.GetProperty(SerializedMember.ValueName).GetProperty(JsonSchema.Ref).GetString(); + Assert.Equal("#", refValue); + } + + [Fact] + public void Serialize_DeepRecursion_ShouldReturnCorrectReference() + { + var root = new RecursiveNode { Name = "root" }; + var child1 = new RecursiveNode { Name = "child1" }; + var child2 = new RecursiveNode { Name = "child2" }; + var child3 = new RecursiveNode { Name = "child3" }; + + root.Child = child1; + child1.Child = child2; + child2.Child = child3; + child3.Child = child2; // Cycle back to child2 + + var reflector = new Reflector(); + var result = reflector.Serialize(root); + + _output.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + + Assert.NotNull(result); + Assert.NotNull(result.props); + + // Root -> Child1 + var child1Prop = result.props.GetField(nameof(RecursiveNode.Child)); + Assert.NotNull(child1Prop); + Assert.Equal(child1.Name, child1Prop.props!.GetField(nameof(RecursiveNode.Name))?.valueJsonElement?.GetString()); + + // Child1 -> Child2 + var child2Prop = child1Prop.props.GetField(nameof(RecursiveNode.Child)); + Assert.NotNull(child2Prop); + Assert.Equal(child2.Name, child2Prop.props!.GetField(nameof(RecursiveNode.Name))?.valueJsonElement?.GetString()); + + // Child2 -> Child3 + var child3Prop = child2Prop.props.GetField(nameof(RecursiveNode.Child)); + Assert.NotNull(child3Prop); + Assert.Equal(child3.Name, child3Prop.props!.GetField(nameof(RecursiveNode.Name))?.valueJsonElement?.GetString()); + + // Child3 -> Reference to Child2 + var refProp = child3Prop.props.GetField(nameof(RecursiveNode.Child)); + Assert.NotNull(refProp); + Assert.Equal(JsonSchema.Reference, refProp.typeName); + + var refValue = refProp.valueJsonElement?.GetProperty(JsonSchema.Ref).GetString(); + // Path to Child2: Root -> Child -> Child + Assert.Equal($"#/{nameof(RecursiveNode.Child)}/{nameof(RecursiveNode.Child)}", refValue); + } + + [Fact] + public void Serialize_DeepRecursion_List_ShouldReturnCorrectReference() + { + var wrapper1 = new RecursiveWrapper(); // Root + var container1 = new RecursiveContainer(); + wrapper1.Container = container1; + + var wrapper2 = new RecursiveWrapper(); // Child 1 + container1.Items.Add(wrapper2); + var container2 = new RecursiveContainer(); + wrapper2.Container = container2; + + var wrapper3 = new RecursiveWrapper(); // Child 2 + container2.Items.Add(wrapper3); + var container3 = new RecursiveContainer(); + wrapper3.Container = container3; + + // Cycle: wrapper3 -> container3 -> items[0] -> wrapper2 + container3.Items.Add(wrapper2); + + var reflector = new Reflector(); + var result = reflector.Serialize(wrapper1); + + _output.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + + Assert.NotNull(result); + Assert.NotNull(result.props); + + // Root (Wrapper1) -> Container -> Items[0] (Wrapper2) + var container1Prop = result.props.GetField(nameof(RecursiveWrapper.Container)); + Assert.NotNull(container1Prop); + var items1Prop = container1Prop.props?.GetField(nameof(RecursiveContainer.Items)); + Assert.NotNull(items1Prop); + // items1Prop is a SerializedMember. Its valueJsonElement IS the array of items. + var wrapper2Prop = items1Prop.valueJsonElement?[0]; + Assert.NotNull(wrapper2Prop); + + // Wrapper2 -> Container -> Items[0] (Wrapper3) + // Note: wrapper2Prop is a JsonElement, we need to navigate its structure which mimics SerializedMember + // But wait, ArrayReflectionConvertor returns a SerializedMember with value as a list of SerializedMembers. + // The "value" property of the SerializedMember for the list is the array. + // The array elements are SerializedMembers. + + // Let's navigate using the SerializedMember structure if possible, but here we have JsonElement for the array items if we didn't deserialize back to SerializedMember. + // Actually, Reflector.Serialize returns a SerializedMember. + // For the list, the 'value' is a JsonElement which IS the array of SerializedMembers (serialized to JSON). + // Wait, let's check ArrayReflectionConvertor. + // It returns SerializedMember.FromValue(..., value: serializedList, ...). + // serializedList is SerializedMemberList. + // So the value IS a SerializedMemberList (which is a List). + // But SerializedMember.valueJsonElement is a JsonElement. + // When FromValue is called, it serializes the value to JsonElement. + // So items1Prop.valueJsonElement is the JSON array of SerializedMembers. + + // wrapper2Prop is the first element of that array. It is a JSON object representing the SerializedMember for Wrapper2. + + // Wrapper2 (JsonElement) -> props -> Container -> props -> Items -> value -> [0] (Wrapper3) + var wrapper2Props = wrapper2Prop?.GetProperty(nameof(SerializedMember.props)); + // We need to find the property with name "Container" in the props array + var container2Prop = GetPropertyFromJsonArray(wrapper2Props, nameof(RecursiveWrapper.Container)); + var items2Prop = GetPropertyFromJsonArray(container2Prop.GetProperty(nameof(SerializedMember.props)), nameof(RecursiveContainer.Items)); + var wrapper3Prop = items2Prop.GetProperty(SerializedMember.ValueName)[0]; + + // Wrapper3 -> Container -> Items -> [0] (Reference to Wrapper2) + var wrapper3Props = wrapper3Prop.GetProperty(nameof(SerializedMember.props)); + var container3Prop = GetPropertyFromJsonArray(wrapper3Props, nameof(RecursiveWrapper.Container)); + var items3Prop = GetPropertyFromJsonArray(container3Prop.GetProperty(nameof(SerializedMember.props)), nameof(RecursiveContainer.Items)); + var refProp = items3Prop.GetProperty(SerializedMember.ValueName)[0]; + + Assert.Equal(JsonSchema.Reference, refProp.GetProperty(nameof(SerializedMember.typeName)).GetString()); + var refValue = refProp.GetProperty(SerializedMember.ValueName).GetProperty(JsonSchema.Ref).GetString(); + + // Path to Wrapper2: # -> Container -> Items -> [0] + Assert.Equal($"#/{nameof(RecursiveWrapper.Container)}/{nameof(RecursiveContainer.Items)}/[0]", refValue); + } + + // ==================== DESERIALIZATION TESTS ==================== + + [Fact] + public void Deserialize_RecursiveObject_ShouldRestoreReferences() + { + // Setup: Create cycle, serialize, deserialize, verify cycle restored + var node1 = new RecursiveNode { Name = "Node1" }; + var node2 = new RecursiveNode { Name = "Node2" }; + node1.Child = node2; + node2.Child = node1; // Cycle + + var reflector = new Reflector(); + var serialized = reflector.Serialize(node1); + + _output.WriteLine("Serialized:"); + _output.WriteLine(JsonSerializer.Serialize(serialized, new JsonSerializerOptions { WriteIndented = true })); + + var deserialized = reflector.Deserialize(serialized); + + Assert.NotNull(deserialized); + Assert.Equal("Node1", deserialized.Name); + Assert.NotNull(deserialized.Child); + Assert.Equal("Node2", deserialized.Child.Name); + Assert.NotNull(deserialized.Child.Child); + Assert.Same(deserialized, deserialized.Child.Child); // Same reference! + } + + [Fact] + public void Deserialize_SelfRecursive_ShouldRestoreReference() + { + var obj = new SelfRecursive(); + obj.Self = obj; + + var reflector = new Reflector(); + var serialized = reflector.Serialize(obj); + + _output.WriteLine("Serialized:"); + _output.WriteLine(JsonSerializer.Serialize(serialized, new JsonSerializerOptions { WriteIndented = true })); + + var deserialized = reflector.Deserialize(serialized); + + Assert.NotNull(deserialized); + Assert.Same(deserialized, deserialized.Self); // Same reference! + } + + [Fact] + public void Deserialize_RecursiveList_ShouldRestoreReference() + { + var wrapper = new RecursiveWrapper(); + var container = new RecursiveContainer(); + wrapper.Container = container; + container.Items.Add(wrapper); + + var reflector = new Reflector(); + var serialized = reflector.Serialize(wrapper); + + _output.WriteLine("Serialized:"); + _output.WriteLine(JsonSerializer.Serialize(serialized, new JsonSerializerOptions { WriteIndented = true })); + + var deserialized = reflector.Deserialize(serialized); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Container); + Assert.Single(deserialized.Container.Items); + Assert.Same(deserialized, deserialized.Container.Items[0]); // Same reference! + } + + [Fact] + public void Deserialize_DeepRecursion_ShouldRestoreCorrectReference() + { + // Setup: root -> child1 -> child2 -> child3 -> child2 (cycle) + var root = new RecursiveNode { Name = "root" }; + var child1 = new RecursiveNode { Name = "child1" }; + var child2 = new RecursiveNode { Name = "child2" }; + var child3 = new RecursiveNode { Name = "child3" }; + + root.Child = child1; + child1.Child = child2; + child2.Child = child3; + child3.Child = child2; // Cycle back to child2 + + var reflector = new Reflector(); + var serialized = reflector.Serialize(root); + + _output.WriteLine("Serialized:"); + _output.WriteLine(JsonSerializer.Serialize(serialized, new JsonSerializerOptions { WriteIndented = true })); + + var deserialized = reflector.Deserialize(serialized); + + Assert.NotNull(deserialized); + Assert.Equal("root", deserialized.Name); + + var d_child1 = deserialized.Child; + Assert.NotNull(d_child1); + Assert.Equal("child1", d_child1.Name); + + var d_child2 = d_child1.Child; + Assert.NotNull(d_child2); + Assert.Equal("child2", d_child2.Name); + + var d_child3 = d_child2.Child; + Assert.NotNull(d_child3); + Assert.Equal("child3", d_child3.Name); + + var d_ref = d_child3.Child; + Assert.NotNull(d_ref); + Assert.Same(d_child2, d_ref); // Should reference the same object + } + + private JsonElement GetPropertyFromJsonArray(JsonElement? array, string name) + { + if (array == null) throw new System.ArgumentNullException(nameof(array)); + foreach (var item in array.Value.EnumerateArray()) + { + if (item.GetProperty(nameof(SerializedMember.name)).GetString() == name) + { + return item; + } + } + throw new System.Exception($"Property {name} not found"); + } + } +} diff --git a/ReflectorNet/src/Convertor/IReflectionConvertor.cs b/ReflectorNet/src/Convertor/IReflectionConvertor.cs index aa18f433..8cfe55cc 100644 --- a/ReflectorNet/src/Convertor/IReflectionConvertor.cs +++ b/ReflectorNet/src/Convertor/IReflectionConvertor.cs @@ -20,22 +20,57 @@ public interface IReflectionConvertor int SerializationPriority(Type type, ILogger? logger = null); + object? Deserialize( + Reflector reflector, + SerializedMember data, + Type? fallbackType = null, + string? fallbackName = null, + int depth = 0, + StringBuilder? stringBuilder = null, + ILogger? logger = null, + DeserializationContext? context = null); - object? Deserialize(Reflector reflector, SerializedMember data, Type? fallbackType = null, string? fallbackName = null, int depth = 0, StringBuilder? stringBuilder = null, ILogger? logger = null); - - SerializedMember Serialize(Reflector reflector, object? obj, Type? fallbackType = null, string? name = null, bool recursive = true, + SerializedMember Serialize( + Reflector reflector, + object? obj, + Type? fallbackType = null, + string? name = null, + bool recursive = true, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null); + int depth = 0, + StringBuilder? stringBuilder = null, + ILogger? logger = null, + SerializationContext? context = null); - bool TryPopulate(Reflector reflector, ref object? obj, SerializedMember data, Type? fallbackType = null, int depth = 0, StringBuilder? stringBuilder = null, + bool TryPopulate( + Reflector reflector, + ref object? obj, + SerializedMember data, + Type? fallbackType = null, + int depth = 0, + StringBuilder? stringBuilder = null, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, ILogger? logger = null); - bool SetField(Reflector reflector, ref object? obj, Type type, FieldInfo fieldInfo, SerializedMember? value, int depth = 0, StringBuilder? stringBuilder = null, + bool SetField( + Reflector reflector, + ref object? obj, + Type type, + FieldInfo fieldInfo, + SerializedMember? value, + int depth = 0, + StringBuilder? stringBuilder = null, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, ILogger? logger = null); - bool SetProperty(Reflector reflector, ref object? obj, Type type, PropertyInfo propertyInfo, SerializedMember? value, int depth = 0, StringBuilder? stringBuilder = null, + + bool SetProperty( + Reflector reflector, + ref object? obj, + Type type, + PropertyInfo propertyInfo, + SerializedMember? value, + int depth = 0, + StringBuilder? stringBuilder = null, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, ILogger? logger = null); @@ -44,6 +79,7 @@ bool SetProperty(Reflector reflector, ref object? obj, Type type, PropertyInfo p Type objType, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, ILogger? logger = null); + IEnumerable? GetSerializableProperties( Reflector reflector, Type objType, @@ -55,6 +91,7 @@ IEnumerable GetAdditionalSerializableFields( Type objType, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, ILogger? logger = null); + IEnumerable GetAdditionalSerializableProperties( Reflector reflector, Type objType, diff --git a/ReflectorNet/src/Convertor/Reflection/ArrayReflectionConvertor.Deserialize.cs b/ReflectorNet/src/Convertor/Reflection/ArrayReflectionConvertor.Deserialize.cs index e84929fb..b99a4aee 100644 --- a/ReflectorNet/src/Convertor/Reflection/ArrayReflectionConvertor.Deserialize.cs +++ b/ReflectorNet/src/Convertor/Reflection/ArrayReflectionConvertor.Deserialize.cs @@ -25,7 +25,8 @@ public partial class ArrayReflectionConvertor : BaseReflectionConvertor string? fallbackName = null, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + DeserializationContext? context = null) { var padding = StringUtils.GetPadding(depth); @@ -102,17 +103,22 @@ public partial class ArrayReflectionConvertor : BaseReflectionConvertor return null; } + // Register the array early (before deserializing elements) so child references can resolve + context?.Register(array); + int i = 0; foreach (var element in jsonArray.EnumerateArray()) { var member = ParseElementToMember(element); + member.name = $"[{i}]"; // Set array index as name for path tracking var deserializedElement = reflector.Deserialize( data: member, fallbackType: elementType, depth: depth + 1, stringBuilder: stringBuilder, - logger: logger); + logger: logger, + context: context); if (deserializedElement != null) { @@ -152,18 +158,25 @@ public partial class ArrayReflectionConvertor : BaseReflectionConvertor return null; } + // Register the list early (before deserializing elements) so child references can resolve + context?.Register(list); + + int i = 0; foreach (var element in jsonArray.EnumerateArray()) { var member = ParseElementToMember(element); + member.name = $"[{i}]"; // Set array index as name for path tracking var deserializedElement = reflector.Deserialize( member, fallbackType: elementType, depth: depth + 1, stringBuilder: stringBuilder, - logger: logger); + logger: logger, + context: context); addMethod.Invoke(list, new[] { deserializedElement }); + i++; } logger?.LogInformation("{padding}Successfully created list of type='{typeName}'", padding, list.GetType().GetTypeName(pretty: true)); @@ -269,7 +282,8 @@ protected virtual bool TryDeserializeValueListInternal( string? name = null, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + DeserializationContext? context = null) { var padding = StringUtils.GetPadding(depth); var paddingNext = StringUtils.GetPadding(depth + 1); @@ -333,16 +347,21 @@ protected virtual bool TryDeserializeValueListInternal( return false; } + // Register the list early (before deserializing elements) so child references can resolve + context?.Register(list); + int i = 0; foreach (var element in jsonArray.EnumerateArray()) { var member = ParseElementToMember(element); + member.name = $"[{i}]"; // Set array index as name for path tracking var parsedValue = reflector.Deserialize( data: member, fallbackType: itemType, depth: depth + 1, stringBuilder: stringBuilder, - logger: logger + logger: logger, + context: context ); if (logger?.IsEnabled(LogLevel.Trace) == true) diff --git a/ReflectorNet/src/Convertor/Reflection/ArrayReflectionConvertor.cs b/ReflectorNet/src/Convertor/Reflection/ArrayReflectionConvertor.cs index 6cbc45de..302ef170 100644 --- a/ReflectorNet/src/Convertor/Reflection/ArrayReflectionConvertor.cs +++ b/ReflectorNet/src/Convertor/Reflection/ArrayReflectionConvertor.cs @@ -60,7 +60,8 @@ protected override SerializedMember InternalSerialize( BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + SerializationContext? context = null) { if (obj == null) return SerializedMember.FromJson(type, json: null, name: name); @@ -84,7 +85,8 @@ protected override SerializedMember InternalSerialize( flags: flags, depth: depth, stringBuilder: stringBuilder, - logger: logger)); + logger: logger, + context: context)); } return SerializedMember.FromValue( diff --git a/ReflectorNet/src/Convertor/Reflection/Base/BaseReflectionConvertor.Deserialize.cs b/ReflectorNet/src/Convertor/Reflection/Base/BaseReflectionConvertor.Deserialize.cs index 425c4215..8020b7bd 100644 --- a/ReflectorNet/src/Convertor/Reflection/Base/BaseReflectionConvertor.Deserialize.cs +++ b/ReflectorNet/src/Convertor/Reflection/Base/BaseReflectionConvertor.Deserialize.cs @@ -57,7 +57,8 @@ public abstract partial class BaseReflectionConvertor : IReflectionConvertor string? fallbackName = null, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + DeserializationContext? context = null) { if (!TryDeserializeValue(reflector, serializedMember: data, @@ -73,6 +74,10 @@ public abstract partial class BaseReflectionConvertor : IReflectionConvertor var padding = StringUtils.GetPadding(depth); + // Register the object early (before deserializing children) so child references can resolve + if (result != null && context != null) + context.Register(result); + if (data.fields != null) { if (data.fields.Count > 0) @@ -94,7 +99,12 @@ public abstract partial class BaseReflectionConvertor : IReflectionConvertor continue; } - var fieldValue = reflector.Deserialize(field, depth: depth + 1, stringBuilder: stringBuilder, logger: logger); + var fieldValue = reflector.Deserialize( + data: field, + depth: depth + 1, + stringBuilder: stringBuilder, + logger: logger, + context: context); var fieldInfo = type!.GetField(field.name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (fieldInfo == null) @@ -135,7 +145,8 @@ public abstract partial class BaseReflectionConvertor : IReflectionConvertor property, depth: depth + 1, stringBuilder: stringBuilder, - logger: logger); + logger: logger, + context: context); var propertyInfo = type!.GetProperty(property.name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); if (propertyInfo == null) diff --git a/ReflectorNet/src/Convertor/Reflection/Base/BaseReflectionConvertor.Serialize.cs b/ReflectorNet/src/Convertor/Reflection/Base/BaseReflectionConvertor.Serialize.cs index 25251a8f..c17e1d5c 100644 --- a/ReflectorNet/src/Convertor/Reflection/Base/BaseReflectionConvertor.Serialize.cs +++ b/ReflectorNet/src/Convertor/Reflection/Base/BaseReflectionConvertor.Serialize.cs @@ -23,7 +23,7 @@ public abstract partial class BaseReflectionConvertor : IReflectionConvertor public virtual SerializedMember Serialize(Reflector reflector, object? obj, Type? type = null, string? name = null, bool recursive = true, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, SerializationContext? context = null) { return InternalSerialize(reflector, obj, type: type ?? obj?.GetType() ?? typeof(T), @@ -32,11 +32,12 @@ public virtual SerializedMember Serialize(Reflector reflector, object? obj, Type flags: flags, depth: depth, stringBuilder: stringBuilder, - logger: logger); + logger: logger, + context: context); } protected virtual SerializedMemberList? SerializeFields(Reflector reflector, object obj, BindingFlags flags, - int depth = 0, StringBuilder? stringBuilder = null, ILogger? logger = null) + int depth = 0, StringBuilder? stringBuilder = null, ILogger? logger = null, SerializationContext? context = null) { var serializedFields = default(SerializedMemberList); var objType = obj.GetType(); @@ -60,14 +61,15 @@ public virtual SerializedMember Serialize(Reflector reflector, object? obj, Type flags: flags, depth: depth + 1, stringBuilder: stringBuilder, - logger: logger) + logger: logger, + context: context) ); } return serializedFields; } protected virtual SerializedMemberList? SerializeProperties(Reflector reflector, object obj, BindingFlags flags, - int depth = 0, StringBuilder? stringBuilder = null, ILogger? logger = null) + int depth = 0, StringBuilder? stringBuilder = null, ILogger? logger = null, SerializationContext? context = null) { var serializedProperties = default(SerializedMemberList); var objType = obj.GetType(); @@ -92,7 +94,8 @@ public virtual SerializedMember Serialize(Reflector reflector, object? obj, Type flags: flags, depth: depth + 1, stringBuilder: stringBuilder, - logger: logger) + logger: logger, + context: context) ); } catch { /* skip inaccessible properties */ } @@ -103,6 +106,6 @@ public virtual SerializedMember Serialize(Reflector reflector, object? obj, Type protected abstract SerializedMember InternalSerialize(Reflector reflector, object? obj, Type type, string? name = null, bool recursive = true, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null); + ILogger? logger = null, SerializationContext? context = null); } } \ No newline at end of file diff --git a/ReflectorNet/src/Convertor/Reflection/GenericReflectionConvertor.cs b/ReflectorNet/src/Convertor/Reflection/GenericReflectionConvertor.cs index 739debb6..9b0a1e83 100644 --- a/ReflectorNet/src/Convertor/Reflection/GenericReflectionConvertor.cs +++ b/ReflectorNet/src/Convertor/Reflection/GenericReflectionConvertor.cs @@ -29,7 +29,8 @@ protected override SerializedMember InternalSerialize( BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + SerializationContext? context = null) { if (obj == null) return SerializedMember.FromJson(type, json: null, name: name); @@ -42,8 +43,8 @@ protected override SerializedMember InternalSerialize( { name = name, typeName = type.GetTypeName(pretty: false) ?? string.Empty, - fields = base.SerializeFields(reflector, obj, flags, depth: depth, stringBuilder: stringBuilder, logger: logger), - props = base.SerializeProperties(reflector, obj, flags, depth: depth, stringBuilder: stringBuilder, logger: logger), + fields = base.SerializeFields(reflector, obj, flags, depth: depth, stringBuilder: stringBuilder, logger: logger, context: context), + props = base.SerializeProperties(reflector, obj, flags, depth: depth, stringBuilder: stringBuilder, logger: logger, context: context), valueJsonElement = new JsonObject().ToJsonElement() } : SerializedMember.FromJson(type, obj.ToJson(reflector), name: name); diff --git a/ReflectorNet/src/Convertor/Reflection/PrimitiveReflectionConvertor.cs b/ReflectorNet/src/Convertor/Reflection/PrimitiveReflectionConvertor.cs index d2d470ec..71269a30 100644 --- a/ReflectorNet/src/Convertor/Reflection/PrimitiveReflectionConvertor.cs +++ b/ReflectorNet/src/Convertor/Reflection/PrimitiveReflectionConvertor.cs @@ -31,7 +31,7 @@ public override int SerializationPriority(Type type, ILogger? logger = null) protected override SerializedMember InternalSerialize(Reflector reflector, object? obj, Type type, string? name = null, bool recursive = true, BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, SerializationContext? context = null) { if (obj == null) return SerializedMember.FromJson(type, json: null, name: name); diff --git a/ReflectorNet/src/Convertor/Reflection/Specialized/AssemblyReflectionConvertor.cs b/ReflectorNet/src/Convertor/Reflection/Specialized/AssemblyReflectionConvertor.cs index 5f5e1445..1a4c70d1 100644 --- a/ReflectorNet/src/Convertor/Reflection/Specialized/AssemblyReflectionConvertor.cs +++ b/ReflectorNet/src/Convertor/Reflection/Specialized/AssemblyReflectionConvertor.cs @@ -34,7 +34,8 @@ protected override SerializedMember InternalSerialize( BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + SerializationContext? context = null) { if (obj is Assembly assemblyObj) { @@ -42,7 +43,7 @@ protected override SerializedMember InternalSerialize( return SerializedMember.FromValue(reflector, type, assemblyName, name: name); } - return base.InternalSerialize(reflector, obj, type, name, recursive, flags, depth, stringBuilder, logger); + return base.InternalSerialize(reflector, obj, type, name, recursive, flags, depth, stringBuilder, logger, context); } public override object? CreateInstance(Reflector reflector, Type type) diff --git a/ReflectorNet/src/Convertor/Reflection/Specialized/TypeReflectionConvertor.cs b/ReflectorNet/src/Convertor/Reflection/Specialized/TypeReflectionConvertor.cs index 19bdb500..ad943f28 100644 --- a/ReflectorNet/src/Convertor/Reflection/Specialized/TypeReflectionConvertor.cs +++ b/ReflectorNet/src/Convertor/Reflection/Specialized/TypeReflectionConvertor.cs @@ -34,7 +34,8 @@ protected override SerializedMember InternalSerialize( BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + SerializationContext? context = null) { if (obj is Type typeObj) { @@ -42,7 +43,7 @@ protected override SerializedMember InternalSerialize( return SerializedMember.FromValue(reflector, type, typeName, name: name); } - return base.InternalSerialize(reflector, obj, type, name, recursive, flags, depth, stringBuilder, logger); + return base.InternalSerialize(reflector, obj, type, name, recursive, flags, depth, stringBuilder, logger, context); } public override object? CreateInstance(Reflector reflector, Type type) diff --git a/ReflectorNet/src/Model/DeserializationContext.cs b/ReflectorNet/src/Model/DeserializationContext.cs new file mode 100644 index 00000000..580f858c --- /dev/null +++ b/ReflectorNet/src/Model/DeserializationContext.cs @@ -0,0 +1,95 @@ +/* + * ReflectorNet + * Author: Ivan Murzak (https://github.com/IvanMurzak) + * Copyright (c) 2025 Ivan Murzak + * Licensed under the Apache License, Version 2.0. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace com.IvanMurzak.ReflectorNet.Model +{ + /// + /// Manages the context for deserialization, specifically for resolving $ref references. + /// + public class DeserializationContext + { + private readonly Dictionary _resolvedObjects; + private readonly Stack _pathStack; + + public DeserializationContext() + { + _resolvedObjects = new Dictionary(); + _pathStack = new Stack(); + _pathStack.Push("#"); // Root + } + + /// + /// Enters a new path segment (property name or array index). + /// Call this BEFORE deserializing a child element. + /// + /// The path segment (property name or "[index]" for arrays). + public void Enter(string? segment) + { + if (!string.IsNullOrEmpty(segment)) + _pathStack.Push(segment!); + } + + /// + /// Exits the current path segment. + /// Call this AFTER deserializing a child element. + /// + /// The path segment that was used to enter. + public void Exit(string? segment) + { + if (!string.IsNullOrEmpty(segment)) + _pathStack.Pop(); + } + + /// + /// Registers a deserialized object at the current path. + /// + /// The deserialized object to register. + public void Register(object obj) + { + var path = BuildCurrentPath(); + _resolvedObjects[path] = obj; + } + + /// + /// Attempts to resolve a $ref path to a previously deserialized object. + /// + /// The JSON Pointer path from the $ref value. + /// The resolved object if found, null otherwise. + /// True if the reference was resolved, false otherwise. + public bool TryResolve(string refPath, out object? result) + { + if (_resolvedObjects.TryGetValue(refPath, out var obj)) + { + result = obj; + return true; + } + result = null; + return false; + } + + /// + /// Gets the current JSON Pointer path. + /// + /// The current path as a JSON Pointer string. + public string GetCurrentPath() => BuildCurrentPath(); + + private string BuildCurrentPath() + { + if (_pathStack.Count == 1) + return "#"; + + // Stack enumerates LIFO, so reverse to get correct path order + var segments = new string[_pathStack.Count]; + _pathStack.CopyTo(segments, 0); + Array.Reverse(segments); + return string.Join("/", segments); + } + } +} diff --git a/ReflectorNet/src/Model/SerializationContext.cs b/ReflectorNet/src/Model/SerializationContext.cs new file mode 100644 index 00000000..102c11bc --- /dev/null +++ b/ReflectorNet/src/Model/SerializationContext.cs @@ -0,0 +1,101 @@ +/* + * ReflectorNet + * Author: Ivan Murzak (https://github.com/IvanMurzak) + * Copyright (c) 2025 Ivan Murzak + * Licensed under the Apache License, Version 2.0. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace com.IvanMurzak.ReflectorNet.Model +{ + /// + /// Manages the context for serialization, specifically for detecting recursive cycles. + /// + public class SerializationContext + { + private readonly Dictionary _visited; + private readonly Stack _pathStack; + + public SerializationContext() + { + _visited = new Dictionary(new ReferenceEqualityComparer()); + _pathStack = new Stack(); + _pathStack.Push("#"); // Root + } + + /// + /// Enters a new object scope. + /// + /// The object being visited. + /// The path segment (property name or index) leading to this object. + /// True if the object can be visited (no cycle); False if a cycle is detected. + public bool Enter(object obj, string? segment) + { + if (!string.IsNullOrEmpty(segment)) + { + _pathStack.Push(segment!); + } + + if (_visited.ContainsKey(obj)) + { + // Cycle detected. Caller must still call Exit() to pop the segment from the stack. + return false; + } + + // Compute and store the path string directly (more memory efficient than storing List) + _visited[obj] = BuildCurrentPath(); + return true; + } + + private string BuildCurrentPath() + { + if (_pathStack.Count == 1) + return "#"; + + // Stack enumerates LIFO, so reverse to get correct path order + var segments = new string[_pathStack.Count]; + _pathStack.CopyTo(segments, 0); + System.Array.Reverse(segments); + return string.Join("/", segments); + } + + /// + /// Exits the object scope. + /// + /// The object being left. + /// The path segment that was used to enter. + public void Exit(object obj, string? segment) + { + _visited.Remove(obj); + if (!string.IsNullOrEmpty(segment)) + { + _pathStack.Pop(); + } + } + + /// + /// Gets the JSON Pointer path to the first occurrence of the object. + /// + /// The object to find. + /// The JSON Pointer string. + public string GetPath(object obj) + { + if (!_visited.TryGetValue(obj, out var path)) + { + throw new System.InvalidOperationException( + $"Object of type '{obj.GetType().GetTypeShortName()}' was not found in the serialization context. " + + "GetPath should only be called for objects that have been visited."); + } + + return path; + } + + private class ReferenceEqualityComparer : IEqualityComparer + { + bool IEqualityComparer.Equals(object? x, object? y) => ReferenceEquals(x, y); + int IEqualityComparer.GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/ReflectorNet/src/Model/SerializedMember.cs b/ReflectorNet/src/Model/SerializedMember.cs index 9ac17034..142d4981 100644 --- a/ReflectorNet/src/Model/SerializedMember.cs +++ b/ReflectorNet/src/Model/SerializedMember.cs @@ -9,6 +9,7 @@ using System.ComponentModel; using System.Linq; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using com.IvanMurzak.ReflectorNet.Utils; @@ -53,6 +54,18 @@ public SerializedMember SetName(string? name) return this; } + public static SerializedMember FromReference(string path, string? name) + { + var jsonObject = new JsonObject { [JsonSchema.Ref] = path }; + + return new SerializedMember + { + name = name, + typeName = JsonSchema.Reference, + valueJsonElement = jsonObject.ToJsonElement() + }; + } + public SerializedMember? GetField(string name) => fields?.FirstOrDefault(x => x.name == name); diff --git a/ReflectorNet/src/Reflector/Reflector.cs b/ReflectorNet/src/Reflector/Reflector.cs index 87962f0a..e66269a0 100644 --- a/ReflectorNet/src/Reflector/Reflector.cs +++ b/ReflectorNet/src/Reflector/Reflector.cs @@ -91,29 +91,49 @@ public SerializedMember Serialize( bool recursive = true, BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + SerializationContext? context = null) { - var type = TypeUtils.GetTypeWithObjectPriority(obj, fallbackType, out var error); - if (type == null) - throw new ArgumentException(error); + context ??= new SerializationContext(); - var convertor = Convertors.GetConvertor(type); - if (convertor == null) - throw new ArgumentException($"[Error] Type '{type.GetTypeName(pretty: false).ValueOrNull()}' not supported for serialization."); + if (obj != null && !context.Enter(obj, name)) + { + var path = context.GetPath(obj); + return SerializedMember.FromReference(path, name); + } - if (logger?.IsEnabled(LogLevel.Trace) == true) - logger.LogTrace($"{StringUtils.GetPadding(depth)} Serialize. {convertor.GetType().GetTypeShortName()} used for type='{type.GetTypeShortName()}', name='{name.ValueOrNull()}'"); + try + { + var type = TypeUtils.GetTypeWithObjectPriority(obj, fallbackType, out var error); + if (type == null) + throw new ArgumentException(error); - return convertor.Serialize( - this, - obj, - fallbackType: type, - name: name, - recursive, - flags, - depth: depth, - stringBuilder: stringBuilder, - logger: logger); + var convertor = Convertors.GetConvertor(type); + if (convertor == null) + throw new ArgumentException($"[Error] Type '{type.GetTypeName(pretty: false).ValueOrNull()}' not supported for serialization."); + + if (logger?.IsEnabled(LogLevel.Trace) == true) + logger.LogTrace($"{StringUtils.GetPadding(depth)} Serialize. {convertor.GetType().GetTypeShortName()} used for type='{type.GetTypeShortName()}', name='{name.ValueOrNull()}'"); + + return convertor.Serialize( + this, + obj, + fallbackType: type, + name: name, + recursive, + flags, + depth: depth, + stringBuilder: stringBuilder, + logger: logger, + context: context); + } + finally + { + if (obj != null) + { + context.Exit(obj, name); + } + } } /// @@ -141,7 +161,8 @@ public SerializedMember Serialize( string? fallbackName = null, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) where T : class + ILogger? logger = null, + DeserializationContext? context = null) where T : class { return Deserialize( data, @@ -149,7 +170,8 @@ public SerializedMember Serialize( fallbackName: fallbackName, depth: depth, stringBuilder: stringBuilder, - logger: logger) as T; + logger: logger, + context: context) as T; } /// @@ -179,7 +201,8 @@ public SerializedMember Serialize( string? fallbackName = null, int depth = 0, StringBuilder? stringBuilder = null, - ILogger? logger = null) + ILogger? logger = null, + DeserializationContext? context = null) { if (data == null) { @@ -192,31 +215,107 @@ public SerializedMember Serialize( } var padding = StringUtils.GetPadding(depth); - var type = TypeUtils.GetTypeWithNamePriority(data, fallbackType, out var error); - if (type == null) + var name = StringUtils.IsNullOrEmpty(data.name) ? fallbackName : data.name; + + // Create context at root level + context ??= new DeserializationContext(); + + // Check for reference type BEFORE normal deserialization + if (data.typeName == JsonSchema.Reference) { - logger?.LogError($"{padding}{error}"); - stringBuilder?.AppendLine($"{padding}[Error] {error}"); + return ResolveReference(data, context, depth, stringBuilder, logger); + } + + // Enter the current path segment for tracking + context.Enter(name); + + try + { + var type = TypeUtils.GetTypeWithNamePriority(data, fallbackType, out var error); + if (type == null) + { + logger?.LogError($"{padding}{error}"); + stringBuilder?.AppendLine($"{padding}[Error] {error}"); + + throw new ArgumentException(error); + } + + var convertor = Convertors.GetConvertor(type); + if (convertor == null) + throw new ArgumentException($"[Error] Type '{type?.GetTypeName(pretty: false).ValueOrNull()}' not supported for deserialization."); + + if (logger?.IsEnabled(LogLevel.Trace) == true) + logger.LogTrace($"{padding}{Consts.Emoji.Launch} Deserialize type='{type.GetTypeShortName()}' name='{name.ValueOrNull()}' convertor='{convertor.GetType().GetTypeShortName()}'"); - throw new ArgumentException(error); + var result = convertor.Deserialize( + this, + data, + type, + fallbackName, + depth: depth + 1, + stringBuilder: stringBuilder, + logger: logger, + context: context + ); + + return result; + } + finally + { + context.Exit(name); } + } - var convertor = Convertors.GetConvertor(type); - if (convertor == null) - throw new ArgumentException($"[Error] Type '{type?.GetTypeName(pretty: false).ValueOrNull()}' not supported for deserialization."); + private object? ResolveReference( + SerializedMember data, + DeserializationContext context, + int depth, + StringBuilder? stringBuilder, + ILogger? logger) + { + var padding = StringUtils.GetPadding(depth); + + // Parse the $ref path from valueJsonElement + if (data.valueJsonElement == null) + { + if (logger?.IsEnabled(LogLevel.Warning) == true) + logger.LogWarning($"{padding}{Consts.Emoji.Warn} Reference has no value"); + stringBuilder?.AppendLine($"{padding}[Warning] Reference has no value"); + return null; + } + + if (!data.valueJsonElement.Value.TryGetProperty(JsonSchema.Ref, out var refElement)) + { + if (logger?.IsEnabled(LogLevel.Warning) == true) + logger.LogWarning($"{padding}{Consts.Emoji.Warn} Reference value missing {JsonSchema.Ref} property"); + stringBuilder?.AppendLine($"{padding}[Warning] Reference value missing {JsonSchema.Ref} property"); + return null; + } + + var refPath = refElement.GetString(); + if (string.IsNullOrEmpty(refPath)) + { + if (logger?.IsEnabled(LogLevel.Warning) == true) + logger.LogWarning($"{padding}{Consts.Emoji.Warn} Reference path is null or empty"); + stringBuilder?.AppendLine($"{padding}[Warning] Reference path is null or empty"); + return null; + } if (logger?.IsEnabled(LogLevel.Trace) == true) - logger.LogTrace($"{padding}{Consts.Emoji.Launch} Deserialize type='{type.GetTypeShortName()}' name='{(StringUtils.IsNullOrEmpty(data.name) ? fallbackName : data.name).ValueOrNull()}' convertor='{convertor.GetType().GetTypeShortName()}'"); + logger.LogTrace($"{padding}{Consts.Emoji.Start} Resolving reference: {refPath}"); - return convertor.Deserialize( - this, - data, - type, - fallbackName, - depth: depth + 1, - stringBuilder: stringBuilder, - logger: logger - ); + if (context.TryResolve(refPath, out var resolved)) + { + if (logger?.IsEnabled(LogLevel.Trace) == true) + logger.LogTrace($"{padding}{Consts.Emoji.Done} Resolved reference to object of type: {resolved?.GetType().GetTypeShortName()}"); + return resolved; + } + + // Reference not yet resolved - this could happen with forward references + if (logger?.IsEnabled(LogLevel.Warning) == true) + logger.LogWarning($"{padding}{Consts.Emoji.Warn} Unable to resolve reference: {refPath}"); + stringBuilder?.AppendLine($"{padding}[Warning] Unable to resolve reference: {refPath}"); + return null; } /// diff --git a/ReflectorNet/src/Utils/Json/JsonSchema.Consts.cs b/ReflectorNet/src/Utils/Json/JsonSchema.Consts.cs index 54ff73d9..f8eb146d 100644 --- a/ReflectorNet/src/Utils/Json/JsonSchema.Consts.cs +++ b/ReflectorNet/src/Utils/Json/JsonSchema.Consts.cs @@ -35,5 +35,7 @@ public partial class JsonSchema public const string RefValue = "#/$defs/"; public const string SchemaDraft = "$schema"; public const string SchemaDraftValue = "https://json-schema.org/draft/2020-12/schema"; + + public const string Reference = "Reference"; } } \ No newline at end of file