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": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(dotnet build:*)",
|
"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.Reflection;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using AyCode.Core.Serializers.Expressions;
|
using AyCode.Core.Serializers.Expressions;
|
||||||
|
using AyCode.Core.Serializers.Jsons;
|
||||||
using LExpression = System.Linq.Expressions.Expression;
|
using LExpression = System.Linq.Expressions.Expression;
|
||||||
using LExpressionType = System.Linq.Expressions.ExpressionType;
|
using LExpressionType = System.Linq.Expressions.ExpressionType;
|
||||||
|
|
||||||
|
|
@ -775,6 +776,187 @@ public static class AcSerializerCommon
|
||||||
|
|
||||||
#endregion
|
#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
|
#region Deserialization Reference Tracking
|
||||||
|
|
||||||
/// <summary>
|
/// <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;
|
namespace AyCode.Core.Serializers.Jsons;
|
||||||
|
|
||||||
public enum AcSerializerType : byte
|
public enum AcSerializerType : byte
|
||||||
|
|
@ -6,9 +8,19 @@ public enum AcSerializerType : byte
|
||||||
Binary = 1,
|
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 class AcSerializerOptions
|
||||||
{
|
{
|
||||||
public abstract AcSerializerType SerializerType { get; init; }
|
public abstract AcSerializerType SerializerType { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether to use $id/$ref reference handling for circular references.
|
/// Whether to use $id/$ref reference handling for circular references.
|
||||||
/// Default: true
|
/// Default: true
|
||||||
|
|
@ -23,6 +35,22 @@ public abstract class AcSerializerOptions
|
||||||
/// Default: byte.MaxValue
|
/// Default: byte.MaxValue
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte MaxDepth { get; init; } = byte.MaxValue;
|
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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,20 @@ public abstract class TypeMetadataBase
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// Results are cached per type and requiresWrite combination.
|
||||||
/// Ordering ensures deterministic serialization across all platforms (Windows, WASM, iOS, Android).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="type">The type to analyze.</param>
|
/// <param name="type">The type to analyze.</param>
|
||||||
/// <param name="requiresRead">Whether the property must be readable (always true in practice).</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>
|
/// <param name="requiresWrite">Whether the property must be writable (true for deserialization).</param>
|
||||||
/// <returns>Array of properties ordered by name (cached).</returns>
|
/// <returns>Array of properties with stable indices (cached).</returns>
|
||||||
protected static PropertyInfo[] GetSerializableProperties(
|
public static PropertyInfo[] GetSerializableProperties(
|
||||||
Type type,
|
Type type,
|
||||||
bool requiresRead = true,
|
bool requiresRead = true,
|
||||||
bool requiresWrite = false)
|
bool requiresWrite = false)
|
||||||
|
|
@ -45,13 +50,36 @@ public abstract class TypeMetadataBase
|
||||||
return OrderedPropertiesCache.GetOrAdd((type, requiresWrite), static key =>
|
return OrderedPropertiesCache.GetOrAdd((type, requiresWrite), static key =>
|
||||||
{
|
{
|
||||||
var (t, needsWrite) = key;
|
var (t, needsWrite) = key;
|
||||||
return t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
|
||||||
|
// 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 &&
|
.Where(p => p.CanRead &&
|
||||||
(!needsWrite || p.CanWrite) &&
|
(!needsWrite || p.CanWrite) &&
|
||||||
p.GetIndexParameters().Length == 0 &&
|
p.GetIndexParameters().Length == 0 &&
|
||||||
!HasJsonIgnoreAttribute(p))
|
!HasJsonIgnoreAttribute(p))
|
||||||
.OrderBy(static p => p.Name, StringComparer.Ordinal)
|
.OrderBy(static p => p.Name, StringComparer.Ordinal); // Alphabetical within level
|
||||||
.ToArray();
|
|
||||||
|
allProperties.AddRange(levelProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allProperties.ToArray();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue