Improve reference handling for serializers and IId types

- Make ReferenceHandlingMode type-aware; OnlyId fully supported for binary, All is default for JSON
- ReferenceHandling is now settable; add ThrowOnCircularReference option
- Always sort Id property first for IId types to optimize tracking
- Serialize/deserialize IId.Id without type marker when reference handling is enabled
- Contexts now delegate options-derived properties to Options
- Improve skip logic and property filter performance in binary serializer
- Update tests to explicitly set ReferenceHandlingMode.All
- Refactor internal APIs for clarity, safety, and efficiency
This commit is contained in:
Loretta 2026-01-23 20:32:51 +01:00
parent 852ab53af3
commit de2727ac8a
15 changed files with 179 additions and 72 deletions

View File

@ -1,4 +1,6 @@
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using static AyCode.Core.Tests.TestModels.AcSerializerModels; using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization; namespace AyCode.Core.Tests.Serialization;
@ -54,7 +56,10 @@ public class AcBinarySerializerCircularReferenceTests
child.GrandChildren.Add(grandChild); child.GrandChildren.Add(grandChild);
parent.Children.Add(child); parent.Children.Add(child);
var binary = parent.ToBinary(); var option = AcBinarySerializerOptions.Default;
option.ReferenceHandling = ReferenceHandlingMode.All;
var binary = parent.ToBinary(option);
var result = binary.BinaryTo<CircularParent>(); var result = binary.BinaryTo<CircularParent>();
Assert.IsNotNull(result); Assert.IsNotNull(result);
@ -125,7 +130,10 @@ public class AcBinarySerializerCircularReferenceTests
return parent; return parent;
}).ToList(); }).ToList();
var binary = parents.ToBinary(); var option = AcBinarySerializerOptions.Default;
option.ReferenceHandling = ReferenceHandlingMode.All;
var binary = parents.ToBinary(option);
var result = binary.BinaryTo<List<CircularParent>>(); var result = binary.BinaryTo<List<CircularParent>>();
Assert.IsNotNull(result); Assert.IsNotNull(result);

View File

@ -23,8 +23,9 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
/// </summary> /// </summary>
public TOptions Options { get; private set; } = null!; public TOptions Options { get; private set; } = null!;
public byte MaxDepth { get; private set; } public byte MaxDepth => Options.MaxDepth;
public ReferenceHandlingMode ReferenceHandling { get; internal set; } public ReferenceHandlingMode ReferenceHandling => Options.ReferenceHandling;
public bool ThrowOnCircularReference => Options.ThrowOnCircularReference;
/// <summary> /// <summary>
/// Global shared cache for metadata (thread-safe, shared across all contexts). /// Global shared cache for metadata (thread-safe, shared across all contexts).
/// Generic specialization ensures separate cache per TMetadata type. /// Generic specialization ensures separate cache per TMetadata type.
@ -142,14 +143,12 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
/// </summary> /// </summary>
public virtual void Reset(TOptions options) public virtual void Reset(TOptions options)
{ {
Options = options;
foreach (var wrapper in _wrappers.Values) foreach (var wrapper in _wrappers.Values)
{ {
wrapper.ResetTracking(); wrapper.ResetTracking();
} }
Options = options;
MaxDepth = options.MaxDepth;
ReferenceHandling = options.ReferenceHandling;
} }
#endregion #endregion

View File

@ -22,13 +22,10 @@ public static partial class AcBinaryDeserializer
private List<string>? _propertyNames; private List<string>? _propertyNames;
//private Dictionary<int, object>? _objectReferences; //private Dictionary<int, object>? _objectReferences;
private Dictionary<int, string>? _stringCache; private Dictionary<int, string>? _stringCache;
private readonly byte _minStringInternLength;
private readonly bool _useStringCaching;
private readonly int _maxCachedStringLength;
/// <summary> /// <summary>
/// Heap-allocated context class for IId-based reference tracking. /// Heap-allocated context class for IId-based reference tracking.
/// Uses AcSerializerContextBase infrastructure. /// Also holds Options - all options-derived properties delegate to ContextClass.Options.
/// </summary> /// </summary>
public readonly BinaryDeserializationContextClass ContextClass; public readonly BinaryDeserializationContextClass ContextClass;
@ -36,12 +33,16 @@ public static partial class AcBinaryDeserializer
/// <summary> /// <summary>
/// Convenience property - true if any reference handling is enabled. /// Convenience property - true if any reference handling is enabled.
/// </summary> /// </summary>
public bool HasReferenceHandling => ContextClass?.ReferenceHandling != ReferenceHandlingMode.None; //public readonly bool HasReferenceHandling => ContextClass.ReferenceHandling != ReferenceHandlingMode.None;
public bool IsMergeMode { readonly get; set; } public bool IsMergeMode { readonly get; set; }
public bool RemoveOrphanedItems { readonly get; set; } public bool RemoveOrphanedItems { readonly get; set; }
public bool IsAtEnd => _position >= _buffer.Length; public readonly bool IsAtEnd => _position >= _buffer.Length;
public int Position => _position; public readonly int Position => _position;
public byte MinStringInternLength => _minStringInternLength;
// Options-derived properties - delegate to ContextClass.Options
public readonly byte MinStringInternLength => ContextClass.Options.MinStringInternLength;
public readonly bool UseStringCaching => ContextClass.Options.UseStringCaching;
public readonly int MaxCachedStringLength => ContextClass.Options.MaxCachedStringLength;
/// <summary> /// <summary>
/// Chain reference tracker for maintaining object identity across chain operations. /// Chain reference tracker for maintaining object identity across chain operations.
@ -76,12 +77,9 @@ public static partial class AcBinaryDeserializer
IsMergeMode = false; IsMergeMode = false;
RemoveOrphanedItems = false; RemoveOrphanedItems = false;
ChainTracker = null; ChainTracker = null;
_minStringInternLength = options.MinStringInternLength;
_useStringCaching = options.UseStringCaching;
_maxCachedStringLength = options.MaxCachedStringLength;
ContextClass = contextClass; ContextClass = contextClass;
// Initialize ReferenceHandling from options (will be overwritten by ReadHeader if present in stream) // Reset ContextClass with options - this sets Options and clears any previous state
ContextClass.ReferenceHandling = options.ReferenceHandling; ContextClass.Reset(options);
} }
public void ReadHeader() public void ReadHeader()
@ -108,11 +106,11 @@ public static partial class AcBinaryDeserializer
if (marker == BinaryTypeCode.MetadataHeader) if (marker == BinaryTypeCode.MetadataHeader)
{ {
hasPropertyTable = true; hasPropertyTable = true;
ContextClass.ReferenceHandling = ReferenceHandlingMode.OnlyId; // Legacy: assume OnlyId ContextClass.Options.ReferenceHandling = ReferenceHandlingMode.OnlyId; // Legacy: assume OnlyId
} }
else if (marker == BinaryTypeCode.NoMetadataHeader) else if (marker == BinaryTypeCode.NoMetadataHeader)
{ {
ContextClass.ReferenceHandling = ReferenceHandlingMode.OnlyId; // Legacy: assume OnlyId ContextClass.Options.ReferenceHandling = ReferenceHandlingMode.OnlyId; // Legacy: assume OnlyId
} }
else if ((marker & 0xF0) == BinaryTypeCode.HeaderFlagsBase) else if ((marker & 0xF0) == BinaryTypeCode.HeaderFlagsBase)
{ {
@ -121,7 +119,7 @@ public static partial class AcBinaryDeserializer
// Decode ReferenceHandlingMode from separate bits // Decode ReferenceHandlingMode from separate bits
var hasOnlyId = (flags & BinaryTypeCode.HeaderFlag_RefHandling_OnlyId) != 0; var hasOnlyId = (flags & BinaryTypeCode.HeaderFlag_RefHandling_OnlyId) != 0;
var hasAll = (flags & BinaryTypeCode.HeaderFlag_RefHandling_All) != 0; var hasAll = (flags & BinaryTypeCode.HeaderFlag_RefHandling_All) != 0;
ContextClass.ReferenceHandling = hasAll ? ReferenceHandlingMode.All ContextClass.Options.ReferenceHandling = hasAll ? ReferenceHandlingMode.All
: hasOnlyId ? ReferenceHandlingMode.OnlyId : hasOnlyId ? ReferenceHandlingMode.OnlyId
: ReferenceHandlingMode.None; : ReferenceHandlingMode.None;
@ -448,7 +446,7 @@ public static partial class AcBinaryDeserializer
EnsureAvailable(length); EnsureAvailable(length);
// WASM optimization: cache short strings to reduce allocations // WASM optimization: cache short strings to reduce allocations
if (_useStringCaching && length <= _maxCachedStringLength) if (UseStringCaching && length <= MaxCachedStringLength)
{ {
return ReadStringUtf8Cached(length); return ReadStringUtf8Cached(length);
} }

View File

@ -219,7 +219,7 @@ public static partial class AcBinaryDeserializer
var metadata = wrapper.Metadata; var metadata = wrapper.Metadata;
// Handle reference ID if present // Handle reference ID if present
if (context.HasReferenceHandling) if (context.ContextClass.UseTypeReferenceHandling(metadata))
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
if (context.ContextClass.TryGetValue(wrapper, refId, out var instance)) return instance; if (context.ContextClass.TryGetValue(wrapper, refId, out var instance)) return instance;
@ -270,7 +270,7 @@ public static partial class AcBinaryDeserializer
if (destPropIndex == -1) if (destPropIndex == -1)
{ {
// No mapping - skip this property // No mapping - skip this property
SkipValue(ref context); SkipValue(ref context, metadata);
continue; continue;
} }
@ -279,7 +279,7 @@ public static partial class AcBinaryDeserializer
if (propInfo == null) if (propInfo == null)
{ {
// Destination property not found - skip // Destination property not found - skip
SkipValue(ref context); SkipValue(ref context, metadata);
continue; continue;
} }
@ -336,7 +336,7 @@ public static partial class AcBinaryDeserializer
context.ReadByte(); // consume Object marker context.ReadByte(); // consume Object marker
// Handle ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.ContextClass.UseTypeReferenceHandling(wrapper.Metadata))
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
if (refId > 0) if (refId > 0)

View File

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using AyCode.Core.Helpers; using AyCode.Core.Helpers;
using AyCode.Core.Serializers;
using static AyCode.Core.Helpers.JsonUtilities; using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries; namespace AyCode.Core.Serializers.Binaries;
@ -26,7 +27,7 @@ public static partial class AcBinaryDeserializer
var metadata = wrapper.Metadata; var metadata = wrapper.Metadata;
// Handle ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.ContextClass.UseTypeReferenceHandling(metadata))
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
if (refId > 0) if (refId > 0)
@ -55,7 +56,20 @@ public static partial class AcBinaryDeserializer
var nextDepth = depth + 1; var nextDepth = depth + 1;
var isMergeMode = context.IsMergeMode; var isMergeMode = context.IsMergeMode;
for (int i = 0; i < properties.Length; i++) var startIndex = 0;
// For IId types with reference handling: Id property has no type marker (value only)
var skipIdMarker = metadata.IsIId && context.ContextClass.UseTypeReferenceHandling(metadata);
if (skipIdMarker)
{
startIndex = 1;
// Id property: read value WITHOUT type marker (serializer didn't write one)
// For IId types, Id is always at index 0 (sorted first)
ReadIdValueWithoutMarker(ref context, target, properties[0], metadata.IdAccessorType);
}
for (int i = startIndex; i < properties.Length; i++)
{ {
var propInfo = properties[i]; var propInfo = properties[i];
var peekCode = context.PeekByte(); var peekCode = context.PeekByte();
@ -115,7 +129,7 @@ public static partial class AcBinaryDeserializer
var wrapper = context.ContextClass.GetWrapper(propInfo.PropertyType); var wrapper = context.ContextClass.GetWrapper(propInfo.PropertyType);
// Handle ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.ContextClass.UseTypeReferenceHandling(wrapper.Metadata))
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
if (refId > 0) if (refId > 0)
@ -241,7 +255,7 @@ public static partial class AcBinaryDeserializer
context.ReadByte(); // consume Object marker context.ReadByte(); // consume Object marker
// Handle ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.ContextClass.UseTypeReferenceHandling(elementMetadata))
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
if (refId > 0) if (refId > 0)
@ -344,7 +358,7 @@ public static partial class AcBinaryDeserializer
if (newItem == null) continue; if (newItem == null) continue;
// Handle ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.ContextClass.UseTypeReferenceHandling(elementMetadata))
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
if (refId > 0) if (refId > 0)
@ -456,7 +470,7 @@ public static partial class AcBinaryDeserializer
if (newItem == null) continue; if (newItem == null) continue;
// Handle ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.ContextClass.UseTypeReferenceHandling(elementMetadata))
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
if (refId > 0) if (refId > 0)
@ -519,6 +533,26 @@ public static partial class AcBinaryDeserializer
} }
} }
/// <summary>
/// Reads Id value without type marker. The serializer didn't write a marker for IId types.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ReadIdValueWithoutMarker(ref BinaryDeserializationContext context, object target, BinaryPropertySetterInfo propInfo, AcSerializerCommon.IdAccessorType idType)
{
switch (idType)
{
case AcSerializerCommon.IdAccessorType.Int32:
propInfo.SetInt32(target, context.ReadVarInt());
break;
case AcSerializerCommon.IdAccessorType.Int64:
propInfo.SetInt64(target, context.ReadVarLong());
break;
case AcSerializerCommon.IdAccessorType.Guid:
propInfo.SetGuid(target, context.ReadGuidUnsafe());
break;
}
}
/// <summary> /// <summary>
/// Sets a property to its default value using typed setters to avoid boxing. /// Sets a property to its default value using typed setters to avoid boxing.
/// </summary> /// </summary>

View File

@ -968,7 +968,7 @@ public static partial class AcBinaryDeserializer
object? instance = null; object? instance = null;
if (context.HasReferenceHandling && metadata.IdAccessorType != AcSerializerCommon.IdAccessorType.None) if (context.ContextClass.UseTypeReferenceHandling(metadata))
{ {
switch (metadata.IdAccessorType) switch (metadata.IdAccessorType)
{ {
@ -1267,7 +1267,7 @@ public static partial class AcBinaryDeserializer
#region Skip Value #region Skip Value
private static void SkipValue(ref BinaryDeserializationContext context) private static void SkipValue(ref BinaryDeserializationContext context, BinaryDeserializeTypeMetadata metaData)
{ {
var typeCode = context.ReadByte(); var typeCode = context.ReadByte();
@ -1349,16 +1349,16 @@ public static partial class AcBinaryDeserializer
if (enumByte == BinaryTypeCode.Int32) context.ReadVarInt(); if (enumByte == BinaryTypeCode.Int32) context.ReadVarInt();
return; return;
case BinaryTypeCode.Object: case BinaryTypeCode.Object:
SkipObject(ref context); SkipObject(ref context, metaData);
return; return;
case BinaryTypeCode.ObjectRef: case BinaryTypeCode.ObjectRef:
context.ReadVarInt(); context.ReadVarInt();
return; return;
case BinaryTypeCode.Array: case BinaryTypeCode.Array:
SkipArray(ref context); SkipArray(ref context, metaData);
return; return;
case BinaryTypeCode.Dictionary: case BinaryTypeCode.Dictionary:
SkipDictionary(ref context); SkipDictionary(ref context, metaData);
return; return;
} }
} }
@ -1404,10 +1404,10 @@ public static partial class AcBinaryDeserializer
} }
} }
private static void SkipObject(ref BinaryDeserializationContext context) private static void SkipObject(ref BinaryDeserializationContext context, BinaryDeserializeTypeMetadata metaData)
{ {
// Skip ref ID if present // Skip ref ID if present
if (context.HasReferenceHandling) if (context.ContextClass.UseTypeReferenceHandling(metaData))
{ {
context.ReadVarInt(); context.ReadVarInt();
} }
@ -1420,22 +1420,22 @@ public static partial class AcBinaryDeserializer
"Unable to determine property count without type metadata."); "Unable to determine property count without type metadata.");
} }
private static void SkipArray(ref BinaryDeserializationContext context) private static void SkipArray(ref BinaryDeserializationContext context, BinaryDeserializeTypeMetadata metaData)
{ {
var count = (int)context.ReadVarUInt(); var count = (int)context.ReadVarUInt();
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
SkipValue(ref context); SkipValue(ref context, metaData);
} }
} }
private static void SkipDictionary(ref BinaryDeserializationContext context) private static void SkipDictionary(ref BinaryDeserializationContext context, BinaryDeserializeTypeMetadata metaData)
{ {
var count = (int)context.ReadVarUInt(); var count = (int)context.ReadVarUInt();
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
SkipValue(ref context); // key SkipValue(ref context, metaData); // key
SkipValue(ref context); // value SkipValue(ref context, metaData); // value
} }
} }

View File

@ -79,6 +79,11 @@ public static partial class AcBinarySerializer
public byte MaxStringInternLength => Options.MaxStringInternLength; public byte MaxStringInternLength => Options.MaxStringInternLength;
public BinaryPropertyFilter? PropertyFilter => Options.PropertyFilter; public BinaryPropertyFilter? PropertyFilter => Options.PropertyFilter;
/// <summary>
/// Cached check for PropertyFilter != null. Set in Reset() to avoid property getter in hot loop.
/// </summary>
public bool HasPropertyFilter { get; private set; }
public int Position => _position; public int Position => _position;
public BinarySerializationContext(AcBinarySerializerOptions options) public BinarySerializationContext(AcBinarySerializerOptions options)
@ -96,11 +101,12 @@ public static partial class AcBinarySerializer
public override void Reset(AcBinarySerializerOptions options) public override void Reset(AcBinarySerializerOptions options)
{ {
// Reset wrapper tracking state from base class (IId tracking) // IMPORTANT: base.Reset sets Options first, so derived code can use Options-derived properties
base.Reset(options); base.Reset(options);
_position = 0; _position = 0;
_initialBufferSize = Math.Max(options.InitialBufferCapacity, MinBufferSize); _initialBufferSize = Math.Max(Options.InitialBufferCapacity, MinBufferSize);
HasPropertyFilter = Options.PropertyFilter != null;
if (_buffer.Length < _initialBufferSize) if (_buffer.Length < _initialBufferSize)
{ {

View File

@ -641,8 +641,8 @@ public static partial class AcBinarySerializer
var wrapper = context.GetWrapper(type); var wrapper = context.GetWrapper(type);
var metadata = wrapper.Metadata; var metadata = wrapper.Metadata;
// Single-pass reference tracking // Single-pass reference tracking - type-specific check
if (context.ReferenceHandling != ReferenceHandlingMode.None) if (context.UseTypeReferenceHandling(metadata))
{ {
switch (metadata.IdAccessorType) switch (metadata.IdAccessorType)
{ {
@ -684,27 +684,56 @@ public static partial class AcBinarySerializer
} }
else else
{ {
// No reference handling - just write object marker // No reference handling for this type - just write object marker
context.WriteByte(BinaryTypeCode.Object); context.WriteByte(BinaryTypeCode.Object);
} }
// Write properties // Write properties
var nextDepth = depth + 1; var nextDepth = depth + 1;
var properties = metadata.Properties; var properties = metadata.Properties;
var propCount = properties.Length; var propCount = properties.Length;
var startIndex = 0;
// For IId types with reference handling: skip type marker for Id property (value only)
// The deserializer knows the Id type from metadata, so marker is redundant
var skipIdMarker = metadata.IsIId && context.UseTypeReferenceHandling(metadata);
if (skipIdMarker)
{
startIndex = 1;
var prop = properties[0];
// Id property: write value WITHOUT type marker (deserializer knows type from metadata)
// For IId types, Id is always at index 0 (sorted first)
switch (metadata.IdAccessorType)
{
case AcSerializerCommon.IdAccessorType.Int32:
context.WriteVarInt(prop.GetInt32(value));
break;
case AcSerializerCommon.IdAccessorType.Int64:
context.WriteVarLong(prop.GetInt64(value));
break;
case AcSerializerCommon.IdAccessorType.Guid:
context.WriteGuidBits(prop.GetGuid(value));
break;
}
}
// Single-pass serialization with SKIP markers // Single-pass serialization with SKIP markers
// - No property count needed (fixed property order) // - No property count needed (fixed property order)
// - No property indices needed (sequential order) // - No property indices needed (sequential order)
// - Single getter call per property // - Single getter call per property
// - Write value OR skip marker in one operation // - Write value OR skip marker in one operation
var hasPropertyFilter = context.HasPropertyFilter;
for (var i = 0; i < propCount; i++) for (var i = startIndex; i < propCount; i++)
{ {
var prop = properties[i]; var prop = properties[i];
// Skip if filter says no - write skip marker // Skip if filter says no - write skip marker
if (context.PropertyFilter != null && !context.ShouldSerializeProperty(value, prop)) if (hasPropertyFilter && !context.ShouldSerializeProperty(value, prop))
{ {
context.WriteByte(BinaryTypeCode.PropertySkip); context.WriteByte(BinaryTypeCode.PropertySkip);
continue; continue;

View File

@ -77,11 +77,12 @@ public static partial class AcJsonDeserializer
public override void Reset(AcJsonSerializerOptions options) public override void Reset(AcJsonSerializerOptions options)
{ {
// IMPORTANT: base.Reset sets Options first
base.Reset(options);
IsMergeMode = false; IsMergeMode = false;
ChainTracker = null; ChainTracker = null;
_refTracker.Reset(); _refTracker.Reset();
base.Reset(options);
} }
public void Clear(AcJsonSerializerOptions options) public void Clear(AcJsonSerializerOptions options)

View File

@ -64,14 +64,15 @@ public static partial class AcJsonSerializer
public override void Reset(AcJsonSerializerOptions options) public override void Reset(AcJsonSerializerOptions options)
{ {
// IMPORTANT: base.Reset sets Options first
base.Reset(options);
_refTracker.Reset(); _refTracker.Reset();
if (options.ReferenceHandling != ReferenceHandlingMode.None) if (ReferenceHandling != ReferenceHandlingMode.None)
{ {
_refTracker.EnsureInitialized(); _refTracker.EnsureInitialized();
} }
base.Reset(options);
} }
public void Clear() public void Clear()

View File

@ -180,9 +180,10 @@ public static partial class AcJsonSerializer
var writer = context.Writer; var writer = context.Writer;
var wrapper = context.GetWrapper(type); var wrapper = context.GetWrapper(type);
var metadata = wrapper.Metadata; var metadata = wrapper.Metadata;
var useReferenceHandling = context.UseTypeReferenceHandling(metadata);
// Use IId-aware reference handling // Use IId-aware reference handling
if (context.ReferenceHandling != ReferenceHandlingMode.None && context.TryGetExistingRefForIId(value, metadata, out var refId)) if (useReferenceHandling && context.TryGetExistingRefForIId(value, metadata, out var refId))
{ {
writer.WriteStartObject(); writer.WriteStartObject();
writer.WriteString(RefPropertyEncoded, refId.ToString(CultureInfo.InvariantCulture)); writer.WriteString(RefPropertyEncoded, refId.ToString(CultureInfo.InvariantCulture));
@ -192,7 +193,7 @@ public static partial class AcJsonSerializer
writer.WriteStartObject(); writer.WriteStartObject();
if (context.ReferenceHandling != ReferenceHandlingMode.None && context.ShouldWriteIdForIId(value, metadata, out var id)) if (useReferenceHandling && context.ShouldWriteIdForIId(value, metadata, out var id))
{ {
writer.WriteString(IdPropertyEncoded, id.ToString(CultureInfo.InvariantCulture)); writer.WriteString(IdPropertyEncoded, id.ToString(CultureInfo.InvariantCulture));
context.MarkAsWrittenForIId(value, metadata, id); context.MarkAsWrittenForIId(value, metadata, id);

View File

@ -21,11 +21,13 @@ public enum ReferenceHandlingMode : byte
/// <summary> /// <summary>
/// Reference handling only for IId objects - uses semantic Id for deduplication. /// Reference handling only for IId objects - uses semantic Id for deduplication.
/// NOTE: Not fully implemented for JSON serializer - use All instead.
/// Binary serializer supports this mode.
/// </summary> /// </summary>
OnlyId = 1, OnlyId = 1,
/// <summary> /// <summary>
/// Full reference handling for all objects (future use). /// Full reference handling for all objects.
/// </summary> /// </summary>
All = 2 All = 2
} }
@ -45,9 +47,10 @@ public abstract class AcSerializerOptions
/// <summary> /// <summary>
/// Reference handling mode for circular/shared references. /// Reference handling mode for circular/shared references.
/// Default: OnlyId (handles IId objects) /// Default: OnlyId (JSON serializer requires All mode, OnlyId not yet implemented)
/// Note: Binary serializer supports OnlyId mode for IId-only tracking.
/// </summary> /// </summary>
public ReferenceHandlingMode ReferenceHandling { get; init; } = ReferenceHandlingMode.OnlyId; public ReferenceHandlingMode ReferenceHandling { get; set; } = ReferenceHandlingMode.OnlyId;
/// <summary> /// <summary>
/// Maximum depth for serialization/deserialization. /// Maximum depth for serialization/deserialization.
@ -58,6 +61,15 @@ public abstract class AcSerializerOptions
/// </summary> /// </summary>
public byte MaxDepth { get; init; } = byte.MaxValue; public byte MaxDepth { get; init; } = byte.MaxValue;
/// <summary>
/// Throw exception on circular reference detection for non-IId types.
/// When true: Tracks all objects and throws InvalidOperationException on circular references.
/// When false: No tracking for non-IId types (faster, but circular refs may cause MaxDepth truncation).
/// Default: true (production safety)
/// Note: IId types are always tracked when ReferenceHandling != None.
/// </summary>
public bool ThrowOnCircularReference { get; init; } = true;
/// <summary> /// <summary>
/// Optional callback for custom property mapping during cross-type operations. /// Optional callback for custom property mapping during cross-type operations.
/// Used when deserializing/populating with Deserialize&lt;TSource, TDest&gt; or Populate&lt;TSource, TDest&gt;. /// Used when deserializing/populating with Deserialize&lt;TSource, TDest&gt; or Populate&lt;TSource, TDest&gt;.
@ -85,7 +97,7 @@ public sealed class AcJsonSerializerOptions : AcSerializerOptions
/// <summary> /// <summary>
/// Default options instance with reference handling enabled and max depth. /// Default options instance with reference handling enabled and max depth.
/// </summary> /// </summary>
public static readonly AcJsonSerializerOptions Default = new(); public static readonly AcJsonSerializerOptions Default = new() { ReferenceHandling = ReferenceHandlingMode.All };
/// <summary> /// <summary>
/// Options for shallow serialization (root level only, no references). /// Options for shallow serialization (root level only, no references).

View File

@ -74,10 +74,13 @@ public static partial class AcToonSerializer
public override void Reset(AcToonSerializerOptions options) public override void Reset(AcToonSerializerOptions options)
{ {
// IMPORTANT: base.Reset sets Options first
base.Reset(options);
CurrentIndentLevel = 0; CurrentIndentLevel = 0;
_nextRefId = 1; _nextRefId = 1;
if (options.ReferenceHandling != ReferenceHandlingMode.None) if (ReferenceHandling != ReferenceHandlingMode.None)
{ {
_scanOccurrences ??= new Dictionary<object, int>(64, ReferenceEqualityComparer.Instance); _scanOccurrences ??= new Dictionary<object, int>(64, ReferenceEqualityComparer.Instance);
_writtenRefs ??= new Dictionary<object, int>(32, ReferenceEqualityComparer.Instance); _writtenRefs ??= new Dictionary<object, int>(32, ReferenceEqualityComparer.Instance);
@ -85,7 +88,6 @@ public static partial class AcToonSerializer
} }
_registeredTypes ??= new HashSet<Type>(16); _registeredTypes ??= new HashSet<Type>(16);
base.Reset(options);
} }
public void Clear() public void Clear()

View File

@ -1,4 +1,5 @@
using AyCode.Core.Helpers; using AyCode.Core.Helpers;
using AyCode.Core.Interfaces;
using AyCode.Core.Serializers.Jsons; using AyCode.Core.Serializers.Jsons;
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@ -227,14 +228,20 @@ public abstract class TypeMetadataBase
return requiresWrite ? WritableProperties : ReadableProperties; return requiresWrite ? WritableProperties : ReadableProperties;
} }
// Id properties are always at index 0 (sorted first) in UnfilteredPropertiesGlobalCache!
public const string IdPropertyName = nameof(IId<int>.Id);
private static List<PropertyInfo> GetUnfilteredProperties(Type type, bool requiresWrite) private static List<PropertyInfo> GetUnfilteredProperties(Type type, bool requiresWrite)
{ {
return UnfilteredPropertiesGlobalCache.GetOrAdd((type, requiresWrite), static key => return UnfilteredPropertiesGlobalCache.GetOrAdd((type, requiresWrite), static key =>
{ {
var (t, needsWrite) = key; var (t, needsWrite) = key;
// Check if type implements IId - if so, Id property will be first
var isIId = GetIdInfo(t).IsId;
// Collect properties from inheritance hierarchy (derived -> base order) // Collect properties from inheritance hierarchy (derived -> base order)
// Then sort alphabetically - ensures consistent ordering for serialization/deserialization // Sort: IId types have Id first, then alphabetical
var allProperties = new List<PropertyInfo>(); var allProperties = new List<PropertyInfo>();
for (var currentType = t; currentType != null && currentType != typeof(object); currentType = currentType.BaseType) for (var currentType = t; currentType != null && currentType != typeof(object); currentType = currentType.BaseType)
@ -246,7 +253,10 @@ public abstract class TypeMetadataBase
(!needsWrite || p.CanWrite) && (!needsWrite || p.CanWrite) &&
p.GetIndexParameters().Length == 0 && p.GetIndexParameters().Length == 0 &&
!IsUnsupportedPropertyType(p.PropertyType)) !IsUnsupportedPropertyType(p.PropertyType))
.OrderBy(static p => p.Name, StringComparer.Ordinal); // Alphabetical within level // IId: Id first (0), then alphabetical (1)
// Non-IId: all alphabetical
.OrderBy(p => isIId && p.Name == IdPropertyName ? 0 : 1)
.ThenBy(static p => p.Name, StringComparer.Ordinal);
allProperties.AddRange(levelProperties); allProperties.AddRange(levelProperties);
} }

View File

@ -57,6 +57,12 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
RefIdGetter = metadata.TypedIdGetter ?? HashCodeGetter; RefIdGetter = metadata.TypedIdGetter ?? HashCodeGetter;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool UseTypeReferenceHandling(ReferenceHandlingMode referenceHandling)
{
return referenceHandling != ReferenceHandlingMode.None && (Metadata.IsIId || referenceHandling == ReferenceHandlingMode.All);
}
/// <summary> /// <summary>
/// Resets tracking state for reuse between serializations. /// Resets tracking state for reuse between serializations.
/// Does not deallocate - just clears for reuse. /// Does not deallocate - just clears for reuse.