Add cross-type deserialization and property mapping support
- Enable deserialization and population from TSource to TDest with automatic or custom property mapping (by name or via PropertyMapper). - Add PropertyMapperDelegate and options for custom mapping logic. - Implement PropertyMappingCache and BinaryIndexMappingCache for efficient, thread-safe mapping reuse. - Ensure stable, inheritance-aware property ordering in TypeMetadataBase for reliable cross-type mapping. - Add CrossTypeDeserializerBase utility for shared cross-type logic. - Add AcBinaryDeserializer.CrossType with new Deserialize<TSource, TDest> and Populate<TSource, TDest> overloads. - Update settings.local.json to allow additional Bash commands for development. - Improve documentation and add missing using directives.
This commit is contained in:
parent
28a818b1ae
commit
f388afcede
|
|
@ -2,7 +2,9 @@
|
|||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dotnet test:*)"
|
||||
"Bash(dotnet test:*)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(dir /B /O-D *.log)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Linq.Expressions;
|
|||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AyCode.Core.Serializers.Expressions;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using LExpression = System.Linq.Expressions.Expression;
|
||||
using LExpressionType = System.Linq.Expressions.ExpressionType;
|
||||
|
||||
|
|
@ -775,6 +776,187 @@ public static class AcSerializerCommon
|
|||
|
||||
#endregion
|
||||
|
||||
#region Property Mapping for Cross-Type Operations
|
||||
|
||||
/// <summary>
|
||||
/// Maps property indices or names from source type to destination type.
|
||||
/// Supports both name-based matching and custom PropertyMapper callbacks.
|
||||
/// Used by cross-type deserialization (Deserialize<TSource, TDest>).
|
||||
/// Thread-safe and cached for performance.
|
||||
/// </summary>
|
||||
public sealed class PropertyMappingCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Type Source, Type Dest), PropertyMappingInfo> _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or builds property mapping between two types.
|
||||
/// Result is cached for subsequent calls.
|
||||
/// </summary>
|
||||
public PropertyMappingInfo GetOrBuild(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func<Type, object> getMetadata)
|
||||
{
|
||||
var key = (sourceType, destType);
|
||||
return _cache.GetOrAdd(key, _ => BuildMapping(sourceType, destType, customMapper, getMetadata));
|
||||
}
|
||||
|
||||
private static PropertyMappingInfo BuildMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func<Type, object> getMetadata)
|
||||
{
|
||||
var sourceProps = sourceType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead)
|
||||
.ToArray();
|
||||
|
||||
var destProps = destType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanWrite)
|
||||
.ToDictionary(p => p.Name, StringComparer.Ordinal);
|
||||
|
||||
var mappings = new List<(PropertyInfo Source, PropertyInfo Dest)>();
|
||||
|
||||
foreach (var sourceProp in sourceProps)
|
||||
{
|
||||
PropertyInfo? destProp = null;
|
||||
|
||||
// Use custom mapper if provided
|
||||
if (customMapper != null)
|
||||
{
|
||||
destProp = customMapper(sourceProp, destType);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default: match by name
|
||||
destProps.TryGetValue(sourceProp.Name, out destProp);
|
||||
}
|
||||
|
||||
if (destProp != null && AreTypesCompatible(sourceProp.PropertyType, destProp.PropertyType))
|
||||
{
|
||||
mappings.Add((sourceProp, destProp));
|
||||
}
|
||||
}
|
||||
|
||||
return new PropertyMappingInfo(mappings.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two property types are compatible for mapping.
|
||||
/// Handles exact match, inheritance, nullable unwrapping, and numeric conversions.
|
||||
/// </summary>
|
||||
private static bool AreTypesCompatible(Type sourceType, Type destType)
|
||||
{
|
||||
// Exact match
|
||||
if (sourceType == destType) return true;
|
||||
|
||||
// Assignable (inheritance, interfaces)
|
||||
if (destType.IsAssignableFrom(sourceType)) return true;
|
||||
|
||||
// Unwrap nullable types
|
||||
var sourceUnderlying = Nullable.GetUnderlyingType(sourceType) ?? sourceType;
|
||||
var destUnderlying = Nullable.GetUnderlyingType(destType) ?? destType;
|
||||
|
||||
if (sourceUnderlying == destUnderlying) return true;
|
||||
|
||||
// Numeric conversions (int -> long, float -> double, etc.)
|
||||
if (IsNumericType(sourceUnderlying) && IsNumericType(destUnderlying))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static bool IsNumericType(Type type)
|
||||
{
|
||||
return type == typeof(byte) || type == typeof(sbyte) ||
|
||||
type == typeof(short) || type == typeof(ushort) ||
|
||||
type == typeof(int) || type == typeof(uint) ||
|
||||
type == typeof(long) || type == typeof(ulong) ||
|
||||
type == typeof(float) || type == typeof(double) ||
|
||||
type == typeof(decimal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains property mapping information for a source->destination type pair.
|
||||
/// Immutable and thread-safe.
|
||||
/// </summary>
|
||||
public sealed class PropertyMappingInfo
|
||||
{
|
||||
public PropertyMappingInfo(IReadOnlyList<(PropertyInfo Source, PropertyInfo Dest)> mappings)
|
||||
{
|
||||
Mappings = mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of source->destination property pairs.
|
||||
/// </summary>
|
||||
public IReadOnlyList<(PropertyInfo Source, PropertyInfo Dest)> Mappings { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary-specific index-to-index mapping for cross-type deserialization.
|
||||
/// Maps source PropertyIndex to destination PropertyIndex.
|
||||
/// Thread-safe and cached.
|
||||
/// </summary>
|
||||
public sealed class BinaryIndexMappingCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Type Source, Type Dest), int[]> _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or builds index mapping from source type to destination type.
|
||||
/// Returns array where index is source PropertyIndex, value is destination PropertyIndex (-1 if no mapping).
|
||||
/// </summary>
|
||||
public int[] GetOrBuild(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func<Type, PropertyInfo[]> getOrderedProperties)
|
||||
{
|
||||
var key = (sourceType, destType);
|
||||
return _cache.GetOrAdd(key, _ => BuildIndexMapping(sourceType, destType, customMapper, getOrderedProperties));
|
||||
}
|
||||
|
||||
private static int[] BuildIndexMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func<Type, PropertyInfo[]> getOrderedProperties)
|
||||
{
|
||||
var sourceProps = getOrderedProperties(sourceType);
|
||||
var destProps = getOrderedProperties(destType);
|
||||
|
||||
// Build lookup: property name -> destination index
|
||||
var destIndexByName = new Dictionary<string, int>(destProps.Length, StringComparer.Ordinal);
|
||||
for (int i = 0; i < destProps.Length; i++)
|
||||
{
|
||||
destIndexByName[destProps[i].Name] = i;
|
||||
}
|
||||
|
||||
// Map source index -> destination index
|
||||
var mapping = new int[sourceProps.Length];
|
||||
|
||||
for (int srcIdx = 0; srcIdx < sourceProps.Length; srcIdx++)
|
||||
{
|
||||
var sourceProp = sourceProps[srcIdx];
|
||||
PropertyInfo? destProp = null;
|
||||
|
||||
// Use custom mapper if provided
|
||||
if (customMapper != null)
|
||||
{
|
||||
destProp = customMapper(sourceProp, destType);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default: match by name
|
||||
if (destIndexByName.TryGetValue(sourceProp.Name, out var destIdx))
|
||||
{
|
||||
destProp = destProps[destIdx];
|
||||
}
|
||||
}
|
||||
|
||||
if (destProp != null && destIndexByName.TryGetValue(destProp.Name, out var mappedIdx))
|
||||
{
|
||||
mapping[srcIdx] = mappedIdx;
|
||||
}
|
||||
else
|
||||
{
|
||||
mapping[srcIdx] = -1; // Skip this property
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deserialization Reference Tracking
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,384 @@
|
|||
using System.Collections;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using static AyCode.Core.Serializers.AcSerializerCommon;
|
||||
|
||||
namespace AyCode.Core.Serializers.Binaries;
|
||||
|
||||
public static partial class AcBinaryDeserializer
|
||||
{
|
||||
private static readonly BinaryIndexMappingCache IndexMappingCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Helper to get index mapping for cross-type operations.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static int[] GetIndexMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper)
|
||||
=> IndexMappingCache.GetOrBuild(sourceType, destType, customMapper, CrossTypeDeserializerBase.GetOrderedProperties);
|
||||
|
||||
#region Cross-Type Deserialization
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes binary data from TSource type to TDest type.
|
||||
/// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
|
||||
/// Use when binary was serialized as TSource but you want to deserialize into TDest.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSource">The source type that was serialized</typeparam>
|
||||
/// <typeparam name="TDest">The destination type to deserialize into</typeparam>
|
||||
/// <param name="data">Binary data to deserialize</param>
|
||||
/// <returns>Deserialized instance of TDest</returns>
|
||||
public static TDest? Deserialize<TSource, TDest>(ReadOnlySpan<byte> data)
|
||||
=> Deserialize<TSource, TDest>(data, AcBinarySerializerOptions.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes binary data from TSource type to TDest type with options.
|
||||
/// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSource">The source type that was serialized</typeparam>
|
||||
/// <typeparam name="TDest">The destination type to deserialize into</typeparam>
|
||||
/// <param name="data">Binary data to deserialize</param>
|
||||
/// <param name="options">Deserialization options (use PropertyMapper for custom mapping)</param>
|
||||
/// <returns">Deserialized instance of TDest</returns>
|
||||
public static TDest? Deserialize<TSource, TDest>(ReadOnlySpan<byte> data, AcBinarySerializerOptions options)
|
||||
{
|
||||
// Early exit checks
|
||||
if (CrossTypeDeserializerBase.IsEmptyData(data, BinaryTypeCode.Null))
|
||||
return default;
|
||||
|
||||
var sourceType = typeof(TSource);
|
||||
var destType = typeof(TDest);
|
||||
|
||||
// Fast path: same type
|
||||
if (CrossTypeDeserializerBase.IsSameType(sourceType, destType))
|
||||
return Deserialize<TDest>(data, options);
|
||||
|
||||
// Cross-type path: use index mapping
|
||||
var indexMapping = GetIndexMapping(sourceType, destType, options.PropertyMapper);
|
||||
var context = new BinaryDeserializationContext(data, options);
|
||||
|
||||
try
|
||||
{
|
||||
context.ReadHeader();
|
||||
var result = ReadValueWithMapping(ref context, destType, indexMapping, 0);
|
||||
return (TDest?)result;
|
||||
}
|
||||
catch (AcBinaryDeserializationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new AcBinaryDeserializationException(
|
||||
$"Failed to deserialize binary data from '{sourceType.Name}' to '{destType.Name}': {ex.Message}",
|
||||
context.Position, destType, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes binary data from TSource type to TDest type (byte array overload).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static TDest? Deserialize<TSource, TDest>(byte[] data)
|
||||
=> Deserialize<TSource, TDest>(data.AsSpan());
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes binary data from TSource type to TDest type with options (byte array overload).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static TDest? Deserialize<TSource, TDest>(byte[] data, AcBinarySerializerOptions options)
|
||||
=> Deserialize<TSource, TDest>(data.AsSpan(), options);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cross-Type Populate
|
||||
|
||||
/// <summary>
|
||||
/// Populates existing TDest instance with data serialized as TSource.
|
||||
/// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSource">The source type that was serialized</typeparam>
|
||||
/// <typeparam name="TDest">The destination type to populate</typeparam>
|
||||
/// <param name="data">Binary data to deserialize</param>
|
||||
/// <param name="target">Existing instance to populate</param>
|
||||
public static void Populate<TSource, TDest>(ReadOnlySpan<byte> data, TDest target)
|
||||
where TDest : class
|
||||
=> Populate<TSource, TDest>(data, target, AcBinarySerializerOptions.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Populates existing TDest instance with data serialized as TSource with options.
|
||||
/// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSource">The source type that was serialized</typeparam>
|
||||
/// <typeparam name="TDest">The destination type to populate</typeparam>
|
||||
/// <param name="data">Binary data to deserialize</param>
|
||||
/// <param name="target">Existing instance to populate</param>
|
||||
/// <param name="options">Deserialization options (use PropertyMapper for custom mapping)</param>
|
||||
public static void Populate<TSource, TDest>(ReadOnlySpan<byte> data, TDest target, AcBinarySerializerOptions options)
|
||||
where TDest : class
|
||||
{
|
||||
// Validation
|
||||
CrossTypeDeserializerBase.ValidatePopulateTarget(target);
|
||||
|
||||
// Early exit checks
|
||||
if (CrossTypeDeserializerBase.IsEmptyData(data, BinaryTypeCode.Null))
|
||||
return;
|
||||
|
||||
var sourceType = typeof(TSource);
|
||||
var destType = typeof(TDest);
|
||||
|
||||
// Fast path: same type
|
||||
if (CrossTypeDeserializerBase.IsSameType(sourceType, destType))
|
||||
{
|
||||
Populate(data, target, options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cross-type path: use index mapping
|
||||
var indexMapping = GetIndexMapping(sourceType, destType, options.PropertyMapper);
|
||||
var context = new BinaryDeserializationContext(data, options);
|
||||
|
||||
try
|
||||
{
|
||||
context.ReadHeader();
|
||||
var typeCode = context.PeekByte();
|
||||
|
||||
if (typeCode == BinaryTypeCode.Null)
|
||||
{
|
||||
context.ReadByte(); // consume null
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeCode == BinaryTypeCode.Object)
|
||||
{
|
||||
context.ReadByte(); // consume Object marker
|
||||
PopulateObjectWithMapping(ref context, target, destType, indexMapping, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new AcBinaryDeserializationException(
|
||||
$"Expected Object type code but got {typeCode}",
|
||||
context.Position, destType);
|
||||
}
|
||||
}
|
||||
catch (AcBinaryDeserializationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new AcBinaryDeserializationException(
|
||||
$"Failed to populate target of type '{destType.Name}' with data from '{sourceType.Name}': {ex.Message}",
|
||||
context.Position, destType, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates existing TDest instance with data serialized as TSource (byte array overload).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Populate<TSource, TDest>(byte[] data, TDest target)
|
||||
where TDest : class
|
||||
=> Populate<TSource, TDest>(data.AsSpan(), target);
|
||||
|
||||
/// <summary>
|
||||
/// Populates existing TDest instance with data serialized as TSource with options (byte array overload).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Populate<TSource, TDest>(byte[] data, TDest target, AcBinarySerializerOptions options)
|
||||
where TDest : class
|
||||
=> Populate<TSource, TDest>(data.AsSpan(), target, options);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Reads a value with index mapping applied.
|
||||
/// Maps source PropertyIndex to destination PropertyIndex using the provided mapping.
|
||||
/// </summary>
|
||||
private static object? ReadValueWithMapping(ref BinaryDeserializationContext context, Type destType, int[] indexMapping, int depth)
|
||||
{
|
||||
var typeCode = context.ReadByte();
|
||||
|
||||
return typeCode switch
|
||||
{
|
||||
BinaryTypeCode.Null => null,
|
||||
BinaryTypeCode.Object => ReadObjectWithMapping(ref context, destType, indexMapping, depth),
|
||||
_ => ReadValue(ref context, destType, depth) // Primitives, arrays, etc. use normal path
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an object using index mapping for property resolution.
|
||||
/// </summary>
|
||||
private static object? ReadObjectWithMapping(ref BinaryDeserializationContext context, Type destType, int[] indexMapping, int depth)
|
||||
{
|
||||
var metadata = GetTypeMetadata(destType);
|
||||
|
||||
// Handle reference ID if present
|
||||
if (context.HasReferenceHandling)
|
||||
{
|
||||
var refId = context.ReadVarInt();
|
||||
if (refId < 0)
|
||||
{
|
||||
// Object reference - get existing object
|
||||
return context.GetReferencedObject(-refId);
|
||||
}
|
||||
|
||||
// New object with ID - will be registered below
|
||||
var instance = CreateInstance(destType, metadata);
|
||||
if (instance != null && refId > 0)
|
||||
{
|
||||
context.RegisterObject(refId, instance);
|
||||
}
|
||||
|
||||
PopulateObjectWithMapping(ref context, instance!, destType, indexMapping, depth);
|
||||
return instance;
|
||||
}
|
||||
|
||||
// No reference handling
|
||||
var obj = CreateInstance(destType, metadata);
|
||||
if (obj != null)
|
||||
{
|
||||
PopulateObjectWithMapping(ref context, obj, destType, indexMapping, depth);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates an object using index mapping.
|
||||
/// Source property indices are remapped to destination indices.
|
||||
/// </summary>
|
||||
private static void PopulateObjectWithMapping(
|
||||
ref BinaryDeserializationContext context,
|
||||
object target,
|
||||
Type destType,
|
||||
int[] indexMapping,
|
||||
int depth)
|
||||
{
|
||||
var metadata = GetTypeMetadata(destType);
|
||||
var propertyCount = (int)context.ReadVarUInt();
|
||||
var nextDepth = depth + 1;
|
||||
|
||||
for (int i = 0; i < propertyCount; i++)
|
||||
{
|
||||
var sourcePropIndex = (int)context.ReadVarUInt();
|
||||
|
||||
// Map source index to destination index
|
||||
var destPropIndex = MapPropertyIndex(sourcePropIndex, indexMapping);
|
||||
|
||||
if (destPropIndex == -1)
|
||||
{
|
||||
// No mapping - skip this property
|
||||
SkipValue(ref context);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get destination property by mapped index
|
||||
var propInfo = metadata.GetPropertyByIndex(destPropIndex);
|
||||
if (propInfo == null)
|
||||
{
|
||||
// Destination property not found - skip
|
||||
SkipValue(ref context);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse common populate logic
|
||||
PopulatePropertyValue(ref context, target, propInfo, nextDepth, sourcePropIndex, destPropIndex, i, propertyCount, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps source property index to destination index using mapping array.
|
||||
/// Returns -1 if no mapping exists.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static int MapPropertyIndex(int sourcePropIndex, int[] indexMapping)
|
||||
{
|
||||
if ((uint)sourcePropIndex < (uint)indexMapping.Length)
|
||||
return indexMapping[sourcePropIndex];
|
||||
|
||||
return -1; // Out of range - skip
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common logic for populating a single property value.
|
||||
/// Shared between normal populate and cross-type populate.
|
||||
/// </summary>
|
||||
private static void PopulatePropertyValue(
|
||||
ref BinaryDeserializationContext context,
|
||||
object target,
|
||||
BinaryPropertySetterInfo propInfo,
|
||||
int nextDepth,
|
||||
int sourcePropIndex,
|
||||
int destPropIndex,
|
||||
int currentIndex,
|
||||
int totalCount,
|
||||
int depth)
|
||||
{
|
||||
var peekCode = context.PeekByte();
|
||||
|
||||
// Optimization: Skip null values early
|
||||
if (peekCode == BinaryTypeCode.Null)
|
||||
{
|
||||
context.ReadByte(); // consume Null marker
|
||||
propInfo.SetValue(target, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
PopulateObjectNested(ref context, existingObj, propInfo.PropertyType, nextDepth);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle collections - reuse existing collection and populate items
|
||||
if (peekCode == BinaryTypeCode.Array && propInfo.IsCollection)
|
||||
{
|
||||
var existingCollection = propInfo.GetValue(target);
|
||||
if (existingCollection is IList existingList)
|
||||
{
|
||||
context.ReadByte(); // consume Array marker
|
||||
PopulateListOptimized(ref context, existingList, propInfo, nextDepth);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: read value and set
|
||||
var positionBeforeRead = context.Position;
|
||||
try
|
||||
{
|
||||
if (TryReadAndSetTypedValue(ref context, target, propInfo, peekCode))
|
||||
return;
|
||||
|
||||
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
|
||||
propInfo.SetValue(target, value);
|
||||
}
|
||||
catch (InvalidCastException ex)
|
||||
{
|
||||
var targetTypeName = target.GetType().Name;
|
||||
var indexInfo = sourcePropIndex == destPropIndex
|
||||
? $"propIndex={sourcePropIndex}"
|
||||
: $"sourcePropIndex={sourcePropIndex}, destPropIndex={destPropIndex}";
|
||||
|
||||
throw new AcBinaryDeserializationException(
|
||||
$"Type mismatch for property '{propInfo.Name}' (index {currentIndex}/{totalCount}, {indexInfo}) on '{targetTypeName}'. " +
|
||||
$"Expected type: '{propInfo.PropertyType.FullName}'. " +
|
||||
$"PeekCode before read: {peekCode} (0x{peekCode:X2}). " +
|
||||
$"Position before read: {positionBeforeRead}, current: {context.Position}. " +
|
||||
$"Depth: {depth}. " +
|
||||
$"Error: {ex.Message}",
|
||||
positionBeforeRead,
|
||||
propInfo.PropertyType,
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
using System.Reflection;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using static AyCode.Core.Serializers.TypeMetadataBase;
|
||||
|
||||
namespace AyCode.Core.Serializers;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class providing common cross-type deserialization functionality.
|
||||
/// Shared by both JSON and Binary deserializers.
|
||||
/// </summary>
|
||||
public static class CrossTypeDeserializerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets ordered properties for a type using stable PropertyIndex ordering.
|
||||
/// Wrapper around TypeMetadataBase.GetSerializableProperties for consistency.
|
||||
/// </summary>
|
||||
public static PropertyInfo[] GetOrderedProperties(Type type)
|
||||
=> GetSerializableProperties(type, requiresRead: true, requiresWrite: true);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two types are the same (fast path detection).
|
||||
/// </summary>
|
||||
public static bool IsSameType(Type sourceType, Type destType)
|
||||
=> sourceType == destType;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that target object for populate is not null.
|
||||
/// </summary>
|
||||
public static void ValidatePopulateTarget<TDest>(TDest target) where TDest : class
|
||||
{
|
||||
if (target == null)
|
||||
throw new ArgumentNullException(nameof(target));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if data is empty or null marker.
|
||||
/// </summary>
|
||||
public static bool IsEmptyData(ReadOnlySpan<byte> data, byte nullMarker)
|
||||
{
|
||||
if (data.Length == 0) return true;
|
||||
if (data.Length == 1 && data[0] == nullMarker) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if JSON data is effectively empty.
|
||||
/// </summary>
|
||||
public static bool IsEmptyJsonData(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length == 0) return true;
|
||||
|
||||
// Check for "null" (4 bytes)
|
||||
if (data.Length == 4)
|
||||
{
|
||||
return data[0] == (byte)'n' &&
|
||||
data[1] == (byte)'u' &&
|
||||
data[2] == (byte)'l' &&
|
||||
data[3] == (byte)'l';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Reflection;
|
||||
|
||||
namespace AyCode.Core.Serializers.Jsons;
|
||||
|
||||
public enum AcSerializerType : byte
|
||||
|
|
@ -6,9 +8,19 @@ public enum AcSerializerType : byte
|
|||
Binary = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delegate for custom property mapping during cross-type deserialization/population.
|
||||
/// Enables mapping between different class hierarchies or renamed properties.
|
||||
/// </summary>
|
||||
/// <param name="sourceProperty">Property from the source type being deserialized</param>
|
||||
/// <param name="destinationType">Target type being populated</param>
|
||||
/// <returns>Mapped destination property, or null to skip this property</returns>
|
||||
public delegate PropertyInfo? PropertyMapperDelegate(PropertyInfo sourceProperty, Type destinationType);
|
||||
|
||||
public abstract class AcSerializerOptions
|
||||
{
|
||||
public abstract AcSerializerType SerializerType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use $id/$ref reference handling for circular references.
|
||||
/// Default: true
|
||||
|
|
@ -23,6 +35,22 @@ public abstract class AcSerializerOptions
|
|||
/// Default: byte.MaxValue
|
||||
/// </summary>
|
||||
public byte MaxDepth { get; init; } = byte.MaxValue;
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback for custom property mapping during cross-type operations.
|
||||
/// Used when deserializing/populating with Deserialize<TSource, TDest> or Populate<TSource, TDest>.
|
||||
///
|
||||
/// Use cases:
|
||||
/// - Mapping between external DTOs and internal models (different class hierarchies)
|
||||
/// - Handling property renames across versions
|
||||
/// - Custom property pairing logic
|
||||
///
|
||||
/// If null (default), properties are matched by name.
|
||||
/// Callback is invoked once during mapping build phase and result is cached.
|
||||
///
|
||||
/// Performance: ZERO overhead on same-type operations (Deserialize<T>).
|
||||
/// </summary>
|
||||
public PropertyMapperDelegate? PropertyMapper { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -29,15 +29,20 @@ public abstract class TypeMetadataBase
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets serializable properties ordered alphabetically by name.
|
||||
/// Gets serializable properties with stable property index ordering.
|
||||
/// Properties are ordered hierarchy-aware (base class first, then derived)
|
||||
/// and alphabetically within each level.
|
||||
/// This ensures PropertyIndex remains stable across inheritance hierarchies:
|
||||
/// - Base properties always get the same index in both base and derived types
|
||||
/// - Supports safe deserialization of derived types into base types
|
||||
/// - Enables cross-version compatibility when new derived properties are added
|
||||
/// Results are cached per type and requiresWrite combination.
|
||||
/// Ordering ensures deterministic serialization across all platforms (Windows, WASM, iOS, Android).
|
||||
/// </summary>
|
||||
/// <param name="type">The type to analyze.</param>
|
||||
/// <param name="requiresRead">Whether the property must be readable (always true in practice).</param>
|
||||
/// <param name="requiresWrite">Whether the property must be writable (true for deserialization).</param>
|
||||
/// <returns>Array of properties ordered by name (cached).</returns>
|
||||
protected static PropertyInfo[] GetSerializableProperties(
|
||||
/// <returns>Array of properties with stable indices (cached).</returns>
|
||||
public static PropertyInfo[] GetSerializableProperties(
|
||||
Type type,
|
||||
bool requiresRead = true,
|
||||
bool requiresWrite = false)
|
||||
|
|
@ -45,13 +50,36 @@ public abstract class TypeMetadataBase
|
|||
return OrderedPropertiesCache.GetOrAdd((type, requiresWrite), static key =>
|
||||
{
|
||||
var (t, needsWrite) = key;
|
||||
return t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead &&
|
||||
(!needsWrite || p.CanWrite) &&
|
||||
p.GetIndexParameters().Length == 0 &&
|
||||
!HasJsonIgnoreAttribute(p))
|
||||
.OrderBy(static p => p.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
// Build inheritance hierarchy (base -> derived)
|
||||
var hierarchy = new Stack<Type>();
|
||||
var currentType = t;
|
||||
while (currentType != null && currentType != typeof(object))
|
||||
{
|
||||
hierarchy.Push(currentType);
|
||||
currentType = currentType.BaseType;
|
||||
}
|
||||
|
||||
// Collect properties level by level (base first)
|
||||
var allProperties = new List<PropertyInfo>();
|
||||
|
||||
while (hierarchy.Count > 0)
|
||||
{
|
||||
var levelType = hierarchy.Pop();
|
||||
|
||||
// Get properties declared at this level only
|
||||
var levelProperties = levelType
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(p => p.CanRead &&
|
||||
(!needsWrite || p.CanWrite) &&
|
||||
p.GetIndexParameters().Length == 0 &&
|
||||
!HasJsonIgnoreAttribute(p))
|
||||
.OrderBy(static p => p.Name, StringComparer.Ordinal); // Alphabetical within level
|
||||
|
||||
allProperties.AddRange(levelProperties);
|
||||
}
|
||||
|
||||
return allProperties.ToArray();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue