Refactor and unify object/collection deserialization logic

Refactored deserialization to use a unified PopulateObjectCore method, replacing PopulateObjectMerge and PopulateObjectNested to reduce duplication. Improved and centralized IId collection merge logic, with automatic detection and handling in merge mode. Reference ID registration is now consistent for reused objects and collections. Simplified SetPropertyToDefault to set collections to null. Updated documentation and clarified merge mode handling throughout. These changes improve maintainability, consistency, and robustness of deserialization.
This commit is contained in:
Loretta 2026-01-05 10:30:38 +01:00
parent fd3487c12b
commit 1f2f06ff8c
3 changed files with 102 additions and 179 deletions

View File

@ -333,7 +333,18 @@ public static partial class AcBinaryDeserializer
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
PopulateObjectNested(ref context, existingObj, propInfo.PropertyType, nextDepth);
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, existingObj);
}
}
PopulateObjectCore(ref context, existingObj, GetTypeMetadata(propInfo.PropertyType), nextDepth, skipDefaultWrite: false);
return;
}
}

View File

@ -12,11 +12,15 @@ public static partial class AcBinaryDeserializer
{
#region Populate Object Methods
/// <summary>
/// Populate object with automatic mode detection from context.
/// Uses IsMergeMode to determine merge behavior for IId collections.
/// </summary>
private static void PopulateObject(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
{
var metadata = GetTypeMetadata(targetType);
// Skip ref ID if present
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
@ -26,30 +30,32 @@ public static partial class AcBinaryDeserializer
}
}
PopulateObject(ref context, target, metadata, depth);
PopulateObjectCore(ref context, target, metadata, depth, skipDefaultWrite: false);
}
private static void PopulateObject(ref BinaryDeserializationContext context, object target, BinaryDeserializeTypeMetadata metadata, int depth, bool skipDefaultWrite = false)
/// <summary>
/// Core populate logic shared by all populate paths.
/// </summary>
/// <param name="context">Deserialization context</param>
/// <param name="target">Target object to populate</param>
/// <param name="metadata">Type metadata</param>
/// <param name="depth">Current depth</param>
/// <param name="skipDefaultWrite">
/// true = Deserialize mode - object just created, properties already at default, skip writing defaults
/// false = Populate mode - existing object, must overwrite properties with default values
/// </param>
private static void PopulateObjectCore(ref BinaryDeserializationContext context, object target, BinaryDeserializeTypeMetadata metadata, int depth, bool skipDefaultWrite)
{
// NEW FORMAT: Fixed property order with SKIP markers
// - No property count (iterate all properties in metadata order)
// - No property indices (use sequential PropertyIndex = i)
// - PropertySkip marker indicates default/null value
//
// skipDefaultWrite:
// true = Deserialize mode - object just created, properties already at default, skip writing
// false = Populate mode - existing object, must overwrite properties with default values
var properties = metadata.PropertiesArray;
var nextDepth = depth + 1;
var isMergeMode = context.IsMergeMode;
for (int i = 0; i < properties.Length; i++)
{
var propInfo = properties[i];
var peekCode = context.PeekByte();
// OPTIMIZATION: Skip marker - property has default/null value
// Skip marker - property has default/null value
if (peekCode == BinaryTypeCode.PropertySkip)
{
context.ReadByte(); // consume Skip marker
@ -60,11 +66,10 @@ public static partial class AcBinaryDeserializer
{
SetPropertyToDefault(target, propInfo);
}
continue;
}
// OPTIMIZATION: Skip null values early - no GetValue call needed!
// Null values - always set
if (peekCode == BinaryTypeCode.Null)
{
context.ReadByte(); // consume Null marker
@ -72,37 +77,57 @@ public static partial class AcBinaryDeserializer
continue;
}
// Handle nested complex objects - reuse existing if available
// ONLY call GetValue if we actually have an Object coming in
if (peekCode == BinaryTypeCode.Object && propInfo.IsComplexType)
{
var existingObj = propInfo.GetValue(target);
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
PopulateObjectNested(ref context, existingObj, propInfo.PropertyType, nextDepth);
continue;
}
}
// Handle collections - reuse existing collection and populate items
// ONLY call GetValue if we actually have an Array coming in
// Handle collections
if (peekCode == BinaryTypeCode.Array && propInfo.IsCollection)
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection is IList existingList)
{
context.ReadByte(); // consume Array marker
// Merge mode with IId collection: use merge logic
if (isMergeMode && propInfo.IsIIdCollection)
{
MergeIIdCollection(ref context, existingList, propInfo, nextDepth);
}
else
{
// Normal populate: replace collection contents
PopulateListOptimized(ref context, existingList, propInfo, nextDepth);
}
continue;
}
}
// Default: read value and set (for primitives, strings, null cases)
// Handle nested complex objects - reuse existing if available
if (peekCode == BinaryTypeCode.Object && propInfo.IsComplexType)
{
var existingObj = propInfo.GetValue(target);
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, existingObj);
}
}
// Recursively populate - existing object, don't skip defaults
PopulateObjectCore(ref context, existingObj, GetTypeMetadata(propInfo.PropertyType), nextDepth, skipDefaultWrite: false);
continue;
}
}
// Default: read value and set (for primitives, strings, new objects)
var positionBeforeRead = context.Position;
try
{
// OPTIMIZATION: Use typed setters for primitives to avoid boxing
// Use typed setters for primitives to avoid boxing
if (TryReadAndSetTypedValue(ref context, target, propInfo, peekCode))
continue;
@ -111,12 +136,9 @@ public static partial class AcBinaryDeserializer
}
catch (InvalidCastException ex)
{
// Only get type info when needed for error message (cold path)
var targetType = target.GetType();
var targetTypeName = targetType.Name;
throw new AcBinaryDeserializationException(
$"Type mismatch for property '{propInfo.Name}' (index {i}) on '{targetTypeName}'. " +
$"Type mismatch for property '{propInfo.Name}' (index {i}) on '{targetType.Name}'. " +
$"Expected type: '{propInfo.PropertyType.FullName}'. " +
$"PeekCode before read: {peekCode} (0x{peekCode:X2}). " +
$"Position before read: {positionBeforeRead}, current: {context.Position}. " +
@ -132,91 +154,12 @@ public static partial class AcBinaryDeserializer
}
/// <summary>
/// Populate nested object, reusing existing object and recursively updating properties.
/// Called from ReadObject for new instances - just calls PopulateObjectCore with skipDefaultWrite=true.
/// </summary>
private static void PopulateObjectNested(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void PopulateObject(ref BinaryDeserializationContext context, object target, BinaryDeserializeTypeMetadata metadata, int depth, bool skipDefaultWrite)
{
var metadata = GetTypeMetadata(targetType);
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, target);
}
}
PopulateObject(ref context, target, metadata, depth);
}
private static void PopulateObjectMerge(ref BinaryDeserializationContext context, object target, Type targetType, int depth, bool skipDefaultWrite = false)
{
var metadata = GetTypeMetadata(targetType);
// Skip ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, target);
}
}
// NEW FORMAT: Fixed property order with SKIP markers
var properties = metadata.PropertiesArray;
var nextDepth = depth + 1;
for (int i = 0; i < properties.Length; i++)
{
var propInfo = properties[i];
var peekCode = context.PeekByte();
// OPTIMIZATION: Skip marker - property has default/null value
if (peekCode == BinaryTypeCode.PropertySkip)
{
context.ReadByte(); // consume Skip marker
// Populate mode: overwrite with default (same as PopulateObject)
// Deserialize mode: skip write (new object already has defaults)
if (!skipDefaultWrite)
{
SetPropertyToDefault(target, propInfo);
}
continue;
}
// Handle IId collection merge
if (propInfo.IsIIdCollection && peekCode == BinaryTypeCode.Array)
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection is IList existingList)
{
context.ReadByte(); // consume Array marker
MergeIIdCollection(ref context, existingList, propInfo, depth);
continue;
}
}
// Handle nested object merge
if (peekCode == BinaryTypeCode.Object && !propInfo.PropertyType.IsPrimitive && !ReferenceEquals(propInfo.PropertyType, StringType))
{
var existingObj = propInfo.GetValue(target);
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
PopulateObjectMerge(ref context, existingObj, propInfo.PropertyType, nextDepth);
continue;
}
}
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value);
}
PopulateObjectCore(ref context, target, metadata, depth, skipDefaultWrite);
}
#endregion
@ -237,7 +180,7 @@ public static partial class AcBinaryDeserializer
var count = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
// ChainMode: Use cached IId info from element type metadata (no runtime reflection!)
// ChainMode: Use cached IId info from element type metadata
BinaryDeserializeTypeMetadata? elementMetadata = null;
if (context.IsChainMode && IsComplexType(elementType))
{
@ -257,7 +200,6 @@ public static partial class AcBinaryDeserializer
{
if (context.ChainTracker!.TryGetObject(elementType, id, out var existingObj))
{
// Use existing object instead of new one
targetList.Add(existingObj);
continue;
}
@ -273,31 +215,6 @@ public static partial class AcBinaryDeserializer
}
}
private static void PopulateListMerge(ref BinaryDeserializationContext context, IList targetList, Type listType, int depth)
{
var elementType = GetCollectionElementType(listType) ?? typeof(object);
// Use cached metadata instead of runtime reflection
if (!IsComplexType(elementType))
{
// Not a complex type, just replace
PopulateList(ref context, targetList, listType, depth);
return;
}
var elementMetadata = GetTypeMetadata(elementType);
if (!elementMetadata.IsIId || elementMetadata.IdGetter == null || elementMetadata.IdType == null)
{
// No IId, just replace
PopulateList(ref context, targetList, listType, depth);
return;
}
// IId merge logic using cached metadata
MergeIIdCollectionWithMetadata(ref context, targetList, elementType, elementMetadata, depth);
}
/// <summary>
/// Optimized list populate that reuses existing items when possible.
/// </summary>
@ -338,7 +255,7 @@ public static partial class AcBinaryDeserializer
}
// Populate mode: existing item, must overwrite with defaults
PopulateObject(ref context, existingItem, elementMetadata, nextDepth, skipDefaultWrite: false);
PopulateObjectCore(ref context, existingItem, elementMetadata, nextDepth, skipDefaultWrite: false);
continue;
}
}
@ -348,12 +265,10 @@ public static partial class AcBinaryDeserializer
if (i < existingCount)
{
// Replace existing item
existingList[i] = value;
}
else
{
// Add new item
existingList.Add(value);
}
}
@ -375,7 +290,8 @@ public static partial class AcBinaryDeserializer
#region Merge Methods
/// <summary>
/// IId collection merge using cached property info (for property-based merge).
/// IId collection merge using cached property info.
/// Matches items by Id, updates existing, adds new, optionally removes orphans.
/// </summary>
private static void MergeIIdCollection(ref BinaryDeserializationContext context, IList existingList, BinaryPropertySetterInfo propInfo, int depth)
{
@ -430,7 +346,7 @@ public static partial class AcBinaryDeserializer
var newItem = CreateInstance(elementType, elementMetadata);
if (newItem == null) continue;
// Read ref ID if present
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
@ -439,7 +355,7 @@ public static partial class AcBinaryDeserializer
}
// Deserialize mode: new item just created, skip writing defaults
PopulateObject(ref context, newItem, elementMetadata, nextDepth, skipDefaultWrite: true);
PopulateObjectCore(ref context, newItem, elementMetadata, nextDepth, skipDefaultWrite: true);
var itemId = idGetter(newItem);
if (itemId != null && !IsDefaultValue(itemId, idType))
@ -449,7 +365,7 @@ public static partial class AcBinaryDeserializer
if (existingById != null && existingById.TryGetValue(itemId, out var existingItem))
{
// Copy properties to existing item
// Copy properties to existing item (preserves reference)
CopyProperties(newItem, existingItem, elementMetadata);
continue;
}
@ -461,7 +377,6 @@ public static partial class AcBinaryDeserializer
// Remove orphaned items (items in destination but not in source)
if (context.RemoveOrphanedItems && existingById != null && sourceIds != null)
{
// Find items to remove (those not in sourceIds)
var itemsToRemove = new List<object>();
foreach (var kvp in existingById)
{
@ -471,7 +386,6 @@ public static partial class AcBinaryDeserializer
}
}
// Remove orphaned items
foreach (var item in itemsToRemove)
{
existingList.Remove(item);
@ -485,7 +399,7 @@ public static partial class AcBinaryDeserializer
}
/// <summary>
/// Optimized IId collection merge using cached metadata (no runtime reflection).
/// IId collection merge using type metadata (for top-level list merge).
/// </summary>
private static void MergeIIdCollectionWithMetadata(
ref BinaryDeserializationContext context,
@ -543,7 +457,7 @@ public static partial class AcBinaryDeserializer
var newItem = CreateInstance(elementType, elementMetadata);
if (newItem == null) continue;
// Read ref ID if present
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
@ -552,7 +466,7 @@ public static partial class AcBinaryDeserializer
}
// Deserialize mode: new item just created, skip writing defaults
PopulateObject(ref context, newItem, elementMetadata, nextDepth, skipDefaultWrite: true);
PopulateObjectCore(ref context, newItem, elementMetadata, nextDepth, skipDefaultWrite: true);
var itemId = idGetter(newItem);
if (itemId != null && !IsDefaultValue(itemId, idType))
@ -562,7 +476,7 @@ public static partial class AcBinaryDeserializer
if (existingById != null && existingById.TryGetValue(itemId, out var existingItem))
{
// Copy properties to existing item
// Copy properties to existing item (preserves reference)
CopyProperties(newItem, existingItem, elementMetadata);
continue;
}
@ -574,7 +488,6 @@ public static partial class AcBinaryDeserializer
// Remove orphaned items (items in destination but not in source)
if (context.RemoveOrphanedItems && existingById != null && sourceIds != null)
{
// Find items to remove (those not in sourceIds)
var itemsToRemove = new List<object>();
foreach (var kvp in existingById)
{
@ -584,7 +497,6 @@ public static partial class AcBinaryDeserializer
}
}
// Remove orphaned items
foreach (var item in itemsToRemove)
{
existingList.Remove(item);
@ -611,7 +523,6 @@ public static partial class AcBinaryDeserializer
/// <summary>
/// Sets a property to its default value using typed setters to avoid boxing.
/// For collections and complex objects, clears the collection instead of setting to null.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SetPropertyToDefault(object target, BinaryPropertySetterInfo propInfo)
@ -647,18 +558,6 @@ public static partial class AcBinaryDeserializer
return;
case PropertyAccessorType.Object:
default:
// For collections: clear instead of setting to null
//if (propInfo.IsCollection)
//{
// var collection = propInfo.GetValue(target);
// if (collection is IList list)
// {
// list.Clear();
// }
// // If not IList or null, leave it as-is (readonly collection or null already)
// return;
//}
// Reference types and nullable value types: set to null
propInfo.SetValue(target, null);
return;

View File

@ -332,12 +332,25 @@ public static partial class AcBinaryDeserializer
if (typeCode == BinaryTypeCode.Object)
{
context.ReadByte();
PopulateObjectMerge(ref context, target, targetType, 0);
// Uses unified PopulateObject which checks IsMergeMode internally
PopulateObject(ref context, target, targetType, 0);
}
else if (typeCode == BinaryTypeCode.Array && target is IList targetList)
{
context.ReadByte();
PopulateListMerge(ref context, targetList, targetType, 0);
// For top-level list merge, check if it's an IId collection
var elementType = GetCollectionElementType(targetType);
if (elementType != null && IsComplexType(elementType))
{
var elementMetadata = GetTypeMetadata(elementType);
if (elementMetadata.IsIId && elementMetadata.IdGetter != null)
{
MergeIIdCollectionWithMetadata(ref context, targetList, elementType, elementMetadata, 0);
return;
}
}
// Non-IId collection, just populate
PopulateList(ref context, targetList, targetType, 0);
}
else
{