From f388afcede92920ad722c49d0a5380ae55fe8d31 Mon Sep 17 00:00:00 2001 From: Loretta Date: Fri, 2 Jan 2026 07:45:42 +0100 Subject: [PATCH] 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 and Populate overloads. - Update settings.local.json to allow additional Bash commands for development. - Improve documentation and add missing using directives. --- .claude/settings.local.json | 4 +- AyCode.Core/Serializers/AcSerializerCommon.cs | 182 +++++++++ .../AcBinaryDeserializer.CrossType.cs | 384 ++++++++++++++++++ .../Serializers/CrossTypeDeserializerBase.cs | 63 +++ .../Jsons/AcJsonSerializerOptions.cs | 28 ++ AyCode.Core/Serializers/TypeMetadataBase.cs | 54 ++- 6 files changed, 701 insertions(+), 14 deletions(-) create mode 100644 AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs create mode 100644 AyCode.Core/Serializers/CrossTypeDeserializerBase.cs 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(); }); } }