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) if (existingObj != null)
{ {
context.ReadByte(); // consume Object marker 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; return;
} }
} }

View File

@ -12,11 +12,15 @@ public static partial class AcBinaryDeserializer
{ {
#region Populate Object Methods #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) private static void PopulateObject(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
{ {
var metadata = GetTypeMetadata(targetType); var metadata = GetTypeMetadata(targetType);
// Skip ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.HasReferenceHandling)
{ {
var refId = context.ReadVarInt(); 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 properties = metadata.PropertiesArray;
var nextDepth = depth + 1; var nextDepth = depth + 1;
var isMergeMode = context.IsMergeMode;
for (int i = 0; i < properties.Length; i++) for (int i = 0; i < properties.Length; i++)
{ {
var propInfo = properties[i]; var propInfo = properties[i];
var peekCode = context.PeekByte(); var peekCode = context.PeekByte();
// OPTIMIZATION: Skip marker - property has default/null value // Skip marker - property has default/null value
if (peekCode == BinaryTypeCode.PropertySkip) if (peekCode == BinaryTypeCode.PropertySkip)
{ {
context.ReadByte(); // consume Skip marker context.ReadByte(); // consume Skip marker
@ -60,11 +66,10 @@ public static partial class AcBinaryDeserializer
{ {
SetPropertyToDefault(target, propInfo); SetPropertyToDefault(target, propInfo);
} }
continue; continue;
} }
// OPTIMIZATION: Skip null values early - no GetValue call needed! // Null values - always set
if (peekCode == BinaryTypeCode.Null) if (peekCode == BinaryTypeCode.Null)
{ {
context.ReadByte(); // consume Null marker context.ReadByte(); // consume Null marker
@ -72,37 +77,57 @@ public static partial class AcBinaryDeserializer
continue; continue;
} }
// Handle nested complex objects - reuse existing if available // Handle collections
// 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
if (peekCode == BinaryTypeCode.Array && propInfo.IsCollection) if (peekCode == BinaryTypeCode.Array && propInfo.IsCollection)
{ {
var existingCollection = propInfo.GetValue(target); var existingCollection = propInfo.GetValue(target);
if (existingCollection is IList existingList) if (existingCollection is IList existingList)
{ {
context.ReadByte(); // consume Array marker context.ReadByte(); // consume Array marker
PopulateListOptimized(ref context, existingList, propInfo, nextDepth);
// 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; 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; var positionBeforeRead = context.Position;
try 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)) if (TryReadAndSetTypedValue(ref context, target, propInfo, peekCode))
continue; continue;
@ -111,12 +136,9 @@ public static partial class AcBinaryDeserializer
} }
catch (InvalidCastException ex) catch (InvalidCastException ex)
{ {
// Only get type info when needed for error message (cold path)
var targetType = target.GetType(); var targetType = target.GetType();
var targetTypeName = targetType.Name;
throw new AcBinaryDeserializationException( 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}'. " + $"Expected type: '{propInfo.PropertyType.FullName}'. " +
$"PeekCode before read: {peekCode} (0x{peekCode:X2}). " + $"PeekCode before read: {peekCode} (0x{peekCode:X2}). " +
$"Position before read: {positionBeforeRead}, current: {context.Position}. " + $"Position before read: {positionBeforeRead}, current: {context.Position}. " +
@ -132,91 +154,12 @@ public static partial class AcBinaryDeserializer
} }
/// <summary> /// <summary>
/// Populate nested object, reusing existing object and recursively updating properties. /// Called from ReadObject for new instances - just calls PopulateObjectCore with skipDefaultWrite=true.
/// </summary> /// </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); PopulateObjectCore(ref context, target, metadata, depth, skipDefaultWrite);
// 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);
}
} }
#endregion #endregion
@ -237,7 +180,7 @@ public static partial class AcBinaryDeserializer
var count = (int)context.ReadVarUInt(); var count = (int)context.ReadVarUInt();
var nextDepth = depth + 1; 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; BinaryDeserializeTypeMetadata? elementMetadata = null;
if (context.IsChainMode && IsComplexType(elementType)) if (context.IsChainMode && IsComplexType(elementType))
{ {
@ -257,7 +200,6 @@ public static partial class AcBinaryDeserializer
{ {
if (context.ChainTracker!.TryGetObject(elementType, id, out var existingObj)) if (context.ChainTracker!.TryGetObject(elementType, id, out var existingObj))
{ {
// Use existing object instead of new one
targetList.Add(existingObj); targetList.Add(existingObj);
continue; 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> /// <summary>
/// Optimized list populate that reuses existing items when possible. /// Optimized list populate that reuses existing items when possible.
/// </summary> /// </summary>
@ -338,7 +255,7 @@ public static partial class AcBinaryDeserializer
} }
// Populate mode: existing item, must overwrite with defaults // 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; continue;
} }
} }
@ -348,12 +265,10 @@ public static partial class AcBinaryDeserializer
if (i < existingCount) if (i < existingCount)
{ {
// Replace existing item
existingList[i] = value; existingList[i] = value;
} }
else else
{ {
// Add new item
existingList.Add(value); existingList.Add(value);
} }
} }
@ -375,7 +290,8 @@ public static partial class AcBinaryDeserializer
#region Merge Methods #region Merge Methods
/// <summary> /// <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> /// </summary>
private static void MergeIIdCollection(ref BinaryDeserializationContext context, IList existingList, BinaryPropertySetterInfo propInfo, int depth) 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); var newItem = CreateInstance(elementType, elementMetadata);
if (newItem == null) continue; if (newItem == null) continue;
// Read ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.HasReferenceHandling)
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
@ -439,7 +355,7 @@ public static partial class AcBinaryDeserializer
} }
// Deserialize mode: new item just created, skip writing defaults // 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); var itemId = idGetter(newItem);
if (itemId != null && !IsDefaultValue(itemId, idType)) if (itemId != null && !IsDefaultValue(itemId, idType))
@ -449,7 +365,7 @@ public static partial class AcBinaryDeserializer
if (existingById != null && existingById.TryGetValue(itemId, out var existingItem)) 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); CopyProperties(newItem, existingItem, elementMetadata);
continue; continue;
} }
@ -461,7 +377,6 @@ public static partial class AcBinaryDeserializer
// Remove orphaned items (items in destination but not in source) // Remove orphaned items (items in destination but not in source)
if (context.RemoveOrphanedItems && existingById != null && sourceIds != null) if (context.RemoveOrphanedItems && existingById != null && sourceIds != null)
{ {
// Find items to remove (those not in sourceIds)
var itemsToRemove = new List<object>(); var itemsToRemove = new List<object>();
foreach (var kvp in existingById) foreach (var kvp in existingById)
{ {
@ -471,7 +386,6 @@ public static partial class AcBinaryDeserializer
} }
} }
// Remove orphaned items
foreach (var item in itemsToRemove) foreach (var item in itemsToRemove)
{ {
existingList.Remove(item); existingList.Remove(item);
@ -485,7 +399,7 @@ public static partial class AcBinaryDeserializer
} }
/// <summary> /// <summary>
/// Optimized IId collection merge using cached metadata (no runtime reflection). /// IId collection merge using type metadata (for top-level list merge).
/// </summary> /// </summary>
private static void MergeIIdCollectionWithMetadata( private static void MergeIIdCollectionWithMetadata(
ref BinaryDeserializationContext context, ref BinaryDeserializationContext context,
@ -543,7 +457,7 @@ public static partial class AcBinaryDeserializer
var newItem = CreateInstance(elementType, elementMetadata); var newItem = CreateInstance(elementType, elementMetadata);
if (newItem == null) continue; if (newItem == null) continue;
// Read ref ID if present // Handle ref ID if present
if (context.HasReferenceHandling) if (context.HasReferenceHandling)
{ {
var refId = context.ReadVarInt(); var refId = context.ReadVarInt();
@ -552,7 +466,7 @@ public static partial class AcBinaryDeserializer
} }
// Deserialize mode: new item just created, skip writing defaults // 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); var itemId = idGetter(newItem);
if (itemId != null && !IsDefaultValue(itemId, idType)) if (itemId != null && !IsDefaultValue(itemId, idType))
@ -562,7 +476,7 @@ public static partial class AcBinaryDeserializer
if (existingById != null && existingById.TryGetValue(itemId, out var existingItem)) 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); CopyProperties(newItem, existingItem, elementMetadata);
continue; continue;
} }
@ -574,7 +488,6 @@ public static partial class AcBinaryDeserializer
// Remove orphaned items (items in destination but not in source) // Remove orphaned items (items in destination but not in source)
if (context.RemoveOrphanedItems && existingById != null && sourceIds != null) if (context.RemoveOrphanedItems && existingById != null && sourceIds != null)
{ {
// Find items to remove (those not in sourceIds)
var itemsToRemove = new List<object>(); var itemsToRemove = new List<object>();
foreach (var kvp in existingById) foreach (var kvp in existingById)
{ {
@ -584,7 +497,6 @@ public static partial class AcBinaryDeserializer
} }
} }
// Remove orphaned items
foreach (var item in itemsToRemove) foreach (var item in itemsToRemove)
{ {
existingList.Remove(item); existingList.Remove(item);
@ -611,7 +523,6 @@ public static partial class AcBinaryDeserializer
/// <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.
/// For collections and complex objects, clears the collection instead of setting to null.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SetPropertyToDefault(object target, BinaryPropertySetterInfo propInfo) private static void SetPropertyToDefault(object target, BinaryPropertySetterInfo propInfo)
@ -647,18 +558,6 @@ public static partial class AcBinaryDeserializer
return; return;
case PropertyAccessorType.Object: case PropertyAccessorType.Object:
default: 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 // Reference types and nullable value types: set to null
propInfo.SetValue(target, null); propInfo.SetValue(target, null);
return; return;

View File

@ -332,12 +332,25 @@ public static partial class AcBinaryDeserializer
if (typeCode == BinaryTypeCode.Object) if (typeCode == BinaryTypeCode.Object)
{ {
context.ReadByte(); 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) else if (typeCode == BinaryTypeCode.Array && target is IList targetList)
{ {
context.ReadByte(); 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 else
{ {