diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index dcf3465..02b9ce9 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(dotnet build:*)",
- "Bash(dotnet test:*)"
+ "Bash(dotnet test:*)",
+ "Bash(dotnet run:*)",
+ "Bash(dir /B /O-D *.log)"
]
}
}
diff --git a/AyCode.Core/Serializers/AcSerializerCommon.cs b/AyCode.Core/Serializers/AcSerializerCommon.cs
index 4311547..7bfd6ca 100644
--- a/AyCode.Core/Serializers/AcSerializerCommon.cs
+++ b/AyCode.Core/Serializers/AcSerializerCommon.cs
@@ -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
+
+ ///
+ /// 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.
+ ///
+ public sealed class PropertyMappingCache
+ {
+ private readonly ConcurrentDictionary<(Type Source, Type Dest), PropertyMappingInfo> _cache = new();
+
+ ///
+ /// Gets or builds property mapping between two types.
+ /// Result is cached for subsequent calls.
+ ///
+ public PropertyMappingInfo GetOrBuild(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func 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 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());
+ }
+
+ ///
+ /// Checks if two property types are compatible for mapping.
+ /// Handles exact match, inheritance, nullable unwrapping, and numeric conversions.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Contains property mapping information for a source->destination type pair.
+ /// Immutable and thread-safe.
+ ///
+ public sealed class PropertyMappingInfo
+ {
+ public PropertyMappingInfo(IReadOnlyList<(PropertyInfo Source, PropertyInfo Dest)> mappings)
+ {
+ Mappings = mappings;
+ }
+
+ ///
+ /// List of source->destination property pairs.
+ ///
+ public IReadOnlyList<(PropertyInfo Source, PropertyInfo Dest)> Mappings { get; }
+ }
+
+ ///
+ /// Binary-specific index-to-index mapping for cross-type deserialization.
+ /// Maps source PropertyIndex to destination PropertyIndex.
+ /// Thread-safe and cached.
+ ///
+ public sealed class BinaryIndexMappingCache
+ {
+ private readonly ConcurrentDictionary<(Type Source, Type Dest), int[]> _cache = new();
+
+ ///
+ /// 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).
+ ///
+ public int[] GetOrBuild(Type sourceType, Type destType, PropertyMapperDelegate? customMapper, Func 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 getOrderedProperties)
+ {
+ var sourceProps = getOrderedProperties(sourceType);
+ var destProps = getOrderedProperties(destType);
+
+ // Build lookup: property name -> destination index
+ var destIndexByName = new Dictionary(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
///
diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs
new file mode 100644
index 0000000..fb85666
--- /dev/null
+++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs
@@ -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();
+
+ ///
+ /// Helper to get index mapping for cross-type operations.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static int[] GetIndexMapping(Type sourceType, Type destType, PropertyMapperDelegate? customMapper)
+ => IndexMappingCache.GetOrBuild(sourceType, destType, customMapper, CrossTypeDeserializerBase.GetOrderedProperties);
+
+ #region Cross-Type Deserialization
+
+ ///
+ /// 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.
+ ///
+ /// The source type that was serialized
+ /// The destination type to deserialize into
+ /// Binary data to deserialize
+ /// Deserialized instance of TDest
+ public static TDest? Deserialize(ReadOnlySpan data)
+ => Deserialize(data, AcBinarySerializerOptions.Default);
+
+ ///
+ /// Deserializes binary data from TSource type to TDest type with options.
+ /// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
+ ///
+ /// The source type that was serialized
+ /// The destination type to deserialize into
+ /// Binary data to deserialize
+ /// Deserialization options (use PropertyMapper for custom mapping)
+ /// Deserialized instance of TDest
+ public static TDest? Deserialize(ReadOnlySpan 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(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);
+ }
+ }
+
+ ///
+ /// Deserializes binary data from TSource type to TDest type (byte array overload).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static TDest? Deserialize(byte[] data)
+ => Deserialize(data.AsSpan());
+
+ ///
+ /// Deserializes binary data from TSource type to TDest type with options (byte array overload).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static TDest? Deserialize(byte[] data, AcBinarySerializerOptions options)
+ => Deserialize(data.AsSpan(), options);
+
+ #endregion
+
+ #region Cross-Type Populate
+
+ ///
+ /// Populates existing TDest instance with data serialized as TSource.
+ /// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
+ ///
+ /// The source type that was serialized
+ /// The destination type to populate
+ /// Binary data to deserialize
+ /// Existing instance to populate
+ public static void Populate(ReadOnlySpan data, TDest target)
+ where TDest : class
+ => Populate(data, target, AcBinarySerializerOptions.Default);
+
+ ///
+ /// Populates existing TDest instance with data serialized as TSource with options.
+ /// Supports cross-type mapping with automatic property name matching or custom PropertyMapper.
+ ///
+ /// The source type that was serialized
+ /// The destination type to populate
+ /// Binary data to deserialize
+ /// Existing instance to populate
+ /// Deserialization options (use PropertyMapper for custom mapping)
+ public static void Populate(ReadOnlySpan 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);
+ }
+ }
+
+ ///
+ /// Populates existing TDest instance with data serialized as TSource (byte array overload).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Populate(byte[] data, TDest target)
+ where TDest : class
+ => Populate(data.AsSpan(), target);
+
+ ///
+ /// Populates existing TDest instance with data serialized as TSource with options (byte array overload).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Populate(byte[] data, TDest target, AcBinarySerializerOptions options)
+ where TDest : class
+ => Populate(data.AsSpan(), target, options);
+
+ #endregion
+
+ #region Helper Methods
+
+ ///
+ /// Reads a value with index mapping applied.
+ /// Maps source PropertyIndex to destination PropertyIndex using the provided mapping.
+ ///
+ 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
+ };
+ }
+
+ ///
+ /// Reads an object using index mapping for property resolution.
+ ///
+ 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;
+ }
+
+ ///
+ /// Populates an object using index mapping.
+ /// Source property indices are remapped to destination indices.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Maps source property index to destination index using mapping array.
+ /// Returns -1 if no mapping exists.
+ ///
+ [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
+ }
+
+ ///
+ /// Common logic for populating a single property value.
+ /// Shared between normal populate and cross-type populate.
+ ///
+ 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
+}
diff --git a/AyCode.Core/Serializers/CrossTypeDeserializerBase.cs b/AyCode.Core/Serializers/CrossTypeDeserializerBase.cs
new file mode 100644
index 0000000..79f0767
--- /dev/null
+++ b/AyCode.Core/Serializers/CrossTypeDeserializerBase.cs
@@ -0,0 +1,63 @@
+using System.Reflection;
+using AyCode.Core.Serializers.Jsons;
+using static AyCode.Core.Serializers.TypeMetadataBase;
+
+namespace AyCode.Core.Serializers;
+
+///
+/// Utility class providing common cross-type deserialization functionality.
+/// Shared by both JSON and Binary deserializers.
+///
+public static class CrossTypeDeserializerBase
+{
+ ///
+ /// Gets ordered properties for a type using stable PropertyIndex ordering.
+ /// Wrapper around TypeMetadataBase.GetSerializableProperties for consistency.
+ ///
+ public static PropertyInfo[] GetOrderedProperties(Type type)
+ => GetSerializableProperties(type, requiresRead: true, requiresWrite: true);
+
+ ///
+ /// Checks if two types are the same (fast path detection).
+ ///
+ public static bool IsSameType(Type sourceType, Type destType)
+ => sourceType == destType;
+
+ ///
+ /// Validates that target object for populate is not null.
+ ///
+ public static void ValidatePopulateTarget(TDest target) where TDest : class
+ {
+ if (target == null)
+ throw new ArgumentNullException(nameof(target));
+ }
+
+ ///
+ /// Checks if data is empty or null marker.
+ ///
+ public static bool IsEmptyData(ReadOnlySpan data, byte nullMarker)
+ {
+ if (data.Length == 0) return true;
+ if (data.Length == 1 && data[0] == nullMarker) return true;
+ return false;
+ }
+
+ ///
+ /// Checks if JSON data is effectively empty.
+ ///
+ public static bool IsEmptyJsonData(ReadOnlySpan 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;
+ }
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonSerializerOptions.cs b/AyCode.Core/Serializers/Jsons/AcJsonSerializerOptions.cs
index fa951a1..72be84b 100644
--- a/AyCode.Core/Serializers/Jsons/AcJsonSerializerOptions.cs
+++ b/AyCode.Core/Serializers/Jsons/AcJsonSerializerOptions.cs
@@ -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,
}
+///
+/// Delegate for custom property mapping during cross-type deserialization/population.
+/// Enables mapping between different class hierarchies or renamed properties.
+///
+/// Property from the source type being deserialized
+/// Target type being populated
+/// Mapped destination property, or null to skip this property
+public delegate PropertyInfo? PropertyMapperDelegate(PropertyInfo sourceProperty, Type destinationType);
+
public abstract class AcSerializerOptions
{
public abstract AcSerializerType SerializerType { get; init; }
+
///
/// Whether to use $id/$ref reference handling for circular references.
/// Default: true
@@ -23,6 +35,22 @@ public abstract class AcSerializerOptions
/// Default: byte.MaxValue
///
public byte MaxDepth { get; init; } = byte.MaxValue;
+
+ ///
+ /// 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>).
+ ///
+ public PropertyMapperDelegate? PropertyMapper { get; init; }
}
///
diff --git a/AyCode.Core/Serializers/TypeMetadataBase.cs b/AyCode.Core/Serializers/TypeMetadataBase.cs
index ac31576..e624144 100644
--- a/AyCode.Core/Serializers/TypeMetadataBase.cs
+++ b/AyCode.Core/Serializers/TypeMetadataBase.cs
@@ -29,29 +29,57 @@ public abstract class TypeMetadataBase
}
///
- /// 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).
///
/// The type to analyze.
/// Whether the property must be readable (always true in practice).
/// Whether the property must be writable (true for deserialization).
- /// Array of properties ordered by name (cached).
- protected static PropertyInfo[] GetSerializableProperties(
- Type type,
- bool requiresRead = true,
+ /// Array of properties with stable indices (cached).
+ public static PropertyInfo[] GetSerializableProperties(
+ Type type,
+ bool requiresRead = true,
bool requiresWrite = false)
{
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();
+ 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();
+
+ 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();
});
}
}